Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51a6b7efaa | ||
|
|
30f5346035 | ||
|
|
e0302e5f34 | ||
|
|
0425841032 | ||
|
|
156550f7eb | ||
|
|
a1648adf12 | ||
|
|
8182bd6b3c | ||
|
|
484ac5f463 | ||
|
|
98e3a6b952 | ||
|
|
f6f3147afb | ||
|
|
54b26511a1 | ||
|
|
8ef91e1266 | ||
|
|
b2689f96a4 | ||
|
|
1b23bdcf15 | ||
|
|
2e63848e0e | ||
|
|
3a748aa1aa | ||
|
|
4574f1e2b2 | ||
|
|
081b167172 | ||
|
|
a818a7004f | ||
|
|
5bc5a6c8b2 | ||
|
|
6c8a39d269 | ||
|
|
db69d5ac39 | ||
|
|
ee400f424b | ||
|
|
ba93e2fa35 | ||
|
|
591b240d12 | ||
|
|
880812f48d | ||
|
|
445ce92dbc | ||
|
|
7f582bb355 | ||
|
|
59f9a1443b | ||
|
|
bcb56d8229 | ||
|
|
1ca2cd8ec2 | ||
|
|
717d8b718a | ||
|
|
363f03a92d | ||
|
|
c5d15a14c9 | ||
|
|
75dc3dd72b | ||
|
|
110e050d20 | ||
|
|
ebcfe49ed6 | ||
|
|
bc8ac08b9a | ||
|
|
309fbdbe7a | ||
|
|
11f831d820 | ||
|
|
806fb6cf29 | ||
|
|
cc2702b825 | ||
|
|
af2589e60b | ||
|
|
971c8a4d8b | ||
|
|
59364e0c75 | ||
|
|
ac83c4c27d | ||
|
|
aa10f962ea | ||
|
|
1f3e531d7b | ||
|
|
ca6ca3f477 | ||
|
|
1c9c4fcec3 | ||
|
|
8f68e24057 | ||
|
|
2374f67ffc | ||
|
|
fea8e8b305 | ||
|
|
79a7e460be | ||
|
|
f48db8ee4e | ||
|
|
ba2a0389fa | ||
|
|
6309a49c37 | ||
|
|
b1291d3ee2 | ||
|
|
18c001e9c5 | ||
|
|
c2c6b265d4 | ||
|
|
1e50b66407 | ||
|
|
2fb2155d79 | ||
|
|
3429c498f9 | ||
|
|
dc7b14e323 | ||
|
|
5d675b9cef | ||
|
|
bf9f0e1fc2 | ||
|
|
02967d9258 | ||
|
|
343176120e | ||
|
|
c0b4dace87 | ||
|
|
b6e8d63fef | ||
|
|
60c07da140 | ||
|
|
f89afc0e05 | ||
|
|
ca0b1ed9ae | ||
|
|
555438a02a | ||
|
|
97e78624bb | ||
|
|
eab1e8db67 | ||
|
|
8e6392e503 | ||
|
|
8b99f2411f | ||
|
|
98905b9c81 | ||
|
|
b7e1df9916 | ||
|
|
3089cab88d | ||
|
|
50b20eaa05 | ||
|
|
3ab42bf588 | ||
|
|
84423a0096 | ||
|
|
98dda8a51b | ||
|
|
42baa5cb78 | ||
|
|
11fd7fcd71 | ||
|
|
d6950948fa | ||
|
|
9693793bba | ||
|
|
a72f012851 | ||
|
|
1368709f4e | ||
|
|
58bc08a045 |
321
App.tsx
321
App.tsx
@@ -18,14 +18,24 @@ 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 { applySyncPayload } from './application/syncPayload';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildSyncPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
} from './application/localVaultBackups';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_DEBUG_HOTKEYS,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from './infrastructure/config/storageKeys';
|
||||
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -222,6 +232,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [workspaceFocusStyle]);
|
||||
|
||||
const {
|
||||
isInitialized: isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
@@ -307,6 +318,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.showSftpTab && activeTabId === 'sftp') {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}, [settings.showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
@@ -389,6 +406,129 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
const buildCurrentSyncPayload = useCallback(() => {
|
||||
let effectivePortForwardingRules = portForwardingRulesForSync;
|
||||
if (effectivePortForwardingRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
effectivePortForwardingRules = stored.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return buildSyncPayload(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts: getEffectiveKnownHosts(knownHosts),
|
||||
groupConfigs,
|
||||
},
|
||||
effectivePortForwardingRules,
|
||||
);
|
||||
}, [
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
hosts,
|
||||
identities,
|
||||
keys,
|
||||
knownHosts,
|
||||
portForwardingRulesForSync,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
]);
|
||||
|
||||
const [startupSyncSafetyReady, setStartupSyncSafetyReady] = useState(false);
|
||||
// buildCurrentSyncPayload's identity changes each time the vault
|
||||
// settles. The retry effect below watches the underlying data arrays
|
||||
// for hydration progress, and uses the ref to always read the latest
|
||||
// builder without pulling buildCurrentSyncPayload itself into deps
|
||||
// (its identity churns on unrelated state updates too).
|
||||
const buildCurrentSyncPayloadRef = useRef(buildCurrentSyncPayload);
|
||||
useEffect(() => {
|
||||
buildCurrentSyncPayloadRef.current = buildCurrentSyncPayload;
|
||||
}, [buildCurrentSyncPayload]);
|
||||
|
||||
const versionBackupAttemptedRef = useRef(false);
|
||||
// Two-stage gate: once the vault has initialized we open the auto-sync
|
||||
// gate immediately — the hook's own hasMeaningfulSyncData guard and
|
||||
// the cross-window restore barrier prevent an empty-but-not-yet-
|
||||
// hydrated snapshot from overwriting cloud data. The version-change
|
||||
// backup itself is best-effort and retries below as vault data arrives.
|
||||
useEffect(() => {
|
||||
if (isVaultInitialized && !startupSyncSafetyReady) {
|
||||
setStartupSyncSafetyReady(true);
|
||||
}
|
||||
}, [isVaultInitialized, startupSyncSafetyReady]);
|
||||
|
||||
// Retry the version-change backup as hosts/keys/snippets become
|
||||
// available. ensureVersionChangeBackup refuses to advance the stored
|
||||
// version stamp when the observed payload is empty, so running this
|
||||
// effect repeatedly is safe and eventually latches once the vault has
|
||||
// hydrated enough to be backed up (or the user genuinely stays empty,
|
||||
// in which case the effect continues to no-op).
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || versionBackupAttemptedRef.current) return;
|
||||
const payload = buildCurrentSyncPayloadRef.current();
|
||||
if (!hasMeaningfulSyncData(payload)) return;
|
||||
versionBackupAttemptedRef.current = true;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const info = await netcattyBridge.get()?.getAppInfo?.();
|
||||
await ensureVersionChangeBackup(payload, info?.version ?? null);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
// Reset the latch so a later data change (or the next mount)
|
||||
// can retry. ensureVersionChangeBackup already leaves the
|
||||
// version stamp untouched on failure, so retrying is safe.
|
||||
versionBackupAttemptedRef.current = false;
|
||||
}
|
||||
console.error('[App] Failed to create version-change backup:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVaultInitialized, hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts]);
|
||||
|
||||
// Memoized "apply a remote payload safely" callback. Stable identity
|
||||
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
|
||||
// on unrelated App-level state changes (which would churn the debounced
|
||||
// auto-sync useEffect dep chain).
|
||||
const handleApplySyncPayload = useCallback(
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
}),
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
|
||||
}),
|
||||
[
|
||||
buildCurrentSyncPayload,
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
settings.rehydrateAllFromStorage,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
|
||||
hosts,
|
||||
@@ -401,13 +541,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
onApplyPayload: (payload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
});
|
||||
},
|
||||
startupReady: startupSyncSafetyReady,
|
||||
onApplyPayload: handleApplySyncPayload,
|
||||
});
|
||||
|
||||
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
|
||||
@@ -553,7 +688,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
const terminalActions = ['copy', 'paste', 'pasteSelection', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
if (terminalActions.includes(binding.action)) {
|
||||
if (isTerminalElement) {
|
||||
return;
|
||||
@@ -858,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);
|
||||
@@ -891,15 +1030,102 @@ 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.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
// doesn't land on a hidden tab (which would get redirected back) and so
|
||||
// number shortcuts don't shift.
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs]
|
||||
: ['vault', ...orderedTabs];
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
// Build complete tab list: vault + sftp + sessions/workspaces
|
||||
const allTabs = ['vault', 'sftp', ...orderedTabs];
|
||||
if (num <= allTabs.length) {
|
||||
setActiveTabId(allTabs[num - 1]);
|
||||
}
|
||||
@@ -907,8 +1133,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
case 'nextTab': {
|
||||
// Build complete tab list: vault + sftp + sessions/workspaces
|
||||
const allTabs = ['vault', 'sftp', ...orderedTabs];
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const currentIdx = allTabs.indexOf(currentId);
|
||||
if (currentIdx !== -1 && allTabs.length > 0) {
|
||||
@@ -920,8 +1144,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
case 'prevTab': {
|
||||
// Build complete tab list: vault + sftp + sessions/workspaces
|
||||
const allTabs = ['vault', 'sftp', ...orderedTabs];
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const currentIdx = allTabs.indexOf(currentId);
|
||||
if (currentIdx !== -1 && allTabs.length > 0) {
|
||||
@@ -934,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':
|
||||
@@ -968,7 +1224,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setActiveTabId('vault');
|
||||
break;
|
||||
case 'openSftp':
|
||||
setActiveTabId('sftp');
|
||||
if (settings.showSftpTab) {
|
||||
setActiveTabId('sftp');
|
||||
}
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
case 'commandPalette':
|
||||
@@ -1056,7 +1314,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast]);
|
||||
}, [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) => {
|
||||
@@ -1416,6 +1674,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
onCloseTabsBatch={closeTabsBatch}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
@@ -1424,6 +1683,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
@@ -1469,6 +1729,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
|
||||
onRunSnippet={runSnippet}
|
||||
onOpenLogView={openLogView}
|
||||
showRecentHosts={settings.showRecentHosts}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
navigateToSection={navigateToSection}
|
||||
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
||||
/>
|
||||
@@ -1544,6 +1806,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
@@ -1582,6 +1846,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onSelectTab={(tabId) => {
|
||||
|
||||
@@ -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',
|
||||
@@ -202,6 +207,10 @@ const en: Messages = {
|
||||
'settings.vault.title': 'Vault',
|
||||
'settings.vault.showRecentHosts': 'Show recently connected hosts',
|
||||
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
@@ -297,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.',
|
||||
@@ -439,10 +454,15 @@ const en: Messages = {
|
||||
'sync.toast.completedMessage': 'Sync completed successfully',
|
||||
'sync.toast.errorTitle': 'Sync Error',
|
||||
'sync.autoSync.failedTitle': 'Sync failed',
|
||||
'sync.autoSync.inspectFailedTitle': 'Sync paused',
|
||||
'sync.autoSync.inspectFailedMessage': 'Could not reach the cloud to check for changes. Auto-sync will retry when data changes or the app is restarted.',
|
||||
'sync.autoSync.syncedTitle': 'Synced from cloud',
|
||||
'sync.autoSync.syncedMessage': 'Your data has been updated from the cloud.',
|
||||
'sync.autoSync.noProvider': 'No cloud provider connected. Open Settings → Sync & Cloud to connect one.',
|
||||
'sync.autoSync.alreadySyncing': 'Sync is already in progress.',
|
||||
'sync.autoSync.restoreInProgress': 'A vault restore is in progress in another window. Please wait for it to finish.',
|
||||
'sync.autoSync.interruptedApplyTitle': 'Sync paused — previous restore interrupted',
|
||||
'sync.autoSync.interruptedApplyMessage': 'A previous restore did not finish cleanly, so the local vault may be inconsistent. Open Settings → Sync & Cloud → Restore and apply a protective backup before auto-sync resumes.',
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
@@ -458,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',
|
||||
@@ -1152,7 +1196,7 @@ const en: Messages = {
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal (Ctrl+F)',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal',
|
||||
'terminal.toolbar.search': 'Search',
|
||||
'terminal.toolbar.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
@@ -1208,6 +1252,7 @@ const en: Messages = {
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
@@ -1386,6 +1431,31 @@ const en: Messages = {
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
|
||||
'cloudSync.localBackups.maxCount': 'Max backups',
|
||||
'cloudSync.localBackups.maxSaved': 'Saved backup retention: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Please enter a number between 1 and 100.',
|
||||
'cloudSync.localBackups.empty': 'No local backups yet.',
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'cloudSync.localBackups.restore': 'Restore',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
||||
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Restore',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Cancel',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Local backups unavailable',
|
||||
'cloudSync.localBackups.unavailableDesc': 'This platform does not expose a secure keychain to Netcatty, so local backups cannot be written safely. Install Netcatty on a system with a supported keychain to enable the local backup history.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Master key required',
|
||||
'cloudSync.localBackups.lockedDesc': 'Set up or unlock your master key before restoring a backup, so restored credentials remain encrypted.',
|
||||
'cloudSync.revisionHistory.viewButton': 'History',
|
||||
'cloudSync.revisionHistory.title': 'Vault Version History',
|
||||
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
|
||||
@@ -1569,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 *',
|
||||
@@ -1740,12 +1813,16 @@ const en: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Connected via API key',
|
||||
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
|
||||
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
|
||||
'ai.codex.notConnected': 'Not connected',
|
||||
'ai.codex.statusUnknown': 'Status unknown',
|
||||
'ai.codex.path': 'Path:',
|
||||
@@ -1756,7 +1833,6 @@ const en: Messages = {
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
@@ -1789,6 +1865,17 @@ const en: Messages = {
|
||||
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': 'User Skills',
|
||||
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
|
||||
'ai.userSkills.openFolder': 'Open Skills Folder',
|
||||
'ai.userSkills.reload': 'Reload Skills',
|
||||
'ai.userSkills.location': 'Location',
|
||||
'ai.userSkills.loading': 'Scanning user skills...',
|
||||
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
|
||||
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
|
||||
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
|
||||
'ai.userSkills.status.ready': 'Ready',
|
||||
'ai.userSkills.status.warning': 'Warning',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
@@ -1843,6 +1930,7 @@ const en: Messages = {
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
@@ -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': '名称',
|
||||
@@ -186,6 +191,10 @@ const zhCN: Messages = {
|
||||
'settings.vault.title': '主机库',
|
||||
'settings.vault.showRecentHosts': '显示最近连接的主机',
|
||||
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -258,10 +267,15 @@ const zhCN: Messages = {
|
||||
'sync.toast.completedMessage': '同步完成',
|
||||
'sync.toast.errorTitle': '同步错误',
|
||||
'sync.autoSync.failedTitle': '同步失败',
|
||||
'sync.autoSync.inspectFailedTitle': '同步已暂停',
|
||||
'sync.autoSync.inspectFailedMessage': '无法访问云端以检查变更。数据改动或下次启动时会自动重试。',
|
||||
'sync.autoSync.syncedTitle': '已从云端同步',
|
||||
'sync.autoSync.syncedMessage': '你的数据已从云端更新。',
|
||||
'sync.autoSync.noProvider': '未连接云同步 provider。请打开 设置 → Sync & Cloud 进行连接。',
|
||||
'sync.autoSync.alreadySyncing': '同步正在进行中。',
|
||||
'sync.autoSync.restoreInProgress': '另一个窗口中的本地备份恢复正在进行中,请等待其完成。',
|
||||
'sync.autoSync.interruptedApplyTitle': '同步已暂停 — 上次恢复未完成',
|
||||
'sync.autoSync.interruptedApplyMessage': '上次本地恢复过程未正常结束,本地数据可能处于半应用状态。请打开「设置 → Sync & Cloud → 恢复」,从保护性备份中恢复后再让自动同步继续。',
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
@@ -277,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': '刚刚',
|
||||
@@ -765,7 +803,7 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
'terminal.toolbar.terminalSettings': '终端设置',
|
||||
'terminal.toolbar.searchTerminal': '搜索终端 (Ctrl+F)',
|
||||
'terminal.toolbar.searchTerminal': '搜索终端',
|
||||
'terminal.toolbar.search': '搜索',
|
||||
'terminal.toolbar.broadcast': '广播',
|
||||
'terminal.toolbar.broadcastEnable': '启用广播模式',
|
||||
@@ -821,6 +859,7 @@ const zhCN: Messages = {
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
@@ -999,6 +1038,31 @@ const zhCN: Messages = {
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.localBackups.title': '本地备份历史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
|
||||
'cloudSync.localBackups.maxCount': '最多保留',
|
||||
'cloudSync.localBackups.maxSaved': '已保存保留数量:{count}',
|
||||
'cloudSync.localBackups.maxInvalid': '请输入 1 到 100 之间的数字。',
|
||||
'cloudSync.localBackups.empty': '还没有本地备份。',
|
||||
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
|
||||
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'cloudSync.localBackups.restore': '恢复',
|
||||
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
|
||||
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
|
||||
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
|
||||
'cloudSync.localBackups.restoreConfirmButton': '恢复',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': '取消',
|
||||
'cloudSync.localBackups.unavailableTitle': '无法使用本地备份',
|
||||
'cloudSync.localBackups.unavailableDesc': '当前平台未提供受支持的安全密钥库,Netcatty 无法安全地写入本地备份。请在支持系统钥匙串的环境中运行,或改用云同步保留恢复点。',
|
||||
'cloudSync.localBackups.lockedTitle': '需要主密钥',
|
||||
'cloudSync.localBackups.lockedDesc': '请先配置或解锁主密钥再恢复备份,以确保恢复后的凭据仍保持加密。',
|
||||
'cloudSync.revisionHistory.viewButton': '历史版本',
|
||||
'cloudSync.revisionHistory.title': '主机库版本历史',
|
||||
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
|
||||
@@ -1325,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 转义序列访问本地剪贴板。',
|
||||
@@ -1577,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': '私钥 *',
|
||||
@@ -1748,12 +1821,16 @@ const zhCN: Messages = {
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
|
||||
'ai.codex.connectedApiKey': '已通过 API Key 连接',
|
||||
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
|
||||
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
|
||||
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
|
||||
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty),否则 Codex 无法鉴权。',
|
||||
'ai.codex.notConnected': '未连接',
|
||||
'ai.codex.statusUnknown': '状态未知',
|
||||
'ai.codex.path': '路径:',
|
||||
@@ -1764,7 +1841,6 @@ const zhCN: Messages = {
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
@@ -1797,6 +1873,17 @@ const zhCN: Messages = {
|
||||
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
'ai.userSkills.title': '用户 Skills',
|
||||
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills,默认只注入轻量索引,只有在请求明显命中某个 skill 时才展开正文。',
|
||||
'ai.userSkills.openFolder': '打开 Skills 文件夹',
|
||||
'ai.userSkills.reload': '重新加载 Skills',
|
||||
'ai.userSkills.location': '位置',
|
||||
'ai.userSkills.loading': '正在扫描用户 skills...',
|
||||
'ai.userSkills.summary': '已就绪 {ready} 个,警告 {warnings} 个',
|
||||
'ai.userSkills.empty': '暂未发现用户 skills。打开文件夹后可添加包含 SKILL.md 的技能目录。',
|
||||
'ai.userSkills.unavailable': '当前环境不支持用户 skills。',
|
||||
'ai.userSkills.status.ready': '正常',
|
||||
'ai.userSkills.status.warning': '警告',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
@@ -1851,6 +1938,7 @@ const zhCN: Messages = {
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
495
application/localVaultBackups.ts
Normal file
495
application/localVaultBackups.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION,
|
||||
STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
|
||||
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;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalVaultBackupDetails {
|
||||
backup: LocalVaultBackupPreview;
|
||||
payload: SyncPayload;
|
||||
}
|
||||
|
||||
export const DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT = 20;
|
||||
export const MIN_LOCAL_VAULT_BACKUP_MAX_COUNT = 1;
|
||||
export const MAX_LOCAL_VAULT_BACKUP_MAX_COUNT = 100;
|
||||
|
||||
export const sanitizeLocalVaultBackupMaxCount = (value: number): number => {
|
||||
if (!Number.isFinite(value)) return DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT;
|
||||
return Math.max(
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
Math.min(MAX_LOCAL_VAULT_BACKUP_MAX_COUNT, Math.round(value)),
|
||||
);
|
||||
};
|
||||
|
||||
export const getLocalVaultBackupMaxCount = (): number => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT);
|
||||
return sanitizeLocalVaultBackupMaxCount(
|
||||
stored ?? DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
);
|
||||
};
|
||||
|
||||
export const setLocalVaultBackupMaxCount = (value: number): number => {
|
||||
const sanitized = sanitizeLocalVaultBackupMaxCount(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT, sanitized);
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCount()): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.trimVaultBackups?.({ maxCount });
|
||||
}
|
||||
|
||||
export async function getLocalVaultBackupCapabilities(): Promise<{
|
||||
encryptionAvailable: boolean;
|
||||
}> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const caps = await bridge?.getVaultBackupCapabilities?.();
|
||||
// Conservatively treat a missing bridge (non-Electron environments, early
|
||||
// boot) as unavailable so callers fall back to the locked-down UI path
|
||||
// instead of assuming capabilities they can't verify.
|
||||
return { encryptionAvailable: Boolean(caps?.encryptionAvailable) };
|
||||
}
|
||||
|
||||
export async function listLocalVaultBackups(): Promise<LocalVaultBackupPreview[]> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const entries = await bridge?.listVaultBackups?.();
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
}
|
||||
|
||||
export async function readLocalVaultBackup(id: string): Promise<LocalVaultBackupDetails | null> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readVaultBackup) return null;
|
||||
return bridge.readVaultBackup({ id });
|
||||
}
|
||||
|
||||
export async function openLocalVaultBackupDir(): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.openVaultBackupDir?.();
|
||||
}
|
||||
|
||||
export async function createLocalVaultBackup(
|
||||
payload: SyncPayload,
|
||||
options: {
|
||||
reason: LocalVaultBackupReason;
|
||||
syncDataVersion?: number;
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
maxCount?: number;
|
||||
},
|
||||
): Promise<LocalVaultBackupPreview | null> {
|
||||
// Intentional: an empty-vault backup has nothing to restore from, so we
|
||||
// early-return instead of writing a zero-entry record. Callers that rely
|
||||
// on a backup (protective-before-restore, version-change on first run)
|
||||
// must treat `null` as "no safety net this time" and continue — blocking
|
||||
// the user's flow on a missing backup would be worse than allowing the
|
||||
// apply to proceed without one.
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.createVaultBackup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
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(),
|
||||
});
|
||||
return result?.backup ?? null;
|
||||
} catch (error) {
|
||||
// The main-process bridge refuses to write backups when safeStorage is
|
||||
// unavailable (VAULT_BACKUP_ENCRYPTION_UNAVAILABLE) because SyncPayload
|
||||
// carries plaintext credentials that must never touch disk unencrypted.
|
||||
// Callers (startup version-change, protective-before-restore) intentionally
|
||||
// continue without a backup rather than blocking the user's flow, so we
|
||||
// log and return null here.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn('[localVaultBackups] Backup skipped:', message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a caller requires a protective backup and the backup
|
||||
* couldn't be written — safeStorage unavailable, bridge missing,
|
||||
* main-process rejection, disk error.
|
||||
*
|
||||
* Callers should surface this as a user-visible abort rather than
|
||||
* proceeding with the destructive apply. Separate from "nothing to
|
||||
* back up" (empty vault) which is returned as `null`.
|
||||
*/
|
||||
export class ProtectiveBackupUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ProtectiveBackupUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a protective local backup before a destructive apply (restore
|
||||
* from backup list, restore from Gist revision, cloud download applied
|
||||
* over meaningful local state).
|
||||
*
|
||||
* Returns `null` when there is nothing meaningful to back up — in that
|
||||
* case the caller can safely proceed with the apply, because there is
|
||||
* no local data to lose.
|
||||
*
|
||||
* Throws `ProtectiveBackupUnavailableError` when pre-apply state IS
|
||||
* meaningful but the backup attempt failed. Callers MUST abort the
|
||||
* destructive apply in that case and surface the error to the user,
|
||||
* otherwise we regress the exact safety contract the backup system
|
||||
* was added to enforce (the `console.error`-and-proceed pattern that
|
||||
* previously swallowed safeStorage/keychain failures and continued).
|
||||
*/
|
||||
export async function createRequiredProtectiveLocalVaultBackup(
|
||||
payload: SyncPayload,
|
||||
): Promise<LocalVaultBackupPreview | null> {
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
// Nothing to protect — an empty-vault backup would produce a
|
||||
// useless record, not a safety net.
|
||||
return null;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.createVaultBackup) {
|
||||
throw new ProtectiveBackupUnavailableError(
|
||||
'Vault backup bridge is not available in this environment.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridge.createVaultBackup({
|
||||
payload,
|
||||
reason: 'before_restore',
|
||||
maxCount: getLocalVaultBackupMaxCount(),
|
||||
});
|
||||
return result?.backup ?? null;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new ProtectiveBackupUnavailableError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long each heartbeat extends the cross-window restore barrier.
|
||||
* Short enough that an abandoned lock (crashed window, hung task)
|
||||
* clears itself quickly without user intervention. The heartbeat
|
||||
* interval below refreshes the deadline as long as the caller's task
|
||||
* is still running, so large vaults or slow keychain unlocks cannot
|
||||
* expose a mid-apply window to concurrent auto-sync even when the
|
||||
* total apply time exceeds this value.
|
||||
*/
|
||||
const RESTORE_BARRIER_HOLD_MS = 60_000;
|
||||
|
||||
/**
|
||||
* How often the heartbeat refreshes the barrier. Picked to ensure at
|
||||
* least two refreshes land before the current deadline would expire,
|
||||
* so a single missed tick (event-loop stall, GC pause) cannot drop
|
||||
* the barrier prematurely.
|
||||
*/
|
||||
const RESTORE_BARRIER_HEARTBEAT_MS = Math.max(1_000, Math.floor(RESTORE_BARRIER_HOLD_MS / 3));
|
||||
|
||||
/**
|
||||
* Run `task` while holding a cross-window "restore in progress" barrier.
|
||||
*
|
||||
* The barrier is a localStorage key readable by every window of the same
|
||||
* origin. useAutoSync reads it on each auto-sync and on each data-change
|
||||
* debounce tick, refusing to push while the deadline is still in the
|
||||
* future. We write a time-bounded deadline (rather than a boolean) so a
|
||||
* crashed window can never leave sync permanently wedged.
|
||||
*
|
||||
* While the task runs, a heartbeat timer re-writes the deadline so a
|
||||
* slow apply (large vault, slow keychain) keeps the barrier held rather
|
||||
* than exposing a post-deadline window to concurrent auto-sync. The
|
||||
* heartbeat is cleared and the barrier is released in a finally block
|
||||
* so success, throw, and unexpected early-return all converge on the
|
||||
* same cleanup.
|
||||
*/
|
||||
export async function withRestoreBarrier<T>(
|
||||
task: () => Promise<T>,
|
||||
holdMs: number = RESTORE_BARRIER_HOLD_MS,
|
||||
): Promise<T> {
|
||||
const writeDeadline = () => {
|
||||
try {
|
||||
localStorageAdapter.writeNumber(
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
Date.now() + holdMs,
|
||||
);
|
||||
} catch (error) {
|
||||
// If we can't write the barrier we still proceed — the UI-side
|
||||
// `isSyncBusy` guard and same-window debounce cancellation are a
|
||||
// secondary defense. Better to complete the restore than refuse on
|
||||
// a broken localStorage.
|
||||
console.warn('[localVaultBackups] Failed to set restore barrier:', error);
|
||||
}
|
||||
};
|
||||
|
||||
writeDeadline();
|
||||
const heartbeat = setInterval(
|
||||
writeDeadline,
|
||||
Math.max(1_000, Math.min(holdMs / 3, RESTORE_BARRIER_HEARTBEAT_MS)),
|
||||
);
|
||||
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
try {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
|
||||
} catch {
|
||||
/* ignore — the deadline will expire naturally */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the apply-in-progress sentinel record. Persisted as JSON in
|
||||
* `STORAGE_KEY_VAULT_APPLY_IN_PROGRESS` so the next session can
|
||||
* distinguish "the last apply completed cleanly" from "the last apply
|
||||
* crashed mid-way and the local vault is a partial mix of states."
|
||||
*/
|
||||
export interface VaultApplyInProgressRecord {
|
||||
startedAt: number;
|
||||
protectiveBackupId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the persisted apply-in-progress record if a previous apply
|
||||
* was interrupted before clearing it. Callers (notably auto-sync) use
|
||||
* this to refuse to push a partial-apply local state over an intact
|
||||
* cloud copy. See `applyProtectedSyncPayload` for the write side.
|
||||
*
|
||||
* `null` here means "no interrupted apply detected" — either nothing
|
||||
* was ever applied, or the last apply finished cleanly.
|
||||
*/
|
||||
export function readInterruptedVaultApply(): VaultApplyInProgressRecord | null {
|
||||
try {
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;
|
||||
const protectiveBackupId =
|
||||
typeof parsed.protectiveBackupId === 'string' ? parsed.protectiveBackupId : null;
|
||||
if (!startedAt) return null;
|
||||
return { startedAt, protectiveBackupId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the apply-in-progress sentinel. The normal completion path
|
||||
* inside `applyProtectedSyncPayload` clears it automatically; this
|
||||
* export exists so the user's explicit recovery action ("I've restored
|
||||
* from a backup, resume sync") can acknowledge the interrupted state
|
||||
* from the UI without re-running an apply.
|
||||
*/
|
||||
export function clearInterruptedVaultApply(): void {
|
||||
try {
|
||||
localStorageAdapter.remove(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
|
||||
} catch {
|
||||
/* ignore — next clean apply will overwrite */
|
||||
}
|
||||
}
|
||||
|
||||
function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void {
|
||||
try {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
|
||||
JSON.stringify(record),
|
||||
);
|
||||
} catch (error) {
|
||||
// Sentinel write is best-effort: a failure here means a later crash
|
||||
// won't be detected, but does NOT compromise the apply itself.
|
||||
// Log so a systematic storage outage is diagnosable.
|
||||
console.warn('[localVaultBackups] Failed to set apply-in-progress sentinel:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "apply a remote-sourced payload safely" helper.
|
||||
*
|
||||
* Holds the cross-window restore barrier, snapshots the pre-apply vault
|
||||
* into a protective backup, persists an apply-in-progress sentinel, and
|
||||
* only then runs the supplied `applyPayload` callback. Every destructive
|
||||
* apply path (startup merge, conflict resolution, empty-vault restore,
|
||||
* manual Gist-revision restore) must go through this so the protections
|
||||
* can't drift out of sync between the main window and the settings
|
||||
* window.
|
||||
*
|
||||
* The sentinel closes the partial-apply-then-crash window: `applyPayload`
|
||||
* writes to several localStorage keys non-atomically (hosts, keys, port-
|
||||
* forwarding rules, settings). A crash mid-sequence leaves the vault in
|
||||
* a state that is neither pre-apply nor post-apply, and the next
|
||||
* auto-sync would otherwise push that partial state over an intact cloud
|
||||
* copy. The sentinel flags "local may be inconsistent" for the next
|
||||
* session; `readInterruptedVaultApply` exposes that to callers that
|
||||
* enforce "don't auto-push a half-applied vault."
|
||||
*
|
||||
* `buildPreApplyPayload` is invoked *before* the apply to snapshot the
|
||||
* current vault. Callers pass their own React-closure builder (hosts,
|
||||
* keys, port-forwarding rules) because the caller owns that state.
|
||||
*
|
||||
* `translateProtectiveBackupFailure` converts the
|
||||
* `ProtectiveBackupUnavailableError` into a user-visible message in the
|
||||
* caller's locale. It runs only on the thrown-and-caught path.
|
||||
*/
|
||||
export function applyProtectedSyncPayload(options: {
|
||||
buildPreApplyPayload: () => SyncPayload;
|
||||
applyPayload: () => void | Promise<void>;
|
||||
translateProtectiveBackupFailure: (message: string) => string;
|
||||
}): Promise<void> {
|
||||
const { buildPreApplyPayload, applyPayload, translateProtectiveBackupFailure } = options;
|
||||
return withRestoreBarrier(async () => {
|
||||
const pre = buildPreApplyPayload();
|
||||
let protectiveBackupId: string | null = null;
|
||||
try {
|
||||
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
|
||||
protectiveBackupId = backup?.id ?? null;
|
||||
} catch (error) {
|
||||
// Destructive apply without a working safety net is exactly the
|
||||
// overwrite-without-recovery regression this module was added to
|
||||
// prevent. Surface the failure to the caller; every call site
|
||||
// currently aborts the apply and shows a user-visible error.
|
||||
if (error instanceof ProtectiveBackupUnavailableError) {
|
||||
throw new Error(translateProtectiveBackupFailure(error.message));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Mark the apply as in-progress. If the renderer crashes between
|
||||
// the first localStorage write inside `applyPayload` and the
|
||||
// successful completion below, the next session will observe this
|
||||
// sentinel and refuse to auto-sync the partial state.
|
||||
writeApplyInProgressSentinel({
|
||||
startedAt: Date.now(),
|
||||
protectiveBackupId,
|
||||
});
|
||||
|
||||
// Only clear the sentinel on successful completion. A throw from
|
||||
// `applyPayload` deliberately leaves the sentinel set: the partial
|
||||
// write is still on disk, and the next session must observe the
|
||||
// flag so auto-sync refuses to push the half-applied state.
|
||||
await applyPayload();
|
||||
clearInterruptedVaultApply();
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureVersionChangeBackup(
|
||||
payload: SyncPayload,
|
||||
currentAppVersion: string | null | undefined,
|
||||
): Promise<{ created: boolean; backup: LocalVaultBackupPreview | null }> {
|
||||
const normalizedVersion = currentAppVersion?.trim() || '';
|
||||
if (!normalizedVersion) {
|
||||
return { created: false, backup: null };
|
||||
}
|
||||
|
||||
const previousVersion =
|
||||
localStorageAdapter.readString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION)?.trim() || '';
|
||||
|
||||
if (!previousVersion) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
|
||||
return { created: false, backup: null };
|
||||
}
|
||||
|
||||
if (previousVersion === normalizedVersion) {
|
||||
return { created: false, backup: null };
|
||||
}
|
||||
|
||||
let backup: LocalVaultBackupPreview | null = null;
|
||||
const payloadIsMeaningful = hasMeaningfulSyncData(payload);
|
||||
if (payloadIsMeaningful) {
|
||||
backup = await createLocalVaultBackup(payload, {
|
||||
reason: 'app_version_change',
|
||||
sourceAppVersion: previousVersion,
|
||||
targetAppVersion: normalizedVersion,
|
||||
});
|
||||
}
|
||||
|
||||
// Only advance the stored version stamp when we actually wrote a
|
||||
// backup. Two failure modes we must NOT collapse into "advance":
|
||||
//
|
||||
// 1. Meaningful payload + backup failed (transient keychain lock,
|
||||
// disk error) — leaving the stamp unchanged means the next
|
||||
// launch retries, instead of turning a transient error into a
|
||||
// permanent "the version-change backup never happened" hole.
|
||||
//
|
||||
// 2. Non-meaningful payload at the moment we checked — on startup
|
||||
// the async vault rehydrate may not have finished yet, so
|
||||
// `hasMeaningfulSyncData` can return false transiently even
|
||||
// though the user has real data. Advancing in that window would
|
||||
// burn the one-shot upgrade opportunity; holding keeps the
|
||||
// retry available on the next launch when rehydrate has
|
||||
// completed (or when the user genuinely starts from empty and
|
||||
// the next migration-boundary arrives).
|
||||
//
|
||||
// Trade-off: a user who truly starts empty and never adds data will
|
||||
// hit this branch on every launch until they do. That's cheap (a
|
||||
// single meaningful-data check) and strictly safer than silently
|
||||
// skipping the first real upgrade backup.
|
||||
const shouldAdvanceVersion = payloadIsMeaningful && backup !== null;
|
||||
if (shouldAdvanceVersion) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
|
||||
}
|
||||
|
||||
return {
|
||||
created: Boolean(backup),
|
||||
backup,
|
||||
};
|
||||
}
|
||||
349
application/state/aiDraftState.test.ts
Normal file
349
application/state/aiDraftState.test.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
createEmptyDraft,
|
||||
ensureDraftForScopeState,
|
||||
getDraftMutationVersionState,
|
||||
getDraftUploadGenerationState,
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
selectDraftForAgentSwitch,
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from "./aiDraftState.ts";
|
||||
|
||||
test("createEmptyDraft seeds selected agent and empty inputs", () => {
|
||||
const draft = createEmptyDraft("agent-alpha");
|
||||
|
||||
assert.equal(draft.agentId, "agent-alpha");
|
||||
assert.equal(draft.text, "");
|
||||
assert.deepEqual(draft.attachments, []);
|
||||
assert.deepEqual(draft.selectedUserSkillSlugs, []);
|
||||
assert.equal(typeof draft.updatedAt, "number");
|
||||
});
|
||||
|
||||
test("resolvePanelView defaults to draft when no explicit view exists", () => {
|
||||
assert.deepEqual(resolvePanelView({}, "terminal:123"), { mode: "draft" });
|
||||
});
|
||||
|
||||
test("setDraftView records draft mode", () => {
|
||||
assert.deepEqual(setDraftView({}, "terminal:123"), {
|
||||
"terminal:123": { mode: "draft" },
|
||||
});
|
||||
});
|
||||
|
||||
test("activateDraftView clears the terminal scope's active session owner", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:123": "session-123",
|
||||
"workspace:abc": "session-workspace",
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:123": { mode: "session", sessionId: "session-123" },
|
||||
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = activateDraftView(
|
||||
activeSessionIdMap,
|
||||
panelViewByScope,
|
||||
"terminal:123",
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"workspace:abc": "session-workspace",
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:123": { mode: "draft" },
|
||||
"workspace:abc": panelViewByScope["workspace:abc"],
|
||||
});
|
||||
});
|
||||
|
||||
test("activateDraftView is a no-op when the scope already has explicit draft view", () => {
|
||||
const activeSessionIdMap = {
|
||||
"workspace:abc": "session-workspace",
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:123": { mode: "draft" },
|
||||
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = activateDraftView(
|
||||
activeSessionIdMap,
|
||||
panelViewByScope,
|
||||
"terminal:123",
|
||||
);
|
||||
|
||||
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
|
||||
test("setSessionView records target session id", () => {
|
||||
assert.deepEqual(setSessionView({}, "workspace:abc", "session-123"), {
|
||||
"workspace:abc": { mode: "session", sessionId: "session-123" },
|
||||
});
|
||||
});
|
||||
|
||||
test("clearScopeDraftState removes both the draft and current panel view", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "session-123" },
|
||||
"workspace:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:1");
|
||||
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"workspace:2": draftsByScope["workspace:2"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"workspace:2": panelViewByScope["workspace:2"],
|
||||
});
|
||||
});
|
||||
|
||||
test("clearScopeDraftState is a no-op when the scope is already cleared", () => {
|
||||
const draftsByScope = {
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"workspace:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:closed");
|
||||
|
||||
assert.equal(next.draftsByScope, draftsByScope);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
|
||||
test("updateDraftForScope creates a draft on first write and keeps other scopes untouched", () => {
|
||||
const draftsByScope = {
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
|
||||
const next = updateDraftForScope(
|
||||
draftsByScope,
|
||||
"terminal:1",
|
||||
"agent-alpha",
|
||||
(draft) => ({
|
||||
...draft,
|
||||
text: "hello world",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(next["terminal:1"].agentId, "agent-alpha");
|
||||
assert.equal(next["terminal:1"].text, "hello world");
|
||||
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
|
||||
});
|
||||
|
||||
test("ensureDraftForScopeState adds the missing scope without dropping siblings", () => {
|
||||
const draftsByScope = {
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
|
||||
const next = ensureDraftForScopeState(
|
||||
draftsByScope,
|
||||
"terminal:1",
|
||||
"agent-alpha",
|
||||
);
|
||||
|
||||
assert.equal(next["terminal:1"].agentId, "agent-alpha");
|
||||
assert.equal(next["terminal:1"].text, "");
|
||||
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
|
||||
});
|
||||
|
||||
test("ensureDraftForScopeState returns the original ref when the scope already exists", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
};
|
||||
|
||||
const next = ensureDraftForScopeState(
|
||||
draftsByScope,
|
||||
"terminal:1",
|
||||
"agent-beta",
|
||||
);
|
||||
|
||||
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"),
|
||||
"terminal:open": createEmptyDraft("agent-beta"),
|
||||
"workspace:keep": createEmptyDraft("agent-gamma"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:closed": { mode: "draft" },
|
||||
"terminal:open": { mode: "session", sessionId: "session-open" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalScopeState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open": draftsByScope["terminal:open"],
|
||||
"workspace:keep": draftsByScope["workspace:keep"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open": panelViewByScope["terminal:open"],
|
||||
"workspace:keep": panelViewByScope["workspace:keep"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneTerminalScopeState returns original refs when nothing is pruned", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:open": createEmptyDraft("agent-alpha"),
|
||||
"workspace:keep": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open": { mode: "draft" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-1" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalScopeState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.equal(next.draftsByScope, draftsByScope);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
|
||||
test("pruneTerminalTransientState clears closed terminal active session, draft, and view state only", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:closed": "session-closed",
|
||||
"terminal:open": "session-open",
|
||||
"workspace:keep": "session-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:closed": createEmptyDraft("agent-alpha"),
|
||||
"terminal:open": createEmptyDraft("agent-beta"),
|
||||
"workspace:keep": createEmptyDraft("agent-gamma"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:closed": { mode: "draft" },
|
||||
"terminal:open": { mode: "session", sessionId: "session-open" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open": "session-open",
|
||||
"workspace:keep": "session-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open": draftsByScope["terminal:open"],
|
||||
"workspace:keep": draftsByScope["workspace:keep"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open": panelViewByScope["terminal:open"],
|
||||
"workspace:keep": panelViewByScope["workspace:keep"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneTerminalTransientState returns original refs when no terminal scopes close", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open": "session-open",
|
||||
"workspace:keep": "session-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open": createEmptyDraft("agent-alpha"),
|
||||
"workspace:keep": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open": { mode: "draft" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
|
||||
assert.equal(next.draftsByScope, draftsByScope);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
282
application/state/aiDraftState.ts
Normal file
282
application/state/aiDraftState.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
} from '../../infrastructure/ai/types';
|
||||
|
||||
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' };
|
||||
|
||||
export function createEmptyDraft(agentId: string): AIDraft {
|
||||
return {
|
||||
text: '',
|
||||
agentId,
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
): AIPanelView {
|
||||
return panelViewByScope[scopeKey] ?? DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function setDraftView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): PanelViewByScope {
|
||||
const currentPanelView = panelViewByScope[scopeKey];
|
||||
if (currentPanelView?.mode === 'draft') {
|
||||
return panelViewByScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...panelViewByScope,
|
||||
[scopeKey]: DEFAULT_PANEL_VIEW,
|
||||
};
|
||||
}
|
||||
|
||||
export function activateDraftView(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const nextPanelViewByScope = setDraftView(panelViewByScope, scopeKey);
|
||||
const hasActiveSession = activeSessionIdMap[scopeKey] != null;
|
||||
|
||||
if (!hasActiveSession) {
|
||||
return {
|
||||
activeSessionIdMap,
|
||||
panelViewByScope: nextPanelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
|
||||
return {
|
||||
activeSessionIdMap: nextActiveSessionIdMap,
|
||||
panelViewByScope: nextPanelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function setSessionView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
sessionId: string,
|
||||
): PanelViewByScope {
|
||||
return {
|
||||
...panelViewByScope,
|
||||
[scopeKey]: { mode: 'session', sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
export function updateDraftForScope(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): DraftsByScope {
|
||||
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
|
||||
const nextDraft = updater(currentDraft);
|
||||
|
||||
return {
|
||||
...draftsByScope,
|
||||
[scopeKey]: nextDraft,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureDraftForScopeState(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
agentId: string,
|
||||
): DraftsByScope {
|
||||
if (draftsByScope[scopeKey]) {
|
||||
return draftsByScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...draftsByScope,
|
||||
[scopeKey]: createEmptyDraft(agentId),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
scopeKey: string,
|
||||
): {
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const hasDraft = Object.prototype.hasOwnProperty.call(draftsByScope, scopeKey);
|
||||
const hasPanelView = Object.prototype.hasOwnProperty.call(panelViewByScope, scopeKey);
|
||||
|
||||
if (!hasDraft && !hasPanelView) {
|
||||
return {
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
draftsByScope: hasDraft
|
||||
? (() => {
|
||||
const nextDrafts = { ...draftsByScope };
|
||||
delete nextDrafts[scopeKey];
|
||||
return nextDrafts;
|
||||
})()
|
||||
: draftsByScope,
|
||||
panelViewByScope: hasPanelView
|
||||
? (() => {
|
||||
const nextPanelViews = { ...panelViewByScope };
|
||||
delete nextPanelViews[scopeKey];
|
||||
return nextPanelViews;
|
||||
})()
|
||||
: panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
function isClosedTerminalScope(scopeKey: string, activeTerminalTargetIds: Set<string>) {
|
||||
if (!scopeKey.startsWith('terminal:')) return false;
|
||||
|
||||
const targetId = scopeKey.slice('terminal:'.length);
|
||||
if (!targetId) return false;
|
||||
|
||||
return !activeTerminalTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function pruneTerminalScopeState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTerminalTargetIds: Set<string>,
|
||||
): {
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const nextDraftsByScope = { ...draftsByScope };
|
||||
const nextPanelViewByScope = { ...panelViewByScope };
|
||||
let draftsChanged = false;
|
||||
let panelViewsChanged = false;
|
||||
|
||||
for (const scopeKey of Object.keys(nextDraftsByScope)) {
|
||||
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
|
||||
delete nextDraftsByScope[scopeKey];
|
||||
draftsChanged = true;
|
||||
}
|
||||
|
||||
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
|
||||
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
|
||||
delete nextPanelViewByScope[scopeKey];
|
||||
panelViewsChanged = true;
|
||||
}
|
||||
|
||||
return {
|
||||
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
|
||||
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneTerminalTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTerminalTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextTerminalScopeState = pruneTerminalScopeState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTerminalTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextTerminalScopeState.draftsByScope,
|
||||
panelViewByScope: nextTerminalScopeState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
160
application/state/aiScopeCleanup.test.ts
Normal file
160
application/state/aiScopeCleanup.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import { createEmptyDraft } from "./aiDraftState.ts";
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from "./aiScopeCleanup.ts";
|
||||
|
||||
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: "catty",
|
||||
scope,
|
||||
messages: [],
|
||||
externalSessionId,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"terminal:closed-terminal": "session-closed-terminal",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
"workspace:closed-workspace": "session-closed-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open-terminal": createEmptyDraft("catty"),
|
||||
"terminal:closed-terminal": createEmptyDraft("catty"),
|
||||
"workspace:open-workspace": createEmptyDraft("catty"),
|
||||
"workspace:closed-workspace": createEmptyDraft("catty"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open-terminal": { mode: "draft" },
|
||||
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
|
||||
"workspace:open-workspace": { mode: "draft" },
|
||||
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
|
||||
} satisfies Record<string, AIPanelView>;
|
||||
|
||||
const next = pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open-terminal", "open-workspace"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "closed-restorable",
|
||||
hostIds: ["host-1"],
|
||||
}, "ext-1"),
|
||||
createSession("terminal-local", {
|
||||
type: "terminal",
|
||||
targetId: "closed-local",
|
||||
hostIds: ["local-shell"],
|
||||
}, "ext-2"),
|
||||
createSession("workspace-closed", {
|
||||
type: "workspace",
|
||||
targetId: "closed-workspace",
|
||||
}, "ext-3"),
|
||||
createSession("terminal-open", {
|
||||
type: "terminal",
|
||||
targetId: "open-terminal",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-4"),
|
||||
];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["open-terminal"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.orphanedSessionIds, [
|
||||
"terminal-restorable",
|
||||
"terminal-local",
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.deepEqual(next.sessions, [
|
||||
sessions[0],
|
||||
sessions[3],
|
||||
]);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "closed-restorable",
|
||||
hostIds: ["host-1"],
|
||||
}),
|
||||
createSession("terminal-open", {
|
||||
type: "terminal",
|
||||
targetId: "open-terminal",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-4"),
|
||||
];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["open-terminal"]),
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
145
application/state/aiScopeCleanup.ts
Normal file
145
application/state/aiScopeCleanup.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types";
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
|
||||
function isInactiveScopedTarget(
|
||||
scopeKey: string,
|
||||
activeTargetIds: Set<string>,
|
||||
): boolean {
|
||||
const separatorIndex = scopeKey.indexOf(":");
|
||||
if (separatorIndex === -1) return false;
|
||||
|
||||
const scopeType = scopeKey.slice(0, separatorIndex);
|
||||
if (scopeType !== "terminal" && scopeType !== "workspace") return false;
|
||||
|
||||
const targetId = scopeKey.slice(separatorIndex + 1);
|
||||
if (!targetId) return false;
|
||||
|
||||
return !activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const nextDraftsByScope = { ...draftsByScope };
|
||||
const nextPanelViewByScope = { ...panelViewByScope };
|
||||
let draftsChanged = false;
|
||||
let panelViewsChanged = false;
|
||||
|
||||
for (const scopeKey of Object.keys(nextDraftsByScope)) {
|
||||
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
|
||||
delete nextDraftsByScope[scopeKey];
|
||||
draftsChanged = true;
|
||||
}
|
||||
|
||||
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
|
||||
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
|
||||
delete nextPanelViewByScope[scopeKey];
|
||||
panelViewsChanged = true;
|
||||
}
|
||||
|
||||
return {
|
||||
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
|
||||
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextScopedState = pruneInactiveScopedState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextScopedState.draftsByScope,
|
||||
panelViewByScope: nextScopedState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
function isRestorableTerminalSession(session: AISession): boolean {
|
||||
return session.scope.type === "terminal"
|
||||
&& !!session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
|
||||
}
|
||||
|
||||
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) {
|
||||
return {
|
||||
sessions,
|
||||
orphanedSessionIds,
|
||||
};
|
||||
}
|
||||
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
let sessionsChanged = false;
|
||||
|
||||
const nextSessions = sessions.flatMap((session) => {
|
||||
if (!orphanedSessionIdSet.has(session.id)) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
if (!isRestorableTerminalSession(session)) {
|
||||
sessionsChanged = true;
|
||||
return [];
|
||||
}
|
||||
return [session];
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: sessionsChanged ? nextSessions : sessions,
|
||||
orphanedSessionIds,
|
||||
};
|
||||
}
|
||||
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 };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
@@ -29,6 +31,21 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
ensureDraftForScopeState,
|
||||
getDraftUploadGenerationState,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from './aiDraftState';
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from './aiScopeCleanup';
|
||||
import { convertFilesToUploads } from './useFileUpload';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
interface AIBridge {
|
||||
@@ -45,6 +62,11 @@ function getAIBridge() {
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
|
||||
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
@@ -72,53 +94,41 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const orphanedSessionIds = currentSessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (orphanedSessionIds.length > 0) {
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
|
||||
// Determine which sessions can be restored via host-based matching
|
||||
const preservedIds = new Set<string>();
|
||||
for (const session of currentSessions) {
|
||||
if (!orphanedSessionIdSet.has(session.id)) continue;
|
||||
// Only preserve remote terminal sessions with real hostIds
|
||||
const isRestorable = session.scope.type === 'terminal'
|
||||
&& session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
|
||||
if (isRestorable) {
|
||||
preservedIds.add(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup ACP sessions for all orphans (both deleted and preserved).
|
||||
// Preserved sessions will get a new externalSessionId on next use,
|
||||
// so cleaning the old one is safe and prevents subprocess leaks.
|
||||
cleanupAcpSessions(orphanedSessionIds);
|
||||
|
||||
const nextSessions = currentSessions
|
||||
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
|
||||
.map((session) => {
|
||||
if (!preservedIds.has(session.id) || !session.externalSessionId) {
|
||||
return session;
|
||||
}
|
||||
// Drop transient ACP session handles so the next turn starts cleanly.
|
||||
return { ...session, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
const sessionsChanged = nextSessions.length !== currentSessions.length
|
||||
|| nextSessions.some((session, index) => session !== currentSessions[index]);
|
||||
if (sessionsChanged) {
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
}
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
// 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) {
|
||||
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
pruneSessionsForStorage(nextSessionCleanup.sessions),
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
@@ -133,6 +143,46 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
const currentActiveSessionIdMap = activeSessionMapChanged
|
||||
? nextActiveSessionIdMap
|
||||
: activeSessionIdMap;
|
||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
||||
currentActiveSessionIdMap,
|
||||
currentDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
prunedScopedTransientState.activeSessionIdMap,
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +213,10 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
|
||||
let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
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;
|
||||
@@ -172,17 +226,33 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
function buildScopeKey(scope: AISessionScope) {
|
||||
return `${scope.type}:${scope.targetId ?? ''}`;
|
||||
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
|
||||
function areHostIdsEqual(left?: string[], right?: string[]) {
|
||||
const leftIds = left ?? [];
|
||||
const rightIds = right ?? [];
|
||||
if (leftIds.length !== rightIds.length) return false;
|
||||
function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
|
||||
latestAIPanelViewByScopeSnapshot = panelViewByScope;
|
||||
}
|
||||
|
||||
const rightSet = new Set(rightIds);
|
||||
return leftIds.every((hostId) => rightSet.has(hostId));
|
||||
function bumpDraftMutationVersion(scopeKey: string) {
|
||||
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
|
||||
latestAIDraftMutationVersionByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function getDraftUploadGeneration(scopeKey: string) {
|
||||
return getDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function bumpDraftUploadGeneration(scopeKey: string) {
|
||||
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
@@ -243,6 +313,14 @@ export function useAIState() {
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
|
||||
);
|
||||
// Per-scope draft/view state is intentionally memory-only so a relaunch
|
||||
// does not restore stale composer input or panel intent against new history.
|
||||
const [draftsByScope, setDraftsByScopeRaw] = useState<DraftsByScope>(() =>
|
||||
latestAIDraftsByScopeSnapshot ?? {}
|
||||
);
|
||||
const [panelViewByScope, setPanelViewByScopeRaw] = useState<PanelViewByScope>(() =>
|
||||
latestAIPanelViewByScopeSnapshot ?? {}
|
||||
);
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
@@ -262,6 +340,14 @@ export function useAIState() {
|
||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||
}, [activeSessionIdMap]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIDraftsByScopeSnapshot(draftsByScope);
|
||||
}, [draftsByScope]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIPanelViewByScopeSnapshot(panelViewByScope);
|
||||
}, [panelViewByScope]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
@@ -284,13 +370,39 @@ export function useAIState() {
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = { ...prev, [scopeKey]: id };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
nextActiveSessionIdMap = next;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextActiveSessionIdMap) return;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}, []);
|
||||
|
||||
const setPanelViewByScope = useCallback((value: PanelViewByScope | ((prev: PanelViewByScope) => PanelViewByScope)) => {
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
|
||||
setPanelViewByScopeRaw((prev) => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
if (next === prev) return prev;
|
||||
nextPanelViewByScope = next;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextPanelViewByScope) return;
|
||||
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
@@ -522,6 +634,12 @@ export function useAIState() {
|
||||
?? {},
|
||||
);
|
||||
return;
|
||||
case AI_STATE_CHANGED_DRAFTS_BY_SCOPE:
|
||||
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
|
||||
return;
|
||||
case AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE:
|
||||
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
||||
return;
|
||||
default:
|
||||
handleStorage({ key } as StorageEvent);
|
||||
}
|
||||
@@ -686,61 +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;
|
||||
// Clear stale ACP handle — retarget may run before orphan cleanup
|
||||
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;
|
||||
|
||||
@@ -808,14 +871,193 @@ export function useAIState() {
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const ensureDraftForScope = useCallback((scopeKey: string, agentId: string): void => {
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const next = ensureDraftForScopeState(prev, scopeKey, agentId);
|
||||
if (next === prev) return prev;
|
||||
nextDraftsByScope = next;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextDraftsByScope) return;
|
||||
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}, []);
|
||||
|
||||
const updateDraft = useCallback((
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): void => {
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const next = updateDraftForScope(
|
||||
prev,
|
||||
scopeKey,
|
||||
fallbackAgentId,
|
||||
(draft) => {
|
||||
return {
|
||||
...updater(draft),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
);
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
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;
|
||||
|
||||
const nextDraft = {
|
||||
...updater(currentDraft),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const next = {
|
||||
...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 = panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
let panelViewChanged = false;
|
||||
|
||||
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
|
||||
const next = activateDraftView(
|
||||
prevActiveSessionIdMap,
|
||||
currentPanelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
|
||||
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
||||
nextActiveSessionIdMap = next.activeSessionIdMap;
|
||||
nextPanelViewByScope = next.panelViewByScope;
|
||||
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
|
||||
});
|
||||
|
||||
if (activeSessionMapChanged && nextActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (panelViewChanged && nextPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
setPanelViewByScopeRaw(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}, [panelViewByScope]);
|
||||
|
||||
const showSessionView = useCallback((scopeKey: string, sessionId: string) => {
|
||||
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
|
||||
}, [setPanelViewByScope]);
|
||||
|
||||
const clearDraftForScope = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let draftsChanged = false;
|
||||
let panelViewChanged = false;
|
||||
|
||||
setDraftsByScopeRaw((prevDraftsByScope) => {
|
||||
const next = clearScopeDraftState(
|
||||
prevDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
draftsChanged = next.draftsByScope !== prevDraftsByScope;
|
||||
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
||||
nextDraftsByScope = next.draftsByScope;
|
||||
nextPanelViewByScope = next.panelViewByScope;
|
||||
return draftsChanged ? next.draftsByScope : prevDraftsByScope;
|
||||
});
|
||||
|
||||
if (!draftsChanged && !panelViewChanged) return;
|
||||
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
|
||||
if (draftsChanged && nextDraftsByScope) {
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (panelViewChanged && nextPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
setPanelViewByScopeRaw(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}, [panelViewByScope]);
|
||||
|
||||
const addDraftFiles = useCallback(async (
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
inputFiles: File[],
|
||||
) => {
|
||||
ensureDraftForScope(scopeKey, fallbackAgentId);
|
||||
const initialUploadGeneration = getDraftUploadGeneration(scopeKey);
|
||||
const uploads = await convertFilesToUploads(inputFiles);
|
||||
if (uploads.length === 0) return;
|
||||
|
||||
if (getDraftUploadGeneration(scopeKey) !== initialUploadGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDraftIfPresent(scopeKey, (draft) => ({
|
||||
...draft,
|
||||
attachments: [...draft.attachments, ...uploads],
|
||||
}));
|
||||
}, [ensureDraftForScope, updateDraftIfPresent]);
|
||||
|
||||
const removeDraftFile = useCallback((scopeKey: string, fallbackAgentId: string, fileId: string) => {
|
||||
updateDraft(scopeKey, fallbackAgentId, (draft) => ({
|
||||
...draft,
|
||||
attachments: draft.attachments.filter((file) => file.id !== fileId),
|
||||
}));
|
||||
}, [updateDraft]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
cleanupOrphanedAISessions(activeTargetIds);
|
||||
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
|
||||
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
|
||||
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
||||
}, []);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
@@ -889,13 +1131,21 @@ export function useAIState() {
|
||||
// Sessions (per-scope active session)
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
setActiveSessionId,
|
||||
ensureDraftForScope,
|
||||
updateDraft,
|
||||
showDraftView,
|
||||
showSessionView,
|
||||
clearDraftForScope,
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -16,38 +16,16 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { notify } from '../notification';
|
||||
|
||||
/**
|
||||
* Check whether a sync payload has any meaningful user data. Covers all
|
||||
* synced entity arrays so that edge cases (e.g. user has 0 hosts but 1
|
||||
* port forwarding rule) are not mistakenly treated as "empty".
|
||||
*/
|
||||
function isPayloadEffectivelyEmpty(payload: SyncPayload): boolean {
|
||||
// Check all synced entity arrays.
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
if (hasEntities) return false;
|
||||
// Also consider settings: if any key has a defined value, the user has
|
||||
// customized something worth preserving.
|
||||
if (payload.settings && Object.values(payload.settings).some((v) => v !== undefined)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
hosts: SyncPayload['hosts'];
|
||||
@@ -61,15 +39,24 @@ interface AutoSyncConfig {
|
||||
groupConfigs?: SyncPayload['groupConfigs'];
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
startupReady?: boolean;
|
||||
|
||||
// Callbacks
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// Get manager singleton for direct state access
|
||||
const manager = getCloudSyncManager();
|
||||
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
|
||||
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
|
||||
// in the future means a restore is applying in some window and auto-sync
|
||||
// must not push concurrently.
|
||||
const isRestoreInProgress = (): boolean => {
|
||||
const raw = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL);
|
||||
return typeof raw === 'number' && raw > Date.now();
|
||||
};
|
||||
|
||||
type SyncTrigger = 'auto' | 'manual';
|
||||
|
||||
interface SyncNowOptions {
|
||||
@@ -190,6 +177,50 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.autoSync.alreadySyncing'));
|
||||
}
|
||||
|
||||
// Cross-window guard: another window may be in the middle of
|
||||
// applying a local vault restore. If we push right now we'd upload
|
||||
// the pre-restore snapshot (the main window's React state hasn't
|
||||
// observed the localStorage writes yet), clobbering the just-
|
||||
// restored cloud copy. Skip silently on auto triggers and fail
|
||||
// loudly on manual ones so the user understands why their click
|
||||
// did nothing.
|
||||
//
|
||||
// Pairs with `withRestoreBarrier` in application/localVaultBackups.ts
|
||||
// (the writer) and with the matching early-return in the
|
||||
// debounced-sync effect below (the other reader, which prevents
|
||||
// scheduling a push while the barrier is held).
|
||||
if (isRestoreInProgress()) {
|
||||
if (trigger === 'auto') {
|
||||
console.info('[AutoSync] Skipping: a vault restore is in progress in another window.');
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.restoreInProgress'));
|
||||
}
|
||||
|
||||
// Refuse to auto-push when a previous apply crashed mid-way and
|
||||
// left the vault in a partial state. `applyProtectedSyncPayload`
|
||||
// sets a sentinel before its non-atomic localStorage writes and
|
||||
// clears it on successful completion; the sentinel's presence
|
||||
// here means the renderer crashed between a first write and the
|
||||
// clean-up, so the in-memory payload is a mix of pre-apply and
|
||||
// post-apply entries. Pushing that would silently overwrite an
|
||||
// intact cloud copy with corrupted data.
|
||||
//
|
||||
// Manual triggers surface a user-visible error that points the
|
||||
// user at the Restore UI; auto triggers return quietly (the
|
||||
// next startup toast below flags the state).
|
||||
const interruptedApply = readInterruptedVaultApply();
|
||||
if (interruptedApply) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn(
|
||||
'[AutoSync] Skipping: previous apply was interrupted — refusing to push partial state.',
|
||||
interruptedApply,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.interruptedApplyMessage'));
|
||||
}
|
||||
|
||||
// If another window unlocked, reuse the in-memory session password from main process.
|
||||
if (state.securityState !== 'UNLOCKED') {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -216,14 +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.
|
||||
if (isPayloadEffectivelyEmpty(payload) && trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
// 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)) {
|
||||
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);
|
||||
@@ -232,7 +272,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// state gets updated even when some providers failed
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
skipNextSyncRef.current = true;
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
@@ -248,6 +288,18 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
lastSyncedDataRef.current = dataHash;
|
||||
|
||||
// Successful sync implies a successful per-provider
|
||||
// `checkProviderConflict` (which inspects remote) — equivalent
|
||||
// to a successful startup reconciliation from the auto-sync
|
||||
// gate's point of view. Opening the gate here is the escape
|
||||
// hatch when a network outage exhausted the startup retry
|
||||
// timer: a user-triggered manual sync (or any first successful
|
||||
// auto sync that somehow ran anyway) resumes auto-sync for the
|
||||
// rest of the session. Without this, a degraded-startup session
|
||||
// would require the user to manually sync after every edit.
|
||||
hasCheckedRemoteRef.current = true;
|
||||
remoteCheckDoneRef.current = true;
|
||||
} catch (error) {
|
||||
if (trigger === 'manual') {
|
||||
throw error;
|
||||
@@ -261,81 +313,232 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
|
||||
|
||||
|
||||
// One-shot toast per mount when a previous apply was interrupted, so the
|
||||
// user understands why auto-sync is silently paused and where to go to
|
||||
// recover. `applyProtectedSyncPayload` clears the sentinel on a clean
|
||||
// apply, so this only fires once per genuine crash and naturally stops
|
||||
// after the user completes a recovery.
|
||||
const interruptedApplyNotifiedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (interruptedApplyNotifiedRef.current) return;
|
||||
if (!sync.isUnlocked) return;
|
||||
const interrupted = readInterruptedVaultApply();
|
||||
if (!interrupted) return;
|
||||
interruptedApplyNotifiedRef.current = true;
|
||||
notify.error(
|
||||
t('sync.autoSync.interruptedApplyMessage'),
|
||||
t('sync.autoSync.interruptedApplyTitle'),
|
||||
);
|
||||
}, [sync.isUnlocked, t]);
|
||||
|
||||
// Stabilize the fields `checkRemoteVersion` reads from `config`.
|
||||
// AutoSyncConfig is a fresh object literal on every App render, so a
|
||||
// naive `config` dep would rebuild `checkRemoteVersion`'s identity on
|
||||
// every unrelated state change — re-firing the retry effect with
|
||||
// `attempt=0` and spawning overlapping in-flight inspections. The
|
||||
// refs below let `checkRemoteVersion` read the latest callback and
|
||||
// readiness flag without pulling the object identity into deps.
|
||||
const onApplyPayloadRef = useRef(config.onApplyPayload);
|
||||
useEffect(() => {
|
||||
onApplyPayloadRef.current = config.onApplyPayload;
|
||||
}, [config.onApplyPayload]);
|
||||
const startupReadyRef = useRef(config.startupReady);
|
||||
useEffect(() => {
|
||||
startupReadyRef.current = config.startupReady;
|
||||
}, [config.startupReady]);
|
||||
// `buildPayload` closes over live React state so its identity flips
|
||||
// on every vault edit; route it through a ref so `checkRemoteVersion`
|
||||
// can read the latest builder without churning its memo identity.
|
||||
const buildPayloadRef = useRef(buildPayload);
|
||||
useEffect(() => {
|
||||
buildPayloadRef.current = buildPayload;
|
||||
}, [buildPayload]);
|
||||
|
||||
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
|
||||
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
|
||||
// could both write-then-clear the apply-in-progress sentinel around
|
||||
// interleaved applies, and both could push post-merge snapshots to
|
||||
// remote. The cross-window `withRestoreBarrier` protects other
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasCheckedRemoteRef.current = true;
|
||||
|
||||
// Find connected provider
|
||||
|
||||
// Find connected provider BEFORE acquiring the in-flight lock so the
|
||||
// "nothing to check" early return doesn't leak the lock and wedge
|
||||
// the retry timer. Any path that takes the lock MUST reach the
|
||||
// finally-release below.
|
||||
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
|
||||
isProviderReadyForSync(state.providers[provider]),
|
||||
) ?? null;
|
||||
|
||||
if (!connectedProvider) return;
|
||||
|
||||
|
||||
if (!connectedProvider) {
|
||||
// Nothing to check — mark as done so the auto-sync gate opens.
|
||||
remoteCheckDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
checkRemoteInFlightRef.current = true;
|
||||
|
||||
// Track whether the startup path completed in a state where the anchor/base
|
||||
// are consistent with the local vault. Only then should we latch
|
||||
// hasCheckedRemoteRef so that transient failures are retryable.
|
||||
let startupConsistent = false;
|
||||
try {
|
||||
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
|
||||
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
|
||||
const base = await manager.loadSyncBase(connectedProvider);
|
||||
const remotePayload = await sync.downloadFromProvider(connectedProvider);
|
||||
const inspection = await manager.inspectProviderRemote(connectedProvider);
|
||||
|
||||
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
|
||||
const localPayload = buildPayload();
|
||||
const localIsEmpty = isPayloadEffectivelyEmpty(localPayload);
|
||||
const remoteHasData = !isPayloadEffectivelyEmpty(remotePayload);
|
||||
if (!inspection.payload || !inspection.remoteChanged || !inspection.remoteFile) {
|
||||
// Remote unchanged (or empty) — no local mutation needed; anchor/base
|
||||
// are already in sync with remote from a previous run.
|
||||
startupConsistent = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (localIsEmpty && remoteHasData) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
const localPayload = buildPayloadRef.current();
|
||||
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
|
||||
const remoteHasData = hasMeaningfulSyncData(remotePayload);
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (localIsEmpty && remoteHasData) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
|
||||
if (userAction === 'restore') {
|
||||
config.onApplyPayload(remotePayload);
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Don't apply remote data.
|
||||
// The next auto-sync will eventually push the empty state if
|
||||
// the user makes another edit.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
if (userAction === 'restore') {
|
||||
// Apply remote FIRST; only commit anchor/base after the UI-side
|
||||
// state has accepted the remote payload, otherwise a failure
|
||||
// between commit and apply would leave the anchor pointing at
|
||||
// remote while local is still empty — the exact overwrite window
|
||||
// we're trying to close.
|
||||
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
skipNextSyncRef.current = true;
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Deliberately do NOT advance
|
||||
// the anchor or base — the next sync must still treat remote as
|
||||
// "unseen" so the empty-vault-push guard (`hasMeaningfulSyncData`)
|
||||
// keeps protecting the cloud copy. startupConsistent stays false
|
||||
// so hasCheckedRemoteRef is not latched and the next startup will
|
||||
// re-prompt if the user still has not added anything.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
config.onApplyPayload(mergeResult.payload);
|
||||
// Prevent the data-change effect from immediately re-uploading the
|
||||
// merged payload — the merge already incorporated both sides. The
|
||||
// next deliberate edit by the user will trigger a normal sync.
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
// throws, the next startup will re-run the merge with fresh data.
|
||||
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
|
||||
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
|
||||
// stores remotePayload as the base so the next diff is computed
|
||||
// against what the cloud actually has, not against the merged
|
||||
// local-only state.
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
|
||||
// If the three-way merge introduced any local-only additions that the
|
||||
// remote does not yet have, we MUST round-trip those to the cloud.
|
||||
// Previously this branch stopped after applying merge locally, so the
|
||||
// merged-in additions lived only on the device that ran the merge
|
||||
// until the user's next edit.
|
||||
//
|
||||
// We push the merged payload *directly* through the manager rather
|
||||
// than going through the React-state-driven `syncNow`. syncNow
|
||||
// rebuilds the payload from hooks state, which may not yet reflect
|
||||
// the onApplyPayload we awaited above (React commit phase is async
|
||||
// relative to the awaited promise resolution). Passing mergeResult
|
||||
// in explicitly removes the race entirely and avoids a setTimeout(0)
|
||||
// that only approximated the correct ordering.
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
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.
|
||||
skipNextSyncRef.current = true;
|
||||
} catch (error) {
|
||||
// Non-fatal: the next user edit will drive another sync cycle.
|
||||
console.warn('[AutoSync] Post-merge round-trip push failed:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
remoteCheckDoneRef.current = true;
|
||||
if (startupConsistent) {
|
||||
hasCheckedRemoteRef.current = true;
|
||||
// Only open the auto-sync gate when the inspect actually
|
||||
// validated the remote state. Leaving the gate closed on
|
||||
// inspect failure is intentional: an edit made during a
|
||||
// degraded startup must not race ahead and push a partially-
|
||||
// hydrated vault over an intact remote. The retry effect
|
||||
// below re-fires checkRemoteVersion on the next provider/
|
||||
// unlock/startupReady transition, and a manual sync from
|
||||
// Settings remains available as an escape hatch.
|
||||
remoteCheckDoneRef.current = true;
|
||||
}
|
||||
checkRemoteInFlightRef.current = false;
|
||||
}
|
||||
}, [sync, config, buildPayload, t]);
|
||||
// Intentionally minimal deps: `buildPayload`, `config.onApplyPayload`,
|
||||
// and `config.startupReady` are read through refs above so their
|
||||
// identity flips (every vault edit produces a fresh `buildPayload`
|
||||
// and a fresh AutoSyncConfig literal) cannot re-memoize this
|
||||
// callback and restart the retry-timer's exponential backoff.
|
||||
}, [t]);
|
||||
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
@@ -379,6 +582,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (sync.isSyncing || isSyncRunningRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hold off on scheduling a new push while another window is applying
|
||||
// a restore — the restore is about to land via localStorage and the
|
||||
// debounce-fired syncNow would otherwise race it. The next data-
|
||||
// change tick after the restore barrier clears will re-enter here.
|
||||
if (isRestoreInProgress()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't even schedule a push while the apply-in-progress sentinel
|
||||
// is held. The syncNow path re-checks and refuses too, but dropping
|
||||
// the debounced schedule here avoids spinning a 3-second timer for
|
||||
// every keystroke while the user is in the Restore UI working
|
||||
// through recovery.
|
||||
if (readInterruptedVaultApply()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (syncTimeoutRef.current) {
|
||||
@@ -397,17 +617,65 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
// Check remote version on startup/unlock, then retry with backoff
|
||||
// while the inspect keeps failing. Without the timer-based retry,
|
||||
// a failure that doesn't coincide with a dep change would wedge the
|
||||
// auto-sync gate closed until the user restarts or manually triggers
|
||||
// sync from Settings — the 30s/60s/90s cadence below lets a short
|
||||
// outage (network blip, provider rate-limit) self-heal.
|
||||
useEffect(() => {
|
||||
if (sync.hasAnyConnectedProvider && sync.isUnlocked && !hasCheckedRemoteRef.current) {
|
||||
// Delay check to ensure everything is loaded
|
||||
const timer = setTimeout(() => {
|
||||
checkRemoteVersion();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
if (
|
||||
!sync.hasAnyConnectedProvider ||
|
||||
!sync.isUnlocked ||
|
||||
hasCheckedRemoteRef.current ||
|
||||
config.startupReady === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]);
|
||||
|
||||
let cancelled = false;
|
||||
let attempt = 0;
|
||||
let timerId: NodeJS.Timeout | null = null;
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
void (async () => {
|
||||
await checkRemoteVersion();
|
||||
if (cancelled || hasCheckedRemoteRef.current) return;
|
||||
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
|
||||
// persistent failure beyond that is almost certainly a
|
||||
// misconfiguration that needs user action rather than more
|
||||
// auto-retries.
|
||||
//
|
||||
// When retries exhaust we deliberately leave the auto-sync gate
|
||||
// CLOSED. Opening it here would allow a partially-lost local
|
||||
// vault to silently clobber an unchanged remote: anchor still
|
||||
// matches, `checkProviderConflict` sees no remote change,
|
||||
// `hasMeaningfulSyncData` doesn't flag non-empty-but-partial
|
||||
// local, and the empty-vault prompt never fires.
|
||||
//
|
||||
// Escape hatch: a successful manual sync from Settings opens
|
||||
// the gate via `syncNow`'s success path. That path runs the
|
||||
// same per-provider inspect we use here, so a successful
|
||||
// manual sync is equivalent to a successful startup inspect
|
||||
// from the gate's point of view — the user's explicit click
|
||||
// authorizes both the push and the subsequent auto-sync
|
||||
// resumption. Until then, auto-sync stays paused and the
|
||||
// "sync paused" toast is the user's signal to act.
|
||||
if (attempt >= 4) return;
|
||||
const delayMs = Math.min(240_000, 30_000 * 2 ** attempt);
|
||||
attempt += 1;
|
||||
timerId = setTimeout(tick, delayMs);
|
||||
})();
|
||||
};
|
||||
|
||||
tick();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
@@ -416,6 +684,25 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
remoteCheckDoneRef.current = false;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
// On unmount, release any pending empty-vault confirmation. Without
|
||||
// this, an unmount mid-dialog (window close, workspace switch) leaves
|
||||
// the resolver promise dangling forever and the `checkRemoteVersion`
|
||||
// finally block never sets remoteCheckDoneRef — in practice React
|
||||
// tears down the hook first, but leaking the resolve callback and
|
||||
// referenced remotePayload keeps them pinned by the awaiter until
|
||||
// the next reload. Resolving with 'keep-empty' is the safe default:
|
||||
// it mirrors the "don't touch remote" choice and leaves the version
|
||||
// stamp untouched so the next mount re-prompts.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const resolve = emptyVaultResolveRef.current;
|
||||
if (resolve) {
|
||||
emptyVaultResolveRef.current = null;
|
||||
resolve('keep-empty');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
|
||||
// Guard: resolve only once (prevents double-click from entering an
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
/**
|
||||
* useFileUpload - Handle file paste/drop with base64 conversion
|
||||
* File upload conversion helpers for AI draft attachments.
|
||||
*
|
||||
* Supports images, PDFs, and other document types.
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { getPathForFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
|
||||
filePath?: string; // original filesystem path (Electron only)
|
||||
}
|
||||
export type { UploadedFile } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Reject only known binary blobs that AI models can't process */
|
||||
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
||||
@@ -38,42 +31,32 @@ async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: str
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileUpload() {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
export async function convertFilesToUploads(inputFiles: File[]): Promise<UploadedFile[]> {
|
||||
const supported = inputFiles.filter(isSupportedFile);
|
||||
if (supported.length === 0) return [];
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
const supported = inputFiles.filter(isSupportedFile);
|
||||
if (supported.length === 0) return;
|
||||
|
||||
const newFiles: UploadedFile[] = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useFileUpload] Failed to convert:', err);
|
||||
}
|
||||
const uploads: Array<UploadedFile | null> = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
const filePath = getPathForFile(file);
|
||||
return { id, filename, dataUrl, base64Data, mediaType, filePath };
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id,
|
||||
filename,
|
||||
dataUrl: result.dataUrl,
|
||||
base64Data: result.base64,
|
||||
mediaType,
|
||||
filePath,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[useFileUpload] Failed to convert:', err);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFiles([]);
|
||||
}, []);
|
||||
|
||||
return { files, addFiles, removeFile, clearFiles };
|
||||
return uploads.filter((upload): upload is UploadedFile => upload !== null);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export const getTerminalPassthroughActions = (): Set<string> => {
|
||||
return new Set([
|
||||
'copy',
|
||||
'paste',
|
||||
'pasteSelection',
|
||||
'selectAll',
|
||||
'clearBuffer',
|
||||
'searchTerminal',
|
||||
|
||||
95
application/state/useLocalVaultBackups.ts
Normal file
95
application/state/useLocalVaultBackups.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
type LocalVaultBackupPreview,
|
||||
getLocalVaultBackupCapabilities,
|
||||
getLocalVaultBackupMaxCount,
|
||||
listLocalVaultBackups,
|
||||
openLocalVaultBackupDir,
|
||||
readLocalVaultBackup,
|
||||
setLocalVaultBackupMaxCount,
|
||||
trimLocalVaultBackups,
|
||||
} from '../localVaultBackups';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function useLocalVaultBackups() {
|
||||
const [backups, setBackups] = useState<LocalVaultBackupPreview[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [maxBackups, setMaxBackupsState] = useState(() => getLocalVaultBackupMaxCount());
|
||||
// `null` while we're still asking the main process. The UI should treat
|
||||
// `null` as "unknown, don't render restore controls yet" so we never expose
|
||||
// a destructive action that might later be disabled.
|
||||
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const refreshBackups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const next = await listLocalVaultBackups();
|
||||
setBackups(next);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const caps = await getLocalVaultBackupCapabilities();
|
||||
if (!cancelled) {
|
||||
setEncryptionAvailable(caps.encryptionAvailable);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEncryptionAvailable(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
void refreshBackups();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshBackups]);
|
||||
|
||||
// Cross-window live refresh: the main process broadcasts when any
|
||||
// renderer's createBackup or trimBackups actually mutated the on-disk
|
||||
// set. Without this subscription, a protective backup written by the
|
||||
// main window wouldn't show up in the Settings window's list until
|
||||
// the user manually navigated away and back, silently under-reporting
|
||||
// the most recent recovery points.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const subscribe = bridge?.onVaultBackupsChanged;
|
||||
if (typeof subscribe !== 'function') return undefined;
|
||||
const unsubscribe = subscribe(() => {
|
||||
void refreshBackups();
|
||||
});
|
||||
return () => {
|
||||
try { unsubscribe?.(); } catch { /* ignore */ }
|
||||
};
|
||||
}, [refreshBackups]);
|
||||
|
||||
const updateMaxBackups = useCallback(async (value: number) => {
|
||||
const sanitized = setLocalVaultBackupMaxCount(value);
|
||||
setMaxBackupsState(sanitized);
|
||||
await trimLocalVaultBackups(sanitized);
|
||||
await refreshBackups();
|
||||
return sanitized;
|
||||
}, [refreshBackups]);
|
||||
|
||||
const openBackupDirectory = useCallback(async () => {
|
||||
await openLocalVaultBackupDir();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backups,
|
||||
isLoading,
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
readBackup: readLocalVaultBackup,
|
||||
setMaxBackups: updateMaxBackups,
|
||||
openBackupDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export default useLocalVaultBackups;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -34,7 +34,9 @@ import {
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -71,6 +73,9 @@ const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -260,6 +265,18 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
|
||||
});
|
||||
const [showRecentHosts, setShowRecentHostsState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
return stored ?? DEFAULT_SHOW_RECENT_HOSTS;
|
||||
});
|
||||
const [showOnlyUngroupedHostsInRoot, setShowOnlyUngroupedHostsInRootState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
|
||||
});
|
||||
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
@@ -463,6 +480,12 @@ export const useSettingsState = () => {
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
|
||||
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
setShowRecentHostsState(storedShowRecentHosts ?? DEFAULT_SHOW_RECENT_HOSTS);
|
||||
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -662,6 +685,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
});
|
||||
@@ -671,6 +695,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
};
|
||||
@@ -834,6 +859,24 @@ export const useSettingsState = () => {
|
||||
setSftpDefaultViewMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showRecentHosts) {
|
||||
setShowRecentHostsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
setShowSftpTabState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -923,6 +966,27 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_HOTKEY_RECORDING, isRecording);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowRecentHosts = useCallback((enabled: boolean) => {
|
||||
setShowRecentHostsState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowOnlyUngroupedHostsInRoot = useCallback((enabled: boolean) => {
|
||||
setShowOnlyUngroupedHostsInRootState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowSftpTab = useCallback((enabled: boolean) => {
|
||||
setShowSftpTabState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
// Always apply CSS to document (needed on mount)
|
||||
@@ -1228,6 +1292,12 @@ export const useSettingsState = () => {
|
||||
setSftpAutoOpenSidebar,
|
||||
sftpDefaultViewMode,
|
||||
setSftpDefaultViewMode,
|
||||
showRecentHosts,
|
||||
setShowRecentHosts,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -1266,6 +1336,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
customThemes, workspaceFocusStyle,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -102,6 +102,7 @@ const safeParse = <T,>(value: string | null): T | null => {
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [identities, setIdentities] = useState<Identity[]>([]);
|
||||
@@ -339,129 +340,133 @@ export const useVaultState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
try {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
setGroupConfigs(decryptedGC);
|
||||
encryptGroupConfigs(decryptedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
setGroupConfigs(decryptedGC);
|
||||
encryptGroupConfigs(decryptedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -657,6 +662,7 @@ export const useVaultState = () => {
|
||||
);
|
||||
|
||||
return {
|
||||
isInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,6 +63,29 @@ export interface SyncableVaultData {
|
||||
groupConfigs?: GroupConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the payload contains any meaningful user data worth
|
||||
* protecting or syncing.
|
||||
*/
|
||||
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
|
||||
@@ -83,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;
|
||||
@@ -173,6 +199,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
|
||||
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
if (showRecent != null) settings.showRecentHosts = showRecent;
|
||||
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
@@ -238,6 +268,15 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
|
||||
// Immersive mode (legacy — always enabled, ignore incoming value)
|
||||
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
|
||||
if (settings.showOnlyUngroupedHostsInRoot != null) {
|
||||
localStorageAdapter.writeBoolean(
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
settings.showOnlyUngroupedHostsInRoot,
|
||||
);
|
||||
}
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Download,
|
||||
Database,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Github,
|
||||
@@ -32,11 +33,19 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { useLocalVaultBackups } from '../application/state/useLocalVaultBackups';
|
||||
import {
|
||||
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
withRestoreBarrier,
|
||||
} from '../application/localVaultBackups';
|
||||
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';
|
||||
@@ -628,10 +637,421 @@ const ConflictModal: React.FC<ConflictModalProps> = ({
|
||||
|
||||
interface SyncDashboardProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
interface LocalBackupsPanelProps {
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
/**
|
||||
* When true, the panel hides the Restore button entirely — e.g. while the
|
||||
* master key has not been configured yet, a restore would land credentials
|
||||
* on disk in plaintext (I3). Listing is still allowed so users can see that
|
||||
* their history exists.
|
||||
*/
|
||||
restoreDisabledReason?: 'no-master-key' | null;
|
||||
}
|
||||
|
||||
const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
onApplyPayload,
|
||||
restoreDisabledReason = null,
|
||||
}) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const {
|
||||
backups,
|
||||
isLoading,
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
readBackup,
|
||||
setMaxBackups,
|
||||
openBackupDirectory,
|
||||
} = useLocalVaultBackups();
|
||||
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
|
||||
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
|
||||
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
|
||||
// Backup chosen in the list but not yet confirmed. A two-step flow keeps
|
||||
// users from wiping their vault with a single accidental click (I2).
|
||||
const [pendingRestoreBackup, setPendingRestoreBackup] = useState<
|
||||
(typeof backups)[number] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxBackupsInput(String(maxBackups));
|
||||
}, [maxBackups]);
|
||||
|
||||
const formatTimestamp = (timestamp: number) =>
|
||||
new Date(timestamp).toLocaleString(resolvedLocale || undefined);
|
||||
|
||||
const getReasonLabel = (reason: 'app_version_change' | 'before_restore') =>
|
||||
reason === 'app_version_change'
|
||||
? t('cloudSync.localBackups.reason.appVersionChange')
|
||||
: t('cloudSync.localBackups.reason.beforeRestore');
|
||||
|
||||
const handleSaveMaxBackups = async () => {
|
||||
// Validate BEFORE calling setMaxBackups, which hands off to the
|
||||
// renderer's `sanitizeLocalVaultBackupMaxCount` clamp. Two failure
|
||||
// modes must be surfaced rather than silently clamped, because
|
||||
// both produce a misleading "saved" toast:
|
||||
//
|
||||
// 1. Empty / non-numeric input — `Number("")` coerces to 0 and
|
||||
// sanitize clamps to the default (20). A user who meant to
|
||||
// clear the field then re-type would see their retention
|
||||
// silently reset to 20 with a success message.
|
||||
//
|
||||
// 2. Out-of-range input (e.g. 500) — sanitize clamps to 100 and
|
||||
// still reports success, but the visible error string says
|
||||
// "between 1 and 100", so the user has no idea their value
|
||||
// was changed. Reject explicitly instead.
|
||||
//
|
||||
// The 1..MAX range check mirrors the main-process `sanitizeMaxCount`
|
||||
// in vaultBackupBridge.cjs so renderer and bridge agree.
|
||||
const parsed = Number(maxBackupsInput);
|
||||
const inRange =
|
||||
Number.isFinite(parsed) &&
|
||||
parsed >= MIN_LOCAL_VAULT_BACKUP_MAX_COUNT &&
|
||||
parsed <= MAX_LOCAL_VAULT_BACKUP_MAX_COUNT;
|
||||
if (!inRange || maxBackupsInput.trim() === '') {
|
||||
toast.error(
|
||||
t('cloudSync.localBackups.maxInvalid'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsSavingMaxBackups(true);
|
||||
try {
|
||||
const next = await setMaxBackups(parsed);
|
||||
setMaxBackupsInput(String(next));
|
||||
toast.success(t('cloudSync.localBackups.maxSaved', { count: String(next) }));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
} finally {
|
||||
setIsSavingMaxBackups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBackupDirectory = async () => {
|
||||
try {
|
||||
await openBackupDirectory();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const performRestore = async (backupId: string) => {
|
||||
setRestoringBackupId(backupId);
|
||||
try {
|
||||
// Hold the cross-window restore barrier around both the load
|
||||
// and the apply so another window's auto-sync cannot push a
|
||||
// pre-restore snapshot concurrently. See `withRestoreBarrier`
|
||||
// in application/localVaultBackups.ts for the read-side in
|
||||
// useAutoSync.
|
||||
//
|
||||
// In-memory React state refresh is implicit: `onApplyPayload`
|
||||
// (supplied by the hosting screen) routes through
|
||||
// `applySyncPayload` → `importDataFromString` → store writes
|
||||
// → the hook-store listeners in `useVaultState` /
|
||||
// `useCustomThemes` / etc. We do NOT explicitly re-pull host
|
||||
// lists here because a future refactor that decouples those
|
||||
// stores from the apply path would silently break the UI
|
||||
// refresh in a way that's only visible after a manual
|
||||
// restart. Any change to that chain must either preserve
|
||||
// store-listener notification OR add an explicit
|
||||
// `rehydrateAllFromStorage` call here — do not assume
|
||||
// restore is "just" a payload swap.
|
||||
await withRestoreBarrier(async () => {
|
||||
const detail = await readBackup(backupId);
|
||||
if (!detail) {
|
||||
throw new Error(t('cloudSync.localBackups.restoreMissing'));
|
||||
}
|
||||
await Promise.resolve(onApplyPayload(detail.payload));
|
||||
});
|
||||
await refreshBackups();
|
||||
toast.success(t('cloudSync.localBackups.restoreSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.localBackups.restoreFailedTitle'),
|
||||
);
|
||||
} finally {
|
||||
setRestoringBackupId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAllowed = restoreDisabledReason === null;
|
||||
// While encryptionAvailable is still `null` we're mid-probe — render the
|
||||
// restore button as disabled so the user never sees a path they can't
|
||||
// actually take (I1 surface). Once resolved, `false` hides the panel body
|
||||
// via the unavailable banner below.
|
||||
const encryptionResolved = encryptionAvailable !== null;
|
||||
const encryptionUsable = encryptionAvailable === true;
|
||||
|
||||
// safeStorage probe finished and returned "not available" → disable the
|
||||
// panel entirely; the main process refuses to write in this state (I1).
|
||||
if (encryptionResolved && !encryptionUsable) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm font-medium">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.unavailableDesc')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-lg">
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.retentionTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.retentionDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 md:min-w-[260px] md:shrink-0">
|
||||
<div className="flex items-end gap-2 md:justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxBackupsInput}
|
||||
onChange={(e) => setMaxBackupsInput(e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveMaxBackups()}
|
||||
disabled={isSavingMaxBackups}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!restoreAllowed && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
|
||||
<AlertTriangle size={14} />
|
||||
<span className="font-medium">
|
||||
{t('cloudSync.localBackups.lockedTitle')}
|
||||
</span>
|
||||
</div>
|
||||
{t('cloudSync.localBackups.lockedDesc')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshBackups()}
|
||||
disabled={isLoading}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
|
||||
{t('settings.system.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenBackupDirectory()}
|
||||
className="gap-1"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('settings.system.openFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backups.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
|
||||
{t('cloudSync.localBackups.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 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">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
hosts: String(backup.preview.hostCount),
|
||||
keys: String(backup.preview.keyCount),
|
||||
snippets: String(backup.preview.snippetCount),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{restoreAllowed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(backup)}
|
||||
// Disable every row while ANY restore is in
|
||||
// flight. Each restore runs a full
|
||||
// `applyProtectedSyncPayload` — multiple
|
||||
// localStorage writes + the apply-in-progress
|
||||
// sentinel. `withRestoreBarrier` serializes
|
||||
// across windows but does NOT serialize
|
||||
// same-window re-entry, so two overlapping
|
||||
// clicks here would interleave destructive
|
||||
// writes and the second run's sentinel-clear
|
||||
// could mask a still-partial first apply.
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId === backup.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore confirmation dialog (I2). Keeps the destructive action
|
||||
gated behind an explicit second click, mirroring the clear-local
|
||||
dialog elsewhere in this screen. */}
|
||||
<Dialog
|
||||
open={pendingRestoreBackup !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingRestoreBackup(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[440px] z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t('cloudSync.localBackups.restoreConfirmTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.localBackups.restoreConfirmDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pendingRestoreBackup && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
|
||||
<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', {
|
||||
hosts: String(pendingRestoreBackup.preview.hostCount),
|
||||
keys: String(pendingRestoreBackup.preview.keyCount),
|
||||
snippets: String(pendingRestoreBackup.preview.snippetCount),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(null)}
|
||||
disabled={restoringBackupId !== null}
|
||||
>
|
||||
{t('cloudSync.localBackups.restoreConfirmCancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const target = pendingRestoreBackup;
|
||||
if (!target) return;
|
||||
setPendingRestoreBackup(null);
|
||||
await performRestore(target.id);
|
||||
}}
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId !== null ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restoreConfirmButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
@@ -780,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);
|
||||
@@ -798,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(() => {
|
||||
@@ -1012,7 +1472,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
if (result.success) {
|
||||
// Apply merged data if a three-way merge happened
|
||||
if (result.mergedPayload && onApplyPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
}
|
||||
toast.success(t('cloudSync.sync.success', { provider }));
|
||||
} else if (result.conflictDetected) {
|
||||
@@ -1030,13 +1490,49 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
try {
|
||||
const payload = await sync.resolveConflict(resolution);
|
||||
if (payload && resolution === 'USE_REMOTE') {
|
||||
onApplyPayload(payload);
|
||||
// USE_REMOTE applies cloud data over local — same data-loss
|
||||
// shape as a local backup restore, so gate auto-sync in
|
||||
// every other window the same way.
|
||||
await withRestoreBarrier(async () => {
|
||||
await Promise.resolve(onApplyPayload(payload));
|
||||
});
|
||||
toast.success(t('cloudSync.resolve.downloaded'));
|
||||
} else if (resolution === 'USE_LOCAL') {
|
||||
// Re-sync with local data
|
||||
// Re-sync with local data. Hold the same cross-window
|
||||
// restore barrier that USE_REMOTE uses: without it, a
|
||||
// concurrent auto-sync tick in another window can slip
|
||||
// between our conflict resolution and the upload,
|
||||
// producing a second upload path with stale state that
|
||||
// races against this push. USE_LOCAL doesn't mutate the
|
||||
// renderer's in-memory state (no onApplyPayload call), so
|
||||
// the barrier is belt-and-suspenders against the other
|
||||
// window's push, not ours.
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
await sync.syncNow(localPayload);
|
||||
|
||||
let results: Map<CloudProvider, SyncResult> | null = null;
|
||||
await withRestoreBarrier(async () => {
|
||||
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);
|
||||
@@ -1094,9 +1590,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreRevision = () => {
|
||||
const handleRestoreRevision = async () => {
|
||||
if (!historyPreview) return;
|
||||
onApplyPayload(historyPreview.payload);
|
||||
// Gist revision restore is a destructive "replace local with cloud
|
||||
// snapshot" op — same shape as a local backup restore, same
|
||||
// cross-window race to block.
|
||||
await withRestoreBarrier(async () => {
|
||||
await Promise.resolve(onApplyPayload(historyPreview.payload));
|
||||
});
|
||||
toast.success(t('cloudSync.revisionHistory.restored'));
|
||||
setShowHistoryModal(false);
|
||||
setHistoryPreview(null);
|
||||
@@ -1142,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>
|
||||
@@ -1327,6 +1841,12 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={localBackupsRef}>
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Local Data */}
|
||||
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1945,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>
|
||||
);
|
||||
};
|
||||
@@ -1955,7 +2538,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
interface CloudSyncSettingsProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
@@ -1965,7 +2548,19 @@ export const CloudSyncSettings: React.FC<CloudSyncSettingsProps> = (props) => {
|
||||
// Simplified UX: once a master key is configured, we auto-unlock via safeStorage
|
||||
// so users don't have to manage a separate LOCKED screen.
|
||||
if (securityState === 'NO_KEY') {
|
||||
return <GatekeeperScreen onSetupComplete={() => { }} />;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GatekeeperScreen onSetupComplete={() => { }} />
|
||||
{/* The master key is not configured yet. Expose the backup
|
||||
history for diagnostic purposes but refuse restores: the
|
||||
vault encryption layer can't re-protect the restored
|
||||
credentials until the user finishes master-key setup (I3). */}
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={props.onApplyPayload}
|
||||
restoreDisabledReason="no-master-key"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SyncDashboard {...props} />;
|
||||
|
||||
@@ -67,27 +67,27 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
|
||||
<DialogTitle>{t('dialog.createWorkspace.title', { defaultValue: 'Create Workspace' })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
|
||||
<Label htmlFor="workspace-name">{t('field.name', { defaultValue: 'Name' })}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
|
||||
placeholder={t('placeholder.workspaceName', { defaultValue: 'Workspace Name' })}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1 flex flex-col min-h-0">
|
||||
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
|
||||
<Label>{t('field.selectHosts', { defaultValue: 'Select Hosts' })}</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
|
||||
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
@@ -99,7 +99,7 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredHosts.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{t('common.noResults', 'No hosts found')}
|
||||
{t('common.noResults', { defaultValue: 'No hosts found' })}
|
||||
</div>
|
||||
) : (
|
||||
filteredHosts.map(host => {
|
||||
@@ -126,15 +126,15 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
{selectedHostIds.size} {t('common.selected', 'selected')}
|
||||
{selectedHostIds.size} {t('common.selected', { defaultValue: 'selected' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common.cancel', { defaultValue: 'Cancel' })}</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
|
||||
{t('common.create', 'Create')}
|
||||
{t('common.create', { defaultValue: 'Create' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -70,6 +70,7 @@ interface QuickSwitcherProps {
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
keyBindings?: KeyBinding[];
|
||||
showSftpTab: boolean;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -84,6 +85,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
keyBindings,
|
||||
showSftpTab,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
@@ -161,7 +163,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
);
|
||||
// Tabs (built-in + sessions + workspaces)
|
||||
items.push({ type: "tab", id: "vault" });
|
||||
items.push({ type: "tab", id: "sftp" });
|
||||
if (showSftpTab) items.push({ type: "tab", id: "sftp" });
|
||||
orphanSessions.forEach((s) =>
|
||||
items.push({ type: "tab", id: s.id, data: s }),
|
||||
);
|
||||
@@ -194,7 +196,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
|
||||
return { flatItems: items, itemIndexMap: indexMap };
|
||||
}, [showCategorized, results, orphanSessions, workspaces, filteredShells]);
|
||||
}, [showCategorized, results, orphanSessions, workspaces, filteredShells, showSftpTab]);
|
||||
|
||||
// O(1) index lookup
|
||||
const getItemIndex = useCallback((type: string, id: string) => {
|
||||
@@ -317,7 +319,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
{(showSftpTab ? ["vault", "sftp"] : ["vault"]).map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
|
||||
@@ -41,7 +41,7 @@ class AITabErrorBoundary extends React.Component<
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
return (this.props as { children: React.ReactNode }).children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
setCustomCSS={settings.setCustomCSS}
|
||||
showRecentHosts={settings.showRecentHosts}
|
||||
setShowRecentHosts={settings.setShowRecentHosts}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,8 @@ 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";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
@@ -245,6 +247,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const sessionRef = useRef<string | null>(null);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
// Token for an in-flight retry chain. handleRetry sets this to a fresh
|
||||
// symbol; any cancel/close/teardown/subsequent-retry invalidates it. The
|
||||
// chained xterm.write callbacks verify the token before proceeding so a
|
||||
// cancelled retry can't fire a startNewSession after the fact.
|
||||
const retryTokenRef = useRef<symbol | null>(null);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
@@ -614,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);
|
||||
@@ -684,6 +697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
retryTokenRef.current = null;
|
||||
cleanupSession();
|
||||
xtermRuntimeRef.current?.dispose();
|
||||
xtermRuntimeRef.current = null;
|
||||
@@ -786,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;
|
||||
@@ -989,7 +1004,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (terminalSettings) {
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback;
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
| 200
|
||||
@@ -1223,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);
|
||||
});
|
||||
@@ -1314,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;
|
||||
|
||||
@@ -1398,6 +1419,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
retryTokenRef.current = null;
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
auth.setAuthRetryMessage(null);
|
||||
@@ -1417,6 +1439,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const handleCloseDisconnectedSession = () => {
|
||||
retryTokenRef.current = null;
|
||||
onCloseSession?.(sessionId);
|
||||
};
|
||||
|
||||
@@ -1458,10 +1481,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleRetry = () => {
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
// Reset terminal state: disable mouse tracking modes and clear screen so
|
||||
// stale SGR mouse sequences don't leak into the new session as text input.
|
||||
termRef.current.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
|
||||
termRef.current.reset();
|
||||
const term = termRef.current;
|
||||
// Claim a fresh retry token. If the user cancels / closes / unmounts /
|
||||
// kicks off another retry while the chained writes below are still
|
||||
// queued, the token will be invalidated and our callbacks will abort
|
||||
// before opening a ghost backend session with no owning UI.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
const retryStillActive = () => retryTokenRef.current === retryToken && termRef.current === term;
|
||||
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
@@ -1470,17 +1498,51 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
setShowLogs(true);
|
||||
if (host.protocol === "serial") {
|
||||
sessionStarters.startSerial(termRef.current);
|
||||
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
sessionStarters.startLocal(termRef.current);
|
||||
} else if (host.protocol === "telnet") {
|
||||
sessionStarters.startTelnet(termRef.current);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(termRef.current);
|
||||
} else {
|
||||
sessionStarters.startSSH(termRef.current);
|
||||
}
|
||||
|
||||
const startNewSession = () => {
|
||||
if (!retryStillActive()) return;
|
||||
if (host.protocol === "serial") {
|
||||
sessionStarters.startSerial(term);
|
||||
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
sessionStarters.startLocal(term);
|
||||
} else if (host.protocol === "telnet") {
|
||||
sessionStarters.startTelnet(term);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(term);
|
||||
} else {
|
||||
sessionStarters.startSSH(term);
|
||||
}
|
||||
};
|
||||
|
||||
// Chain the whole preparation through xterm.write callbacks so everything
|
||||
// lands in strict order — see #695. xterm.write is async, so without
|
||||
// chaining, a fast reconnect path (local/serial especially) can interleave
|
||||
// the new session's first bytes with our reset sequence, corrupting the
|
||||
// first screen.
|
||||
//
|
||||
// 1. Exit the alternate screen first. preserveTerminalViewportInScrollback
|
||||
// is a no-op on the alt buffer (disconnect while in vim/less/top), so
|
||||
// we must be on the normal buffer before preserving.
|
||||
term.write('\x1b[?1049l', () => {
|
||||
if (!retryStillActive()) return;
|
||||
// 2. Push the previous session's viewport into scrollback so the user
|
||||
// can still read it after reconnect.
|
||||
preserveTerminalViewportInScrollback(term);
|
||||
// 3. Soft terminal reset (DECSTR, \x1b[!p) resets VT220-era modes that
|
||||
// full-screen apps may have left on — DECCKM (otherwise arrow keys
|
||||
// emit SS3 and break readline history), keypad mode, SGR,
|
||||
// insert/replace, origin, cursor visibility — without clearing the
|
||||
// buffer. DECSTR does not cover xterm-specific extensions, so also
|
||||
// explicitly disable mouse tracking (1000/1002/1003/1006) and
|
||||
// bracketed paste (2004). Finally home the cursor.
|
||||
term.write(
|
||||
'\x1b[!p\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[H',
|
||||
// 4. Only now — after every prep byte has been applied to the
|
||||
// terminal — start the new session, so its first output can't
|
||||
// interleave with the reset sequence.
|
||||
startNewSession,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = status !== "connected"
|
||||
@@ -1615,6 +1677,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isAlternateScreen={hasMouseTracking}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onPasteSelection={terminalContextActions.onPasteSelection}
|
||||
onSelectAll={terminalContextActions.onSelectAll}
|
||||
onClear={terminalContextActions.onClear}
|
||||
onSelectWord={terminalContextActions.onSelectWord}
|
||||
@@ -1657,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)',
|
||||
|
||||
@@ -33,6 +33,7 @@ import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKe
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import Terminal from './Terminal';
|
||||
@@ -40,7 +41,7 @@ import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { cleanupOrphanedAISessions, useAIState } from '../application/state/useAIState';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
@@ -259,6 +260,10 @@ interface AIChatPanelsHostProps {
|
||||
}) => ExecutorContext;
|
||||
}
|
||||
|
||||
interface AIStateMaintenanceHostProps {
|
||||
validAIScopeTargetIds: Set<string>;
|
||||
}
|
||||
|
||||
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const aiState = useAIState();
|
||||
return (
|
||||
@@ -271,6 +276,27 @@ const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const AIStateProvider = memo(AIStateProviderInner);
|
||||
AIStateProvider.displayName = 'AIStateProvider';
|
||||
|
||||
const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
|
||||
validAIScopeTargetIds,
|
||||
}) => {
|
||||
const aiState = useContext(AIStateContext);
|
||||
|
||||
if (!aiState) {
|
||||
throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider');
|
||||
}
|
||||
|
||||
const { cleanupOrphanedSessions } = aiState;
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOrphanedSessions(validAIScopeTargetIds);
|
||||
}, [cleanupOrphanedSessions, validAIScopeTargetIds]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const AIStateMaintenanceHost = memo(AIStateMaintenanceHostInner);
|
||||
AIStateMaintenanceHost.displayName = 'AIStateMaintenanceHost';
|
||||
|
||||
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
mountedTabIds,
|
||||
activeTabId,
|
||||
@@ -300,12 +326,20 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
draftsByScope={aiState.draftsByScope}
|
||||
panelViewByScope={aiState.panelViewByScope}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
ensureDraftForScope={aiState.ensureDraftForScope}
|
||||
updateDraft={aiState.updateDraft}
|
||||
showDraftView={aiState.showDraftView}
|
||||
showSessionView={aiState.showSessionView}
|
||||
clearDraftForScope={aiState.clearDraftForScope}
|
||||
addDraftFiles={aiState.addDraftFiles}
|
||||
removeDraftFile={aiState.removeDraftFile}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
retargetSessionScope={aiState.retargetSessionScope}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
@@ -395,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> = ({
|
||||
@@ -448,6 +484,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -628,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';
|
||||
@@ -852,7 +893,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return map;
|
||||
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
|
||||
|
||||
const validTerminalTabIds = useMemo(() => {
|
||||
const validAIScopeTargetIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const session of sessions) ids.add(session.id);
|
||||
for (const workspace of workspaces) ids.add(workspace.id);
|
||||
@@ -940,16 +981,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidePanelOpenTabs(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
sessionActivityStore.prune(validSessionActivityIds);
|
||||
}, [validSessionActivityIds, validTerminalTabIds]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOrphanedAISessions(validTerminalTabIds);
|
||||
}, [validTerminalTabIds]);
|
||||
}, [validSessionActivityIds, validAIScopeTargetIds]);
|
||||
|
||||
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
|
||||
if (!workspace) return {} as Record<string, WorkspaceRect>;
|
||||
@@ -1228,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);
|
||||
@@ -1253,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) => {
|
||||
@@ -1646,8 +1708,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// recomputing scope resolution from scratch on every tab switch.
|
||||
const aiContextsByTabId = useMemo(() => {
|
||||
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
|
||||
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
|
||||
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
|
||||
const tabIds = new Set<string>(mountedAiTabIds);
|
||||
if (activeTabId) tabIds.add(activeTabId);
|
||||
|
||||
@@ -1919,6 +1981,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
return (
|
||||
<AIStateProvider>
|
||||
<AIStateMaintenanceHost validAIScopeTargetIds={validAIScopeTargetIds} />
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
@@ -1973,7 +2036,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="sftp"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'sftp'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
@@ -1987,7 +2053,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="scripts"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'scripts'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
@@ -2001,7 +2070,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="theme"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'theme'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
@@ -2015,7 +2087,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="ai"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'ai'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
@@ -2359,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;
|
||||
@@ -44,6 +45,7 @@ interface TopTabsProps {
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
showSftpTab: boolean;
|
||||
}
|
||||
|
||||
// Detect local OS for local terminal tab icons
|
||||
@@ -243,6 +245,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onCloseLogView,
|
||||
onCloseTabsBatch,
|
||||
onOpenQuickSwitcher,
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
@@ -251,6 +254,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
showSftpTab,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -302,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();
|
||||
};
|
||||
}
|
||||
@@ -480,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) => {
|
||||
@@ -498,6 +545,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={session.id}
|
||||
data-tab-type="session"
|
||||
data-state={activeTabId === session.id ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(session.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, session.id)}
|
||||
@@ -506,7 +555,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -577,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>
|
||||
);
|
||||
@@ -597,6 +647,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={workspace.id}
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(workspace.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, workspace.id)}
|
||||
@@ -605,7 +657,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -681,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>
|
||||
);
|
||||
@@ -695,9 +748,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<div
|
||||
key={logView.id}
|
||||
data-tab-id={logView.id}
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
@@ -785,9 +840,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
|
||||
<div
|
||||
data-tab-id="vault"
|
||||
data-tab-type="root"
|
||||
data-state={isVaultActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab('vault')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"netcatty-tab relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isVaultActive
|
||||
@@ -812,40 +870,45 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
>
|
||||
<FolderLock size={14} /> Vaults
|
||||
</div>
|
||||
<div
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isSftpActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
{showSftpTab && (
|
||||
<div
|
||||
data-tab-id="sftp"
|
||||
data-tab-type="root"
|
||||
data-state={isSftpActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"netcatty-tab relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isSftpActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable tabs container with fade masks */}
|
||||
@@ -969,7 +1032,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.onSyncNow === next.onSyncNow &&
|
||||
prev.onToggleTheme === next.onToggleTheme &&
|
||||
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
|
||||
prev.isImmersiveActive === next.isImmersiveActive
|
||||
prev.isImmersiveActive === next.isImmersiveActive &&
|
||||
prev.showSftpTab === next.showSftpTab
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -36,10 +36,14 @@ 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 { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED, STORAGE_KEY_SHOW_RECENT_HOSTS } from "../infrastructure/config/storageKeys";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
|
||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import {
|
||||
@@ -147,6 +151,8 @@ interface VaultViewProps {
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
// Optional: navigate to a specific section on mount or when changed
|
||||
navigateToSection?: VaultSection | null;
|
||||
onNavigateToSectionHandled?: () => void;
|
||||
@@ -193,6 +199,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onRunSnippet,
|
||||
groupConfigs,
|
||||
onUpdateGroupConfigs,
|
||||
showRecentHosts,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
navigateToSection,
|
||||
onNavigateToSectionHandled,
|
||||
}) => {
|
||||
@@ -230,11 +238,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [confirmedDropTarget, setConfirmedDropTarget] = useState<DropTarget | null>(null);
|
||||
const dropTargetPulseTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
true,
|
||||
);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
if (navigateToSection) {
|
||||
@@ -874,6 +877,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
});
|
||||
} else if (showOnlyUngroupedHostsInRoot) {
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = (h.group || "").trim();
|
||||
return hostGroup === "";
|
||||
});
|
||||
}
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
@@ -911,7 +919,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}, [hosts, selectedGroupPath, search, selectedTags, sortMode]);
|
||||
}, [hosts, selectedGroupPath, showOnlyUngroupedHostsInRoot, search, selectedTags, sortMode]);
|
||||
|
||||
// Pinned hosts for root-level display (not inside a subgroup)
|
||||
// Respects active search and tag filters
|
||||
@@ -962,6 +970,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// No longer deduplicate pinned/recent hosts from the main list,
|
||||
// so hosts always appear in their groups regardless of pinned/recent status.
|
||||
const pinnedRecentIds = useMemo(() => new Set<string>(), []);
|
||||
const visibleDisplayedHosts = useMemo(
|
||||
() => displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)),
|
||||
[displayedHosts, selectedGroupPath, pinnedRecentIds],
|
||||
);
|
||||
|
||||
// For tree view: apply search, tag filter, and sorting, but not group filtering
|
||||
const treeViewHosts = useMemo(() => {
|
||||
@@ -1125,6 +1137,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- findGroupNode is derived from buildGroupTree
|
||||
}, [buildGroupTree, selectedGroupPath, customGroups]);
|
||||
const shouldHideEmptyRootHostsSection = useMemo(() => {
|
||||
if (selectedGroupPath || viewMode === "tree") return false;
|
||||
if (search.trim() || selectedTags.length > 0) return false;
|
||||
if (visibleDisplayedHosts.length > 0) return false;
|
||||
return (
|
||||
displayedGroups.length > 0 ||
|
||||
pinnedHosts.length > 0 ||
|
||||
(showRecentHosts && recentHosts.length > 0)
|
||||
);
|
||||
}, [
|
||||
selectedGroupPath,
|
||||
viewMode,
|
||||
search,
|
||||
selectedTags.length,
|
||||
visibleDisplayedHosts.length,
|
||||
displayedGroups.length,
|
||||
pinnedHosts.length,
|
||||
showRecentHosts,
|
||||
recentHosts.length,
|
||||
]);
|
||||
|
||||
// Known Hosts callbacks - use refs to keep stable references
|
||||
// Store latest values in refs so callbacks don't need to depend on them
|
||||
@@ -2353,6 +2385,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!shouldHideEmptyRootHostsSection && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
@@ -2360,7 +2393,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
@@ -2622,7 +2655,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
style={viewMode === "grid" ? splitViewGridStyle : undefined}
|
||||
>
|
||||
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
|
||||
{visibleDisplayedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
@@ -2754,6 +2787,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentSection === "snippets" && (
|
||||
@@ -2907,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);
|
||||
@@ -2939,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"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-hidden', className)}
|
||||
className={cn('relative flex-1 overflow-x-hidden overflow-y-hidden', className)}
|
||||
initial="instant"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
@@ -20,7 +20,7 @@ export type ConversationContentProps = ComponentProps<typeof StickToBottom.Conte
|
||||
|
||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn('flex flex-col gap-4 p-4', className)}
|
||||
className={cn('flex min-w-0 max-w-full flex-col gap-4 overflow-x-hidden p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ export const MessageResponse = memo(
|
||||
// Style the rendered markdown
|
||||
// Code: base styles (code-block overrides are in index.css)
|
||||
'[&_code]:text-[12px] [&_code]:font-mono',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%] [&_p_code]:whitespace-normal [&_p_code]:[overflow-wrap:anywhere]',
|
||||
'[&_p]:my-1.5',
|
||||
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
|
||||
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -40,6 +41,7 @@ function formatToolResult(result: unknown): string {
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
className?: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-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';
|
||||
import type { UploadedFile } from '../../application/state/useFileUpload';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
@@ -21,7 +20,11 @@ import {
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
const MODEL_PICKER_MAX_WIDTH = 360;
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
@@ -48,6 +51,14 @@ interface ChatInputProps {
|
||||
onRemoveFile?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** User skills currently selected for the next send */
|
||||
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
|
||||
/** Available user skills for /skill-slug insertion */
|
||||
userSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
|
||||
/** Callback to add a selected user skill */
|
||||
onAddUserSkill?: (slug: string) => void;
|
||||
/** Callback to remove a selected user skill */
|
||||
onRemoveUserSkill?: (slug: string) => void;
|
||||
/** Permission mode (only shown for Catty Agent) */
|
||||
permissionMode?: AIPermissionMode;
|
||||
/** Callback when user changes permission mode */
|
||||
@@ -72,38 +83,76 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onAddFiles,
|
||||
onRemoveFile,
|
||||
hosts = [],
|
||||
selectedUserSkills = [],
|
||||
userSkills = [],
|
||||
onAddUserSkill,
|
||||
onRemoveUserSkill,
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashSkill' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
|
||||
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
|
||||
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
|
||||
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
|
||||
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';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showSlashSkillPicker = activeMenu === 'slashSkill';
|
||||
const showPermPicker = activeMenu === 'perm';
|
||||
|
||||
const closeAllMenus = useCallback(() => {
|
||||
setActiveMenu(null);
|
||||
setMenuPos(null);
|
||||
setInputPanelPos(null);
|
||||
setHoveredModelId(null);
|
||||
setShowHostSubmenu(false);
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
}, []);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputShellRef = useRef<HTMLDivElement>(null);
|
||||
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const permBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
|
||||
const beforeCaret = text.slice(0, caretPosition);
|
||||
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
|
||||
if (!match) return null;
|
||||
const start = beforeCaret.length - match[0].length + match[1].length;
|
||||
return {
|
||||
start,
|
||||
end: beforeCaret.length,
|
||||
query: String(match[2] || '').toLowerCase(),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getInputPanelMenuPos = useCallback(() => {
|
||||
const rect = inputShellRef.current?.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
const horizontalMargin = 12;
|
||||
const safeRight = window.innerWidth - horizontalMargin;
|
||||
const width = Math.min(rect.width, safeRight - rect.left);
|
||||
return {
|
||||
left: rect.left,
|
||||
bottom: window.innerHeight - rect.top + 8,
|
||||
width,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((newValue: string) => {
|
||||
onChange(newValue);
|
||||
const caretPosition = textareaRef.current?.selectionStart ?? newValue.length;
|
||||
// Detect if user just typed @
|
||||
if (
|
||||
hosts.length > 0 &&
|
||||
@@ -111,16 +160,28 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
newValue.endsWith('@')
|
||||
) {
|
||||
// Position the popover near the textarea
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
|
||||
}
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (pos) setInputPanelPos(pos);
|
||||
setActiveMenu('atMention');
|
||||
} else if (showAtMention && !newValue.includes('@')) {
|
||||
setActiveMenu(null);
|
||||
return;
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention]);
|
||||
|
||||
const slashTrigger = findSlashTrigger(newValue, caretPosition);
|
||||
if (userSkills.length > 0 && slashTrigger) {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (pos) setInputPanelPos(pos);
|
||||
setSlashQuery(slashTrigger.query);
|
||||
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
|
||||
setActiveMenu('slashSkill');
|
||||
return;
|
||||
}
|
||||
|
||||
if (showAtMention && !newValue.includes('@')) {
|
||||
setActiveMenu(null);
|
||||
} else if (showSlashSkillPicker) {
|
||||
closeAllMenus();
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, userSkills.length, showSlashSkillPicker, closeAllMenus, getInputPanelMenuPos]);
|
||||
|
||||
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
|
||||
// Replace the trailing @ with @hostname
|
||||
@@ -133,10 +194,117 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashSkill') => {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (!pos) return;
|
||||
setInputPanelPos(pos);
|
||||
if (menu === 'slashSkill') {
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
}
|
||||
setActiveMenu(menu);
|
||||
}, [getInputPanelMenuPos]);
|
||||
|
||||
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;
|
||||
const before = value.slice(0, slashRange.start);
|
||||
const after = value.slice(slashRange.end);
|
||||
if (/\s$/.test(before) && /^\s/.test(after)) {
|
||||
return `${before}${after.slice(1)}`;
|
||||
}
|
||||
return `${before}${after}`;
|
||||
}, [slashRange, value]);
|
||||
|
||||
const insertUserSkillToken = useCallback((skill: { slug: string }) => {
|
||||
onAddUserSkill?.(skill.slug);
|
||||
if (slashRange) {
|
||||
onChange(removeSlashQueryFromInput());
|
||||
}
|
||||
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) => item.getAsFile())
|
||||
.filter(Boolean) as File[];
|
||||
.map((item: DataTransferItem) => item.getAsFile())
|
||||
.filter((f): f is File => !!f);
|
||||
if (pastedFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
onAddFiles?.(pastedFiles);
|
||||
@@ -166,21 +334,40 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
// Permission mode chip removed — agents run in autonomous mode
|
||||
|
||||
// selectedModelId may be "model/thinking" for codex
|
||||
const selectedBaseModelId = selectedModelId?.split('/')[0];
|
||||
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
|
||||
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
|
||||
// selectedModelId may be "<modelId>/<thinkingLevel>" for codex ChatGPT models
|
||||
// (e.g. "gpt-5.4/high"). Note: custom config.toml / OpenRouter model ids
|
||||
// themselves can contain '/' (e.g. "qwen/qwen3.6-plus"), so don't just
|
||||
// split on the first '/'. Match against the full id first; only treat the
|
||||
// trailing segment as a thinking level when we find a preset whose
|
||||
// declared thinkingLevels make the combined form equal to selectedModelId.
|
||||
const { selectedPreset, selectedThinking } = (() => {
|
||||
if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
const direct = modelPresets.find(m => m.id === selectedModelId);
|
||||
if (direct) return { selectedPreset: direct, selectedThinking: undefined };
|
||||
const viaThinking = modelPresets.find(
|
||||
m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId),
|
||||
);
|
||||
if (viaThinking) {
|
||||
const thinking = selectedModelId.slice(viaThinking.id.length + 1);
|
||||
return { selectedPreset: viaThinking, selectedThinking: thinking };
|
||||
}
|
||||
return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
})();
|
||||
const selectedBaseModelId = selectedPreset?.id;
|
||||
const modelLabel = selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel');
|
||||
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
const selectedSkillChipClassName =
|
||||
'inline-flex h-7 items-center gap-1.5 rounded-full border border-primary/18 bg-primary/8 pl-2.5 pr-1.5 text-[11px] font-medium text-foreground/86 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]';
|
||||
const iconButtonClassName =
|
||||
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<div ref={inputShellRef} className="relative">
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
{/* File attachment chips */}
|
||||
{files.length > 0 && (
|
||||
@@ -224,13 +411,44 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
{/* Textarea with expand toggle */}
|
||||
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
{selectedUserSkills.length > 0 && (
|
||||
<div className="px-3 pt-3 pb-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedUserSkills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className={selectedSkillChipClassName}
|
||||
title={skill.description || skill.name || skill.slug}
|
||||
>
|
||||
<Package size={11} className="text-primary/72 shrink-0" />
|
||||
<span className="truncate max-w-[180px]">
|
||||
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUserSkill?.(skill.slug)}
|
||||
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
|
||||
aria-label={`Remove skill ${skill.name || skill.slug}`}
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled}
|
||||
className={expanded ? 'max-h-[220px]' : undefined}
|
||||
className={[
|
||||
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
|
||||
expanded ? 'max-h-[220px]' : undefined,
|
||||
].filter(Boolean).join(' ')}
|
||||
maxLength={100000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -243,31 +461,93 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
</div>
|
||||
|
||||
{/* @ mention popover */}
|
||||
{showAtMention && hosts.length > 0 && menuPos && createPortal(
|
||||
{showAtMention && hosts.length > 0 && inputPanelPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mention host"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
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-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<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>
|
||||
{showHostnameLine ? (
|
||||
<div className="pl-3.5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{host.hostname}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* / skill popover */}
|
||||
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Insert user skill"
|
||||
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 }}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
</>,
|
||||
document.body,
|
||||
@@ -322,48 +602,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<ImageIcon size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
|
||||
</button>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowHostSubmenu(true)}
|
||||
onMouseLeave={() => setShowHostSubmenu(false)}
|
||||
onFocus={() => setShowHostSubmenu(true)}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="Mention host"
|
||||
onClick={() => openInputPanelMenu('atMention')}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<AtSign size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
|
||||
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{userSkills.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="Mention host"
|
||||
aria-expanded={showHostSubmenu && hosts.length > 0}
|
||||
aria-label="Insert user skill"
|
||||
onClick={() => openInputPanelMenu('slashSkill')}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<AtSign size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
|
||||
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
<Package size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuUserSkills')}</span>
|
||||
<ChevronRight size={10} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
{showHostSubmenu && hosts.length > 0 && (
|
||||
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const mention = `@${host.label || host.hostname} `;
|
||||
onChange(value + mention);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
@@ -375,7 +637,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
if (!hasModelPicker) return;
|
||||
if (!showModelPicker) {
|
||||
const rect = modelBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
if (rect) {
|
||||
// Clamp so the popover stays inside the viewport when
|
||||
// the chip is near the right edge of a narrow AI side
|
||||
// panel.
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
|
||||
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
setActiveMenu('model');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
@@ -395,8 +663,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{modelPresets.map(preset => {
|
||||
@@ -420,12 +688,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="flex-1 text-foreground/85">{preset.name}</span>
|
||||
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50 shrink-0" />}
|
||||
</button>
|
||||
{/* Thinking level sub-menu */}
|
||||
{hasThinking && hoveredModelId === preset.id && (
|
||||
@@ -555,6 +822,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -177,13 +177,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{message.toolResults?.map((tr) => (
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
<div key={tr.toolCallId}>
|
||||
<ToolCall
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -255,15 +256,16 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
<div key={tc.id}>
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -308,34 +310,35 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
<div key={tc.id}>
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
|
||||
{Array.from(pendingApprovals.entries())
|
||||
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
|
||||
.map((entry) => {
|
||||
const [id, req] = entry;
|
||||
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
|
||||
.map(([id, req]) => {
|
||||
return (
|
||||
<ToolCall
|
||||
key={id}
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
onApprove={() => handleApprove(id)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
<div key={id}>
|
||||
<ToolCall
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
onApprove={() => handleApprove(id)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Streaming indicator — only when no content and no thinking yet */}
|
||||
|
||||
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;
|
||||
}
|
||||
177
components/ai/aiPanelViewState.test.ts
Normal file
177
components/ai/aiPanelViewState.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
normalizePanelView,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
} from "./aiPanelViewState.ts";
|
||||
|
||||
function createSession(id: string): AISession {
|
||||
return {
|
||||
id,
|
||||
title: `Session ${id}`,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "catty",
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId: "terminal-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("draft view never falls back to most recent history", () => {
|
||||
const panelView: AIPanelView = { mode: "draft" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), null);
|
||||
});
|
||||
|
||||
test("session view returns the selected session", () => {
|
||||
const selectedSession = createSession("session-2");
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: selectedSession.id };
|
||||
const sessions = [createSession("session-1"), selectedSession];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), selectedSession);
|
||||
});
|
||||
|
||||
test("missing session target resolves to null instead of history fallback", () => {
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), null);
|
||||
});
|
||||
|
||||
test("missing session target normalizes back to draft view", () => {
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(normalizePanelView(panelView, sessions), { mode: "draft" });
|
||||
});
|
||||
|
||||
test("missing explicit panel view resumes the most recent matching history when no draft exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "workspace"),
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
);
|
||||
});
|
||||
|
||||
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", "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
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, "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")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, sessions),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("draft state is used when there is no implicit history to resume", () => {
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, []),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("history selection switches to the chosen session without touching draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyHistorySessionSelection("session-2", {
|
||||
showSessionView: (sessionId) => {
|
||||
calls.push(`view:${sessionId}`);
|
||||
},
|
||||
setActiveSessionId: (sessionId) => {
|
||||
calls.push(`active:${sessionId}`);
|
||||
},
|
||||
closeHistory: () => {
|
||||
calls.push("close-history");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"view:session-2",
|
||||
"active:session-2",
|
||||
"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",
|
||||
]);
|
||||
});
|
||||
94
components/ai/aiPanelViewState.ts
Normal file
94
components/ai/aiPanelViewState.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: "draft" };
|
||||
|
||||
interface HistorySessionSelectionActions {
|
||||
showSessionView: (sessionId: string) => void;
|
||||
setActiveSessionId: (sessionId: string) => void;
|
||||
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);
|
||||
}
|
||||
|
||||
if (hasDraft) {
|
||||
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)) {
|
||||
return { mode: "session", sessionId: persistedSessionId };
|
||||
}
|
||||
|
||||
if (sessions[0]) {
|
||||
return { mode: "session", sessionId: sessions[0].id };
|
||||
}
|
||||
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function normalizePanelView(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
): AIPanelView {
|
||||
if (panelView.mode !== "session") {
|
||||
return panelView;
|
||||
}
|
||||
|
||||
return sessions.some((session) => session.id === panelView.sessionId)
|
||||
? panelView
|
||||
: DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function resolveDisplayedSession(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
): AISession | null {
|
||||
if (panelView.mode !== "session") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
|
||||
}
|
||||
|
||||
export function applyHistorySessionSelection(
|
||||
sessionId: string,
|
||||
actions: HistorySessionSelectionActions,
|
||||
): void {
|
||||
actions.showSessionView(sessionId);
|
||||
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;
|
||||
}
|
||||
@@ -121,6 +121,17 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
chatSessionId?: string,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<{
|
||||
ok: boolean;
|
||||
skills?: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'ready' | 'warning';
|
||||
}>;
|
||||
}>;
|
||||
aiUserSkillsBuildContext?: (prompt: string, selectedSkillSlugs?: string[]) => Promise<{ ok: boolean; context?: string; error?: string }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
|
||||
@@ -155,6 +166,45 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
const USER_SKILLS_CONTEXT_TIMEOUT_MS = 500;
|
||||
|
||||
interface UserSkillsContextResult {
|
||||
ok: boolean;
|
||||
context?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function buildExplicitUserSkillsFallback(selectedUserSkillSlugs?: string[]): string {
|
||||
if (!selectedUserSkillSlugs?.length) return '';
|
||||
return `The user explicitly selected these Netcatty user skills for this request: ${selectedUserSkillSlugs.map((slug) => `/${slug}`).join(', ')}. Honor those selections even if their expanded skill content is unavailable.`;
|
||||
}
|
||||
|
||||
async function resolveUserSkillsContext(
|
||||
bridge: PanelBridge | undefined,
|
||||
prompt: string,
|
||||
selectedUserSkillSlugs?: string[],
|
||||
): Promise<string> {
|
||||
if (!bridge?.aiUserSkillsBuildContext) {
|
||||
return buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
|
||||
}
|
||||
|
||||
const buildContextPromise: Promise<UserSkillsContextResult> = bridge
|
||||
.aiUserSkillsBuildContext(prompt, selectedUserSkillSlugs)
|
||||
.catch(() => ({ ok: false, context: '' }));
|
||||
|
||||
const hasExplicitSelections = (selectedUserSkillSlugs?.length ?? 0) > 0;
|
||||
const result = hasExplicitSelections
|
||||
? await buildContextPromise
|
||||
: await Promise.race([
|
||||
buildContextPromise,
|
||||
new Promise<UserSkillsContextResult>((resolve) =>
|
||||
setTimeout(() => resolve({ ok: false, context: '' }), USER_SKILLS_CONTEXT_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
|
||||
return result.context || buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
|
||||
}
|
||||
|
||||
const sharedStreamingSessionIds = new Set<string>();
|
||||
const sharedAbortControllers = new Map<string, AbortController>();
|
||||
const streamingSubscribers = new Set<() => void>();
|
||||
@@ -239,6 +289,7 @@ export interface SendToCattyContext {
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
selectedUserSkillSlugs?: string[];
|
||||
}
|
||||
|
||||
/** Context values needed by sendToExternalAgent that change frequently. */
|
||||
@@ -251,6 +302,7 @@ export interface SendToExternalContext {
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
selectedUserSkillSlugs?: string[];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -303,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: '',
|
||||
@@ -508,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;
|
||||
@@ -542,6 +592,11 @@ export function useAIChatStreaming({
|
||||
context: SendToExternalContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const userSkillsContext = await resolveUserSkillsContext(
|
||||
bridge,
|
||||
trimmed,
|
||||
context.selectedUserSkillSlugs,
|
||||
);
|
||||
|
||||
if (agentConfig.acpCommand && bridge) {
|
||||
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
@@ -551,11 +606,6 @@ export function useAIChatStreaming({
|
||||
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
|
||||
}
|
||||
|
||||
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
|
||||
// avoiding plaintext key transit across the IPC boundary.
|
||||
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
|
||||
const agentProviderId = openaiProvider?.id;
|
||||
|
||||
// Mutable flag: set after tool-result, cleared when new assistant msg is created
|
||||
let needsNewAssistantMsg = false;
|
||||
const maybeCreateAssistantMsg = () => {
|
||||
@@ -637,19 +687,23 @@ export function useAIChatStreaming({
|
||||
onDone: () => {},
|
||||
},
|
||||
abortController.signal,
|
||||
agentProviderId,
|
||||
// Managed ACP agents (codex, claude) must resolve auth from their own
|
||||
// CLI config/login state, so we deliberately pass no providerId here.
|
||||
// See issue #705 for Codex; same reasoning for Claude.
|
||||
undefined,
|
||||
context.selectedAgentModel,
|
||||
context.existingSessionId,
|
||||
context.historyMessages,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
context.toolIntegrationMode,
|
||||
context.defaultTargetSession,
|
||||
userSkillsContext,
|
||||
);
|
||||
} else {
|
||||
// Fallback: spawn as raw process
|
||||
await runExternalAgentTurn(
|
||||
agentConfig,
|
||||
trimmed,
|
||||
userSkillsContext ? `${userSkillsContext}\n\nUser request:\n${trimmed}` : trimmed,
|
||||
{
|
||||
onTextDelta: (text: string) => {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
|
||||
@@ -683,6 +737,11 @@ export function useAIChatStreaming({
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const userSkillsContext = await resolveUserSkillsContext(
|
||||
bridge,
|
||||
trimmed,
|
||||
context.selectedUserSkillSlugs,
|
||||
);
|
||||
const getExecutorContext = context.getExecutorContext ?? (() => ({
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
|
||||
@@ -710,6 +769,7 @@ export function useAIChatStreaming({
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
|
||||
userSkillsContext,
|
||||
});
|
||||
|
||||
// Guard: activeProvider must exist for Catty agent path
|
||||
|
||||
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;
|
||||
}
|
||||
80
components/ai/userSkillsState.test.ts
Normal file
80
components/ai/userSkillsState.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getNextSelectedUserSkillSlugsMap,
|
||||
getReadyUserSkillOptions,
|
||||
pruneSelectedUserSkillSlugsMap,
|
||||
} from "./userSkillsState.ts";
|
||||
|
||||
test("getReadyUserSkillOptions returns only ready skills and clears invalid payloads", () => {
|
||||
assert.deepEqual(getReadyUserSkillOptions(null), []);
|
||||
assert.deepEqual(getReadyUserSkillOptions({ ok: false }), []);
|
||||
assert.deepEqual(
|
||||
getReadyUserSkillOptions({
|
||||
ok: true,
|
||||
skills: [
|
||||
{
|
||||
id: "alpha",
|
||||
slug: "alpha",
|
||||
name: "Alpha",
|
||||
description: "Alpha helper",
|
||||
status: "ready",
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
slug: "beta",
|
||||
name: "Beta",
|
||||
description: "Beta helper",
|
||||
status: "warning",
|
||||
},
|
||||
],
|
||||
}),
|
||||
[
|
||||
{
|
||||
id: "alpha",
|
||||
slug: "alpha",
|
||||
name: "Alpha",
|
||||
description: "Alpha helper",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("pruneSelectedUserSkillSlugsMap removes stale slugs and empty scopes", () => {
|
||||
assert.deepEqual(
|
||||
pruneSelectedUserSkillSlugsMap(
|
||||
{
|
||||
"terminal:1": ["alpha", "missing"],
|
||||
"workspace:1": ["missing"],
|
||||
},
|
||||
[
|
||||
{
|
||||
id: "alpha",
|
||||
slug: "alpha",
|
||||
name: "Alpha",
|
||||
description: "Alpha helper",
|
||||
},
|
||||
],
|
||||
),
|
||||
{
|
||||
"terminal:1": ["alpha"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("getNextSelectedUserSkillSlugsMap preserves selections when refresh fails", () => {
|
||||
const selected = {
|
||||
"terminal:1": ["alpha", "missing"],
|
||||
"workspace:1": ["beta"],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
getNextSelectedUserSkillSlugsMap(selected, null),
|
||||
selected,
|
||||
);
|
||||
assert.equal(
|
||||
getNextSelectedUserSkillSlugsMap(selected, { ok: false }),
|
||||
selected,
|
||||
);
|
||||
});
|
||||
73
components/ai/userSkillsState.ts
Normal file
73
components/ai/userSkillsState.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface UserSkillStatusItemLike {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "ready" | "warning";
|
||||
}
|
||||
|
||||
export interface UserSkillsStatusLike {
|
||||
ok: boolean;
|
||||
skills?: UserSkillStatusItemLike[];
|
||||
}
|
||||
|
||||
export interface UserSkillOption {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function getReadyUserSkillOptions(
|
||||
status: UserSkillsStatusLike | null | undefined,
|
||||
): UserSkillOption[] {
|
||||
if (!status?.ok || !Array.isArray(status.skills)) return [];
|
||||
|
||||
return status.skills
|
||||
.filter((skill) => skill.status === "ready" && typeof skill.slug === "string" && skill.slug.length > 0)
|
||||
.map((skill) => ({
|
||||
id: skill.id,
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
}));
|
||||
}
|
||||
|
||||
export function pruneSelectedUserSkillSlugsMap(
|
||||
selectedByScope: Record<string, string[]>,
|
||||
options: UserSkillOption[],
|
||||
): Record<string, string[]> {
|
||||
const validSlugs = new Set(options.map((option) => option.slug));
|
||||
let changed = false;
|
||||
const nextEntries: Array<[string, string[]]> = [];
|
||||
|
||||
for (const [scopeKey, slugs] of Object.entries(selectedByScope)) {
|
||||
const filteredSlugs = slugs.filter((slug) => validSlugs.has(slug));
|
||||
if (filteredSlugs.length !== slugs.length) changed = true;
|
||||
if (filteredSlugs.length > 0) {
|
||||
nextEntries.push([scopeKey, filteredSlugs]);
|
||||
} else if (slugs.length > 0) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return selectedByScope;
|
||||
}
|
||||
|
||||
return Object.fromEntries(nextEntries);
|
||||
}
|
||||
|
||||
export function getNextSelectedUserSkillSlugsMap(
|
||||
selectedByScope: Record<string, string[]>,
|
||||
status: UserSkillsStatusLike | null | undefined,
|
||||
): Record<string, string[]> {
|
||||
if (!status?.ok || !Array.isArray(status.skills)) {
|
||||
return selectedByScope;
|
||||
}
|
||||
|
||||
return pruneSelectedUserSkillSlugsMap(
|
||||
selectedByScope,
|
||||
getReadyUserSkillOptions(status),
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* - CodexConnectionCard, ClaudeCodeCard
|
||||
* - SafetySettings
|
||||
*/
|
||||
import { Bot, Globe } from "lucide-react";
|
||||
import { AlertTriangle, Bot, FolderOpen, Globe, Link, Package, RefreshCcw } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Select, SettingRow } from "../settings-ui";
|
||||
import { AgentIconBadge } from "../../ai/AgentIconBadge";
|
||||
|
||||
@@ -32,6 +33,7 @@ import type {
|
||||
AgentPathInfo,
|
||||
CodexIntegrationStatus,
|
||||
CodexLoginSession,
|
||||
UserSkillsStatusResult,
|
||||
} from "./ai/types";
|
||||
import {
|
||||
AGENT_DEFAULTS,
|
||||
@@ -187,6 +189,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [copilotCustomPath, setCopilotCustomPath] = useState("");
|
||||
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
|
||||
const [userSkillsStatus, setUserSkillsStatus] = useState<UserSkillsStatusResult | null>(null);
|
||||
const [isLoadingUserSkills, setIsLoadingUserSkills] = useState(false);
|
||||
|
||||
// Ref to read current defaultAgentId without adding it as a dependency.
|
||||
const defaultAgentIdRef = useRef(defaultAgentId);
|
||||
@@ -304,18 +308,14 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
|
||||
], [externalAgents, t]);
|
||||
|
||||
const hasOpenAiProviderKey = providers.some(
|
||||
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
|
||||
);
|
||||
|
||||
const refreshCodexIntegration = useCallback(async () => {
|
||||
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
|
||||
setIsCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const integration = await bridge.aiCodexGetIntegration();
|
||||
const integration = await bridge.aiCodexGetIntegration(opts);
|
||||
setCodexIntegration(integration);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
@@ -425,6 +425,54 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
}
|
||||
}, [refreshCodexIntegration]);
|
||||
|
||||
const refreshUserSkillsStatus = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiUserSkillsGetStatus) {
|
||||
setUserSkillsStatus({
|
||||
ok: false,
|
||||
error: t('ai.userSkills.unavailable'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingUserSkills(true);
|
||||
try {
|
||||
const result = await bridge.aiUserSkillsGetStatus();
|
||||
setUserSkillsStatus(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setUserSkillsStatus({ ok: false, error: message });
|
||||
} finally {
|
||||
setIsLoadingUserSkills(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void refreshUserSkillsStatus().then(() => {
|
||||
if (cancelled) return;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshUserSkillsStatus]);
|
||||
|
||||
const handleOpenUserSkillsFolder = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiUserSkillsOpenFolder) return;
|
||||
|
||||
setIsLoadingUserSkills(true);
|
||||
try {
|
||||
const result = await bridge.aiUserSkillsOpenFolder();
|
||||
setUserSkillsStatus(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setUserSkillsStatus({ ok: false, error: message });
|
||||
} finally {
|
||||
setIsLoadingUserSkills(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="ai"
|
||||
@@ -524,9 +572,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
integration={codexIntegration}
|
||||
loginSession={codexLoginSession}
|
||||
isLoading={isCodexLoading}
|
||||
hasOpenAiProviderKey={hasOpenAiProviderKey}
|
||||
error={codexError}
|
||||
onRefresh={() => void refreshCodexIntegration()}
|
||||
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
|
||||
onConnect={() => void handleStartCodexLogin()}
|
||||
onCancel={() => void handleCancelCodexLogin()}
|
||||
onOpenUrl={handleOpenCodexLoginUrl}
|
||||
@@ -592,7 +639,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot size={18} className="text-muted-foreground" />
|
||||
<Link size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.toolAccess.title')}</h3>
|
||||
</div>
|
||||
|
||||
@@ -614,6 +661,106 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.userSkills.title')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshUserSkillsStatus()}
|
||||
disabled={isLoadingUserSkills}
|
||||
>
|
||||
<RefreshCcw size={14} className="mr-2" />
|
||||
{t('ai.userSkills.reload')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenUserSkillsFolder()}
|
||||
disabled={isLoadingUserSkills}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t('ai.userSkills.openFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/30 p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('ai.userSkills.description')}
|
||||
</p>
|
||||
{userSkillsStatus?.directoryPath ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.userSkills.location')}:{" "}
|
||||
<span className="font-mono">{userSkillsStatus.directoryPath}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLoadingUserSkills
|
||||
? t('ai.userSkills.loading')
|
||||
: userSkillsStatus?.ok
|
||||
? t('ai.userSkills.summary', {
|
||||
ready: String(userSkillsStatus.readyCount ?? 0),
|
||||
warnings: String(userSkillsStatus.warningCount ?? 0),
|
||||
})
|
||||
: userSkillsStatus?.error || t('ai.userSkills.unavailable')}
|
||||
</div>
|
||||
|
||||
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{userSkillsStatus.skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="rounded-md border border-border/60 bg-background/70 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{skill.description}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono break-all">
|
||||
{skill.directoryName}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
skill.status === "ready"
|
||||
? "rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-medium text-emerald-600"
|
||||
: "rounded-full bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600"
|
||||
}
|
||||
>
|
||||
{skill.status === "ready"
|
||||
? t('ai.userSkills.status.ready')
|
||||
: t('ai.userSkills.status.warning')}
|
||||
</span>
|
||||
</div>
|
||||
{skill.warnings.length > 0 ? (
|
||||
<div className="mt-3 space-y-1 text-sm text-amber-700">
|
||||
{skill.warnings.map((warning, index) => (
|
||||
<div key={`${skill.id}-${index}`} className="flex items-start gap-2">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : userSkillsStatus?.ok ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('ai.userSkills.empty')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* -- Web Search Section -- */}
|
||||
<WebSearchSettings
|
||||
webSearchConfig={webSearchConfig}
|
||||
|
||||
@@ -7,8 +7,6 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { STORAGE_KEY_SHOW_RECENT_HOSTS } from "../../../infrastructure/config/storageKeys";
|
||||
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
|
||||
|
||||
export default function SettingsAppearanceTab(props: {
|
||||
theme: "dark" | "light" | "system";
|
||||
@@ -27,6 +25,12 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage: (language: string) => void;
|
||||
customCSS: string;
|
||||
setCustomCSS: (css: string) => void;
|
||||
showRecentHosts: boolean;
|
||||
setShowRecentHosts: (enabled: boolean) => void;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
|
||||
showSftpTab: boolean;
|
||||
setShowSftpTab: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
@@ -47,13 +51,14 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
showRecentHosts,
|
||||
setShowRecentHosts,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
} = props;
|
||||
|
||||
const [showRecentHosts, setShowRecentHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
true,
|
||||
);
|
||||
|
||||
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
|
||||
|
||||
const hexToHsl = useCallback((hex: string) => {
|
||||
@@ -269,6 +274,21 @@ export default function SettingsAppearanceTab(props: {
|
||||
>
|
||||
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.vault.showOnlyUngroupedHostsInRoot')}
|
||||
description={t('settings.vault.showOnlyUngroupedHostsInRootDesc')}
|
||||
>
|
||||
<Toggle
|
||||
checked={showOnlyUngroupedHostsInRoot}
|
||||
onChange={setShowOnlyUngroupedHostsInRoot}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.vault.showSftpTab')}
|
||||
description={t('settings.vault.showSftpTabDesc')}
|
||||
>
|
||||
<Toggle checked={showSftpTab} onChange={setShowSftpTab} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.customCss")} />
|
||||
|
||||
@@ -2,7 +2,9 @@ import React, { useCallback } from "react";
|
||||
import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
|
||||
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
|
||||
import type { SyncableVaultData } from "../../../application/syncPayload";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
|
||||
@@ -25,6 +27,7 @@ export default function SettingsSyncTab(props: {
|
||||
clearVaultData,
|
||||
onSettingsApplied,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
// If hook state is empty but localStorage has data, the async store
|
||||
@@ -54,14 +57,19 @@ export default function SettingsSyncTab(props: {
|
||||
}, [vault, portForwardingRules]);
|
||||
|
||||
const onApplyPayload = useCallback(
|
||||
(payload: SyncPayload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
});
|
||||
},
|
||||
[importDataFromString, importPortForwardingRules, onSettingsApplied],
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: onBuildPayload,
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
}),
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
|
||||
}),
|
||||
[importDataFromString, importPortForwardingRules, onBuildPayload, onSettingsApplied, t],
|
||||
);
|
||||
|
||||
const clearAllLocalData = useCallback(() => {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -15,7 +15,6 @@ export const CodexConnectionCard: React.FC<{
|
||||
integration: CodexIntegrationStatus | null;
|
||||
loginSession: CodexLoginSession | null;
|
||||
isLoading: boolean;
|
||||
hasOpenAiProviderKey: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onConnect: () => void;
|
||||
@@ -31,7 +30,6 @@ export const CodexConnectionCard: React.FC<{
|
||||
integration,
|
||||
loginSession,
|
||||
isLoading,
|
||||
hasOpenAiProviderKey,
|
||||
error,
|
||||
onRefresh,
|
||||
onConnect,
|
||||
@@ -42,6 +40,14 @@ export const CodexConnectionCard: React.FC<{
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const customConfigIncomplete = Boolean(
|
||||
integration?.state === "connected_custom_config"
|
||||
&& integration.customConfig
|
||||
&& integration.customConfig.envKey
|
||||
&& !integration.customConfig.envKeyPresent
|
||||
&& !integration.customConfig.hasHardcodedApiKey,
|
||||
);
|
||||
|
||||
const status = isResolvingPath
|
||||
? t('ai.codex.detecting')
|
||||
: !found
|
||||
@@ -52,9 +58,13 @@ export const CodexConnectionCard: React.FC<{
|
||||
? t('ai.codex.connectedChatGPT')
|
||||
: integration?.state === "connected_api_key"
|
||||
? t('ai.codex.connectedApiKey')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
: integration?.state === "connected_custom_config"
|
||||
? customConfigIncomplete
|
||||
? t('ai.codex.customConfigIncomplete')
|
||||
: t('ai.codex.connectedCustomConfig')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
@@ -62,9 +72,11 @@ export const CodexConnectionCard: React.FC<{
|
||||
? "text-amber-500"
|
||||
: loginSession?.state === "running"
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
: customConfigIncomplete
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const outputText = loginSession?.error
|
||||
? loginSession.error
|
||||
@@ -139,6 +151,9 @@ export const CodexConnectionCard: React.FC<{
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : integration?.state === "connected_custom_config" ? (
|
||||
// Nothing to log out of; config.toml is user-owned state.
|
||||
null
|
||||
) : integration?.isConnected ? (
|
||||
<Button variant="outline" size="sm" onClick={onLogout}>
|
||||
<LogOut size={14} className="mr-1.5" />
|
||||
@@ -157,10 +172,23 @@ export const CodexConnectionCard: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasOpenAiProviderKey && (
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.apiKeyHint')}
|
||||
</p>
|
||||
{integration?.state === "connected_custom_config" && integration.customConfig && (
|
||||
<>
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.customConfigHint').replace(
|
||||
'{provider}',
|
||||
integration.customConfig.displayName || integration.customConfig.providerName,
|
||||
)}
|
||||
</p>
|
||||
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codex.customConfigMissingEnvKey').replace(
|
||||
'{envKey}',
|
||||
integration.customConfig.envKey,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,14 +10,27 @@ import type {
|
||||
export type CodexIntegrationState =
|
||||
| "connected_chatgpt"
|
||||
| "connected_api_key"
|
||||
| "connected_custom_config"
|
||||
| "not_logged_in"
|
||||
| "unknown";
|
||||
|
||||
export interface CodexCustomProviderConfig {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
envKeyPresent: boolean;
|
||||
hasHardcodedApiKey: boolean;
|
||||
model: string | null;
|
||||
authHash: string | null;
|
||||
}
|
||||
|
||||
export interface CodexIntegrationStatus {
|
||||
state: CodexIntegrationState;
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
customConfig?: CodexCustomProviderConfig | null;
|
||||
}
|
||||
|
||||
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
|
||||
@@ -37,6 +50,28 @@ export interface AgentPathInfo {
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface UserSkillStatusItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
directoryName: string;
|
||||
directoryPath: string;
|
||||
skillPath: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "ready" | "warning";
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface UserSkillsStatusResult {
|
||||
ok: boolean;
|
||||
directoryPath?: string;
|
||||
readyCount?: number;
|
||||
warningCount?: number;
|
||||
skills?: UserSkillStatusItem[];
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProviderFormState {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
@@ -57,12 +92,14 @@ export interface FetchBridge {
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexLogout?: () => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
|
||||
aiResolveCli?: (params: { command: string; customPath?: string }) => Promise<AgentPathInfo>;
|
||||
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
|
||||
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
|
||||
openExternal?: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -318,6 +318,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
@@ -325,7 +327,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
onDragOver={(e) => handleTabDragOver(e, tab.id)}
|
||||
onDrop={(e) => handleTabDrop(e, tab.id)}
|
||||
className={cn(
|
||||
"relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
|
||||
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
|
||||
"transition-[color,opacity,transform] duration-100 ease-out",
|
||||
isActive
|
||||
? "text-foreground border-b-2"
|
||||
|
||||
@@ -46,6 +46,8 @@ interface UseSftpViewPaneCallbacksParams {
|
||||
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
|
||||
mkdirLocal?: (path: string) => Promise<void>;
|
||||
deleteLocalFile?: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpViewPaneCallbacks = ({
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,7 @@ export interface TerminalContextMenuProps {
|
||||
isAlternateScreen?: boolean;
|
||||
onCopy?: () => void;
|
||||
onPaste?: () => void;
|
||||
onPasteSelection?: () => void;
|
||||
onSelectAll?: () => void;
|
||||
onClear?: () => void;
|
||||
onSplitHorizontal?: () => void;
|
||||
@@ -48,6 +49,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
isAlternateScreen = false,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onPasteSelection,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onSplitHorizontal,
|
||||
@@ -70,6 +72,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
|
||||
const copyShortcut = getShortcut('copy');
|
||||
const pasteShortcut = getShortcut('paste');
|
||||
const pasteSelectionShortcut = getShortcut('paste-selection');
|
||||
const selectAllShortcut = getShortcut('select-all');
|
||||
const splitHShortcut = getShortcut('split-horizontal');
|
||||
const splitVShortcut = getShortcut('split-vertical');
|
||||
@@ -123,6 +126,13 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
{t('terminal.menu.paste')}
|
||||
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{onPasteSelection && (
|
||||
<ContextMenuItem onClick={onPasteSelection} disabled={!hasSelection}>
|
||||
<ClipboardPaste size={14} className="mr-2" />
|
||||
{t('terminal.menu.pasteSelection')}
|
||||
<ContextMenuShortcut>{pasteSelectionShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={onSelectAll}>
|
||||
<TerminalIcon size={14} className="mr-2" />
|
||||
{t('terminal.menu.selectAll')}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,24 @@ export const useTerminalContextActions = ({
|
||||
}
|
||||
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
|
||||
|
||||
const onPasteSelection = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const selection = term.getSelection();
|
||||
if (!selection || !sessionRef.current) return;
|
||||
let data = normalizeLineEndings(selection);
|
||||
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
if (scrollOnPasteRef?.current) {
|
||||
term.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
@@ -76,5 +94,5 @@ export const useTerminalContextActions = ({
|
||||
onHasSelectionChange?.(true);
|
||||
}, [onHasSelectionChange, termRef]);
|
||||
|
||||
return { onCopy, onPaste, onSelectAll, onClear, onSelectWord };
|
||||
return { onCopy, onPaste, onPasteSelection, onSelectAll, onClear, onSelectWord };
|
||||
};
|
||||
|
||||
@@ -328,12 +328,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startSSH = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clear terminal before connect", err);
|
||||
}
|
||||
|
||||
if (!ctx.terminalBackend.backendAvailable()) {
|
||||
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
|
||||
term.writeln(
|
||||
@@ -717,12 +711,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startTelnet = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clear terminal before connect", err);
|
||||
}
|
||||
|
||||
if (!ctx.terminalBackend.telnetAvailable()) {
|
||||
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
|
||||
term.writeln("\r\n[Telnet bridge unavailable. Please run the desktop build.]");
|
||||
@@ -756,12 +744,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startMosh = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clear terminal before connect", err);
|
||||
}
|
||||
|
||||
if (!ctx.terminalBackend.moshAvailable()) {
|
||||
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
|
||||
term.writeln("\r\n[Mosh bridge unavailable. Please run the desktop build.]");
|
||||
@@ -812,12 +794,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const startLocal = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clear terminal before connect", err);
|
||||
}
|
||||
|
||||
if (!ctx.terminalBackend.localAvailable()) {
|
||||
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
|
||||
term.writeln(
|
||||
|
||||
@@ -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 => {
|
||||
@@ -182,7 +186,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const cursorStyle = settings?.cursorShape ?? "block";
|
||||
const cursorBlink = settings?.cursorBlink ?? true;
|
||||
const scrollback = settings?.scrollback ?? 10000;
|
||||
// xterm.js treats scrollback=0 as "no scrollback buffer", which breaks mouse
|
||||
// wheel scrolling (events become arrow-key sequences). The UI uses 0 to mean
|
||||
// "no limit", so map it to a large value instead.
|
||||
const rawScrollback = settings?.scrollback ?? 10000;
|
||||
const scrollback = rawScrollback === 0 ? 999999 : rawScrollback;
|
||||
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
|
||||
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
|
||||
const fontWeightBold = settings?.fontWeightBold ?? 700;
|
||||
@@ -415,18 +423,44 @@ 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);
|
||||
if (!consumed) return false; // Event was consumed by autocomplete
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "f" && e.type === "keydown") {
|
||||
e.preventDefault();
|
||||
ctx.setIsSearchOpen(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentScheme = ctx.hotkeySchemeRef.current;
|
||||
// Use shared utility for platform detection when hotkey scheme is disabled
|
||||
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
|
||||
@@ -499,6 +533,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "pasteSelection": {
|
||||
const selection = term.getSelection();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (selection && id) {
|
||||
let data = normalizeLineEndings(selection);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "selectAll": {
|
||||
term.selectAll();
|
||||
break;
|
||||
@@ -655,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);
|
||||
|
||||
@@ -394,9 +394,10 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
// Terminal Operations
|
||||
{ id: 'copy', action: 'copy', label: 'Copy from Terminal', mac: '⌘ + C', pc: 'Ctrl + Shift + C', category: 'terminal' },
|
||||
{ id: 'paste', action: 'paste', label: 'Paste to Terminal', mac: '⌘ + V', pc: 'Ctrl + Shift + V', category: 'terminal' },
|
||||
{ id: 'paste-selection', action: 'pasteSelection', label: 'Paste Selection to Terminal', mac: '⌘ + Shift + X', pc: 'Ctrl + Shift + X', category: 'terminal' },
|
||||
{ id: 'select-all', action: 'selectAll', label: 'Select All in Terminal', mac: '⌘ + A', pc: 'Ctrl + Shift + A', category: 'terminal' },
|
||||
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + ⌃ + K', pc: 'Ctrl + Shift + K', category: 'terminal' },
|
||||
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + Shift + F', category: 'terminal' },
|
||||
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + F', category: 'terminal' },
|
||||
|
||||
// Navigation / Split View
|
||||
{ id: 'move-focus', action: 'moveFocus', label: 'Move focus between Split View panes', mac: '⌘ + ⌥ + arrows', pc: 'Ctrl + Alt + arrows', category: 'navigation' },
|
||||
@@ -496,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
|
||||
|
||||
@@ -624,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
|
||||
|
||||
/**
|
||||
@@ -206,6 +209,10 @@ export interface SyncPayload {
|
||||
immersiveMode?: boolean;
|
||||
// Vault: show recently connected hosts
|
||||
showRecentHosts?: boolean;
|
||||
// Vault: root list shows only ungrouped hosts
|
||||
showOnlyUngroupedHostsInRoot?: boolean;
|
||||
// Top tabs: show standalone SFTP view tab
|
||||
showSftpTab?: boolean;
|
||||
};
|
||||
|
||||
// Sync metadata
|
||||
@@ -280,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,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/**/*',
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { existsSync } = require("node:fs");
|
||||
const { existsSync, readFileSync } = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs");
|
||||
@@ -124,6 +125,212 @@ function getActiveCodexLoginSession() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Codex config.toml probing ──
|
||||
//
|
||||
// Users who hand-configure `~/.codex/config.toml` with a custom
|
||||
// `model_provider` + matching `[model_providers.<name>]` entry are fully
|
||||
// functional from the Codex CLI, but `codex login status` doesn't see them
|
||||
// because it only reports on `~/.codex/auth.json` (populated by `codex login`).
|
||||
// We read and minimally parse the config file so we can surface this as a
|
||||
// valid "ready" state and skip the ChatGPT login prompt in the UI.
|
||||
|
||||
/** Find `#` outside quoted regions. Tracks escape state via a flag rather
|
||||
* than peeking at the previous character, so even runs of backslashes like
|
||||
* `"C:\\path\\"` close the string correctly. Literal (single-quoted) TOML
|
||||
* strings don't recognize `\` as an escape, so only honor escapes inside
|
||||
* basic (double-quoted) strings. */
|
||||
function findUnquotedHash(value) {
|
||||
let inStr = false;
|
||||
let quote = "";
|
||||
let escaped = false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value[i];
|
||||
if (inStr) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (quote === '"' && ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
inStr = false;
|
||||
quote = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inStr = true;
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "#") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the narrow subset of TOML we need from Codex's config.toml:
|
||||
* - top-level string keys (e.g. `model_provider = "my_provider"`)
|
||||
* - `[model_providers.<name>]` tables with string-valued keys
|
||||
* Unsupported TOML features (arrays, inline tables, multi-line strings, etc.)
|
||||
* are ignored — Codex's config.toml doesn't use them for provider definitions.
|
||||
*/
|
||||
function parseCodexConfigToml(text) {
|
||||
const result = { model_providers: {} };
|
||||
let currentProvider = null;
|
||||
let atTopLevel = true;
|
||||
|
||||
// Strip UTF-8 BOM so the first key still matches the regex on Windows-edited files.
|
||||
const normalized = String(text || "").replace(/^\uFEFF/, "");
|
||||
const lines = normalized.split(/\r?\n/);
|
||||
for (const rawLine of lines) {
|
||||
let line = rawLine;
|
||||
const hashIdx = findUnquotedHash(line);
|
||||
if (hashIdx >= 0) line = line.slice(0, hashIdx);
|
||||
line = line.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
const section = sectionMatch[1].trim();
|
||||
if (section.startsWith("model_providers.")) {
|
||||
currentProvider = section.slice("model_providers.".length);
|
||||
if (!result.model_providers[currentProvider]) {
|
||||
result.model_providers[currentProvider] = {};
|
||||
}
|
||||
atTopLevel = false;
|
||||
} else {
|
||||
currentProvider = null;
|
||||
atTopLevel = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const kvMatch = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*(.+)$/);
|
||||
if (!kvMatch) continue;
|
||||
const key = kvMatch[1];
|
||||
let raw = kvMatch[2].trim();
|
||||
let value;
|
||||
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
||||
value = raw.slice(1, -1);
|
||||
} else {
|
||||
value = raw;
|
||||
}
|
||||
|
||||
if (atTopLevel) {
|
||||
result[key] = value;
|
||||
} else if (currentProvider) {
|
||||
result.model_providers[currentProvider][key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect `~/.codex/config.toml` to determine whether the user has
|
||||
* configured a custom `model_provider` that isn't the built-in OpenAI/ChatGPT
|
||||
* path.
|
||||
*
|
||||
* Returns null when:
|
||||
* - the config file doesn't exist or can't be read
|
||||
* - no `model_provider` is set, or it points to the default `openai` preset
|
||||
* - the referenced provider entry is missing (config is malformed)
|
||||
*
|
||||
* Returns a summary object otherwise — even if the env_key isn't currently
|
||||
* exported in the shell environment. That case is surfaced via
|
||||
* `envKeyPresent: false` so the UI can warn the user; we don't want the
|
||||
* absence of an env var to silently fall back to the ChatGPT login flow,
|
||||
* because the config.toml is a strong signal the user doesn't want that.
|
||||
*/
|
||||
function readCodexCustomProviderConfig(shellEnv) {
|
||||
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
|
||||
if (!home) return null;
|
||||
const configPath = path.join(home, ".codex", "config.toml");
|
||||
if (!existsSync(configPath)) return null;
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(configPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseCodexConfigToml(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeName = typeof parsed.model_provider === "string"
|
||||
? parsed.model_provider.trim()
|
||||
: "";
|
||||
if (!activeName) return null;
|
||||
// The built-in "openai" provider still goes through ChatGPT/API-key auth
|
||||
// managed by `codex login`, so treating it as "custom" would be wrong.
|
||||
if (activeName === "openai") return null;
|
||||
|
||||
const providerEntry = parsed.model_providers?.[activeName];
|
||||
if (!providerEntry) return null;
|
||||
|
||||
const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : "";
|
||||
const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : "";
|
||||
const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : "";
|
||||
const activeModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
|
||||
|
||||
// Hash the actual auth material (either the hardcoded api_key or the
|
||||
// resolved env_key value) so the ACP provider fingerprint changes when
|
||||
// the user rotates their key — without ever returning the raw value
|
||||
// across the IPC boundary.
|
||||
const authMaterial = hardcodedApiKey || envKeyValue;
|
||||
const authHash = authMaterial
|
||||
? createHash("sha256").update(authMaterial).digest("hex")
|
||||
: null;
|
||||
|
||||
return {
|
||||
providerName: activeName,
|
||||
displayName: providerEntry.name || activeName,
|
||||
baseUrl: providerEntry.base_url || null,
|
||||
envKey: envKeyName || null,
|
||||
envKeyPresent: Boolean(envKeyValue),
|
||||
hasHardcodedApiKey: Boolean(hardcodedApiKey),
|
||||
model: activeModel || null,
|
||||
authHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user-facing error message when a Codex config.toml custom
|
||||
* provider references an env_key that isn't exported in the shell env and
|
||||
* doesn't have a hardcoded api_key either — otherwise returns null. Shared
|
||||
* by every spawn path (stream handler, list-models handler) so users get
|
||||
* the same actionable message regardless of which one hits first.
|
||||
*/
|
||||
function getCodexCustomConfigPreflightError(customConfig) {
|
||||
if (!customConfig) return null;
|
||||
if (!customConfig.envKey) return null;
|
||||
if (customConfig.envKeyPresent || customConfig.hasHardcodedApiKey) return null;
|
||||
return `Codex is configured to use the "${customConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${customConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and click "Refresh Status" in Settings.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the ACP auth override object for Codex spawn sites.
|
||||
* - netcatty-managed API key present → "codex-api-key"
|
||||
* - user's own ~/.codex/config.toml custom provider detected → no override
|
||||
* (so codex-acp resolves auth from the shell env / config itself)
|
||||
* - otherwise → "chatgpt" (triggers the browser OAuth login flow)
|
||||
*
|
||||
* Returned as an object designed to be spread into createACPProvider options.
|
||||
*/
|
||||
function getCodexAuthOverride(apiKey, shellEnv) {
|
||||
if (apiKey) return { authMethodId: "codex-api-key" };
|
||||
if (readCodexCustomProviderConfig(shellEnv)) return {};
|
||||
return { authMethodId: "chatgpt" };
|
||||
}
|
||||
|
||||
// ── Integration state ──
|
||||
|
||||
function normalizeCodexIntegrationState(rawOutput) {
|
||||
@@ -199,6 +406,9 @@ module.exports = {
|
||||
toCodexLoginSessionResponse,
|
||||
getActiveCodexLoginSession,
|
||||
normalizeCodexIntegrationState,
|
||||
readCodexCustomProviderConfig,
|
||||
getCodexAuthOverride,
|
||||
getCodexCustomConfigPreflightError,
|
||||
extractCodexError,
|
||||
isCodexAuthError,
|
||||
getCodexAuthFingerprint,
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
|
||||
@@ -211,6 +211,15 @@ async function getShellEnv() {
|
||||
return _cachedShellEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the shell-env cache so the next getShellEnv() call re-spawns the
|
||||
* login shell. Useful when the user has just exported a new variable in
|
||||
* their rc file and clicks "Refresh Status" without restarting the app.
|
||||
*/
|
||||
function invalidateShellEnvCache() {
|
||||
_cachedShellEnv = null;
|
||||
}
|
||||
|
||||
// ── Claude Code ACP binary resolution ──
|
||||
|
||||
/**
|
||||
@@ -316,5 +325,6 @@ module.exports = {
|
||||
resolveClaudeAcpBinaryPath,
|
||||
toUnpackedAsarPath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
};
|
||||
|
||||
530
electron/bridges/ai/userSkills.cjs
Normal file
530
electron/bridges/ai/userSkills.cjs
Normal file
@@ -0,0 +1,530 @@
|
||||
const fsPromises = require("node:fs/promises");
|
||||
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 = 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;
|
||||
const MAX_TOTAL_INJECTED_SKILL_CHARS = 12000;
|
||||
const USER_SKILLS_README_CONTENT = [
|
||||
"Netcatty user skills",
|
||||
"",
|
||||
"Add one folder per skill inside this directory.",
|
||||
"Each skill folder must contain a SKILL.md file.",
|
||||
"",
|
||||
"Example layout:",
|
||||
" Skills/",
|
||||
" My Skill/",
|
||||
" SKILL.md",
|
||||
"",
|
||||
"Minimal SKILL.md:",
|
||||
" ---",
|
||||
" name: My Skill",
|
||||
" description: Short summary of what this skill helps with.",
|
||||
" ---",
|
||||
"",
|
||||
" Write the skill instructions here.",
|
||||
"",
|
||||
"After adding or editing a skill, reopen the AI settings page or start a new chat to refresh the list.",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
"the", "and", "for", "with", "that", "this", "from", "into", "when", "then",
|
||||
"only", "your", "will", "should", "have", "has", "had", "using", "use",
|
||||
"agent", "skill", "skills", "task", "file", "files", "user", "into", "about",
|
||||
]);
|
||||
|
||||
function stripQuotes(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function slugifySkill(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function tokenize(value) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/i)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 3 && !STOPWORDS.has(token));
|
||||
}
|
||||
|
||||
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");
|
||||
return code
|
||||
? `Failed to read SKILL.md (${code}: ${message}).`
|
||||
: `Failed to read SKILL.md (${message}).`;
|
||||
}
|
||||
|
||||
function containsPlaintextPhrase(prompt, phrase) {
|
||||
const trimmedPhrase = String(phrase || "").trim();
|
||||
if (!trimmedPhrase) return false;
|
||||
const pattern = new RegExp(`(^|\\s)${escapeRegExp(trimmedPhrase)}(?=$|\\s|[.,!?;:])`, "i");
|
||||
return pattern.test(String(prompt || ""));
|
||||
}
|
||||
|
||||
function parseFrontmatter(content) {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
|
||||
if (!match) {
|
||||
return { attributes: {}, body: content, hasFrontmatter: false };
|
||||
}
|
||||
|
||||
const attributes = {};
|
||||
for (const rawLine of match[1].split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex <= 0) continue;
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const value = stripQuotes(line.slice(colonIndex + 1).trim());
|
||||
if (key) attributes[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
attributes,
|
||||
body: content.slice(match[0].length),
|
||||
hasFrontmatter: true,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeSkillSlugs(skillsOrSlugs, maxItems = 4) {
|
||||
const values = (Array.isArray(skillsOrSlugs) ? skillsOrSlugs : [])
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") return entry;
|
||||
const slug = typeof entry?.slug === "string" ? entry.slug : "";
|
||||
return slug;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.map((slug) => `/${slug}`);
|
||||
if (values.length <= maxItems) {
|
||||
return values.join(", ");
|
||||
}
|
||||
return `${values.slice(0, maxItems).join(", ")}, and ${values.length - maxItems} more`;
|
||||
}
|
||||
|
||||
function getUserSkillsDir(electronApp) {
|
||||
const userDataDir = electronApp?.getPath?.("userData");
|
||||
if (!userDataDir) {
|
||||
throw new Error("Electron app userData path is unavailable.");
|
||||
}
|
||||
return path.join(userDataDir, USER_SKILLS_DIR_NAME);
|
||||
}
|
||||
|
||||
async function ensureUserSkillsDir(electronApp) {
|
||||
const skillsDir = getUserSkillsDir(electronApp);
|
||||
await fsPromises.mkdir(skillsDir, { recursive: true });
|
||||
return skillsDir;
|
||||
}
|
||||
|
||||
async function ensureUserSkillsReadme(electronApp) {
|
||||
const skillsDir = await ensureUserSkillsDir(electronApp);
|
||||
const dirEntries = await fsPromises.readdir(skillsDir);
|
||||
if (dirEntries.length === 0) {
|
||||
await fsPromises.writeFile(
|
||||
path.join(skillsDir, USER_SKILLS_README_NAME),
|
||||
USER_SKILLS_README_CONTENT,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
return skillsDir;
|
||||
}
|
||||
|
||||
async function scanUserSkills(electronApp) {
|
||||
const skillsDir = await ensureUserSkillsReadme(electronApp);
|
||||
const dirEntries = await fsPromises.readdir(skillsDir, { withFileTypes: true });
|
||||
const skills = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const entry of dirEntries) {
|
||||
// Only process actual directories, skipping symlinks for security
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
||||
|
||||
const dirName = entry.name;
|
||||
// Basic path traversal protection: skip any directory name containing path separators
|
||||
if (dirName.includes("/") || dirName.includes("\\") || dirName === ".." || dirName === ".") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
const skillPath = path.join(skillDir, "SKILL.md");
|
||||
const baseItem = {
|
||||
id: dirName,
|
||||
slug: slugifySkill(dirName),
|
||||
directoryName: dirName,
|
||||
directoryPath: skillDir,
|
||||
skillPath,
|
||||
name: dirName,
|
||||
description: "",
|
||||
status: "warning",
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await fsPromises.access(skillPath);
|
||||
} catch {
|
||||
baseItem.warnings.push("Missing SKILL.md");
|
||||
warnings.push(`${dirName}: Missing SKILL.md`);
|
||||
skills.push(baseItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fsPromises.lstat(skillPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
baseItem.warnings.push("SKILL.md must not be a symbolic link.");
|
||||
warnings.push(`${dirName}: SKILL.md must not be a symbolic link.`);
|
||||
skills.push(baseItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
baseItem.warnings.push("SKILL.md must be a regular file.");
|
||||
warnings.push(`${dirName}: SKILL.md must be a regular file.`);
|
||||
skills.push(baseItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.size > MAX_SKILL_BYTES) {
|
||||
baseItem.warnings.push(`SKILL.md is too large (${stat.size} bytes > ${MAX_SKILL_BYTES} bytes).`);
|
||||
warnings.push(`${dirName}: SKILL.md is too large.`);
|
||||
skills.push(baseItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await fsPromises.readFile(skillPath, "utf8");
|
||||
const { attributes, body, hasFrontmatter } = parseFrontmatter(content);
|
||||
const name = stripQuotes(attributes.name || "").trim();
|
||||
const description = stripQuotes(attributes.description || "").trim();
|
||||
const usableSlug = slugifySkill(name || dirName);
|
||||
|
||||
if (!hasFrontmatter) {
|
||||
baseItem.warnings.push("Missing YAML frontmatter.");
|
||||
}
|
||||
if (!name) {
|
||||
baseItem.warnings.push("Missing frontmatter field: name.");
|
||||
}
|
||||
if (!description) {
|
||||
baseItem.warnings.push("Missing frontmatter field: description.");
|
||||
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||||
baseItem.warnings.push(`Description is too long (${description.length} chars > ${MAX_DESCRIPTION_LENGTH}).`);
|
||||
}
|
||||
if (!usableSlug) {
|
||||
baseItem.warnings.push("Skill name must include ASCII letters or digits to generate a usable slug.");
|
||||
}
|
||||
|
||||
if (baseItem.warnings.length > 0) {
|
||||
warnings.push(...baseItem.warnings.map((warning) => `${dirName}: ${warning}`));
|
||||
skills.push({
|
||||
...baseItem,
|
||||
slug: usableSlug,
|
||||
name: name || dirName,
|
||||
description,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
skills.push({
|
||||
...baseItem,
|
||||
slug: usableSlug,
|
||||
name,
|
||||
description,
|
||||
status: "ready",
|
||||
warnings: [],
|
||||
body,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const warning = formatSkillReadWarning(error);
|
||||
baseItem.warnings.push(warning);
|
||||
warnings.push(`${dirName}: ${warning}`);
|
||||
skills.push(baseItem);
|
||||
}
|
||||
}
|
||||
|
||||
const readySkillsBySlug = new Map();
|
||||
for (const skill of skills) {
|
||||
if (skill.status !== "ready" || !skill.slug) continue;
|
||||
const matches = readySkillsBySlug.get(skill.slug);
|
||||
if (matches) {
|
||||
matches.push(skill);
|
||||
} else {
|
||||
readySkillsBySlug.set(skill.slug, [skill]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [slug, duplicateSkills] of readySkillsBySlug.entries()) {
|
||||
if (duplicateSkills.length < 2) continue;
|
||||
const duplicateWarning = `Duplicate skill slug "${slug}". Rename the skill or change its frontmatter name.`;
|
||||
for (const skill of duplicateSkills) {
|
||||
skill.status = "warning";
|
||||
skill.warnings = [...skill.warnings, duplicateWarning];
|
||||
warnings.push(`${skill.directoryName}: ${duplicateWarning}`);
|
||||
}
|
||||
}
|
||||
|
||||
const readyCount = skills.filter((skill) => skill.status === "ready").length;
|
||||
const warningCount = skills.filter((skill) => skill.status === "warning").length;
|
||||
|
||||
return {
|
||||
directoryPath: skillsDir,
|
||||
readyCount,
|
||||
warningCount,
|
||||
skills: skills.map((skill) => ({
|
||||
id: skill.id,
|
||||
slug: skill.slug,
|
||||
directoryName: skill.directoryName,
|
||||
directoryPath: skill.directoryPath,
|
||||
skillPath: skill.skillPath,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
status: skill.status,
|
||||
warnings: skill.warnings,
|
||||
})),
|
||||
warnings,
|
||||
_readySkills: skills.filter((skill) => skill.status === "ready"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scores how well a skill matches a user prompt.
|
||||
*
|
||||
* Scored based on:
|
||||
* - 50 points: Plain-text name/directory mention (e.g. prompt contains "my skill")
|
||||
* - 1 point per keyword overlap (after tokenization/stopword filtering)
|
||||
*
|
||||
* @param {string} prompt - The user prompt
|
||||
* @param {object} skill - The skill object from scanUserSkills
|
||||
* @returns {number} The score (higher is better)
|
||||
*/
|
||||
function scoreSkillMatch(prompt, skill) {
|
||||
const name = String(skill.name || "").trim();
|
||||
const directoryName = String(skill.directoryName || "").trim();
|
||||
|
||||
// High weight for an exact plain-text mention of the skill name.
|
||||
if (
|
||||
(name && containsPlaintextPhrase(prompt, name)) ||
|
||||
(directoryName && containsPlaintextPhrase(prompt, directoryName))
|
||||
) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
// Fallback to token keyword overlap
|
||||
const promptTokens = new Set(tokenize(prompt));
|
||||
const skillTokens = tokenize(`${skill.name} ${skill.description}`);
|
||||
let overlap = 0;
|
||||
for (const token of skillTokens) {
|
||||
if (promptTokens.has(token)) overlap += 1;
|
||||
}
|
||||
return overlap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the contextual prompt part from matched user skills.
|
||||
*
|
||||
* @param {object} electronApp - The Electron app instance
|
||||
* @param {string} prompt - The user's input prompt
|
||||
* @param {string[]} selectedSkillSlugs - Explicitly requested skill slugs
|
||||
* @returns {Promise<{context: string, status: object}>} The built prompt part and scan status
|
||||
*/
|
||||
async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs = []) {
|
||||
const status = await scanUserSkills(electronApp);
|
||||
const readySkills = status._readySkills || [];
|
||||
const trimmedPrompt = String(prompt || "").trim();
|
||||
if (readySkills.length === 0) {
|
||||
return { context: "", status };
|
||||
}
|
||||
|
||||
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
|
||||
let remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
|
||||
const indexEntries = [];
|
||||
let indexChars = 0;
|
||||
|
||||
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();
|
||||
for (const rawSlug of Array.isArray(selectedSkillSlugs) ? selectedSkillSlugs : []) {
|
||||
const slug = slugifySkill(rawSlug);
|
||||
if (!slug || seenExplicitSlugs.has(slug)) continue;
|
||||
seenExplicitSlugs.add(slug);
|
||||
orderedExplicitSlugs.push(slug);
|
||||
}
|
||||
|
||||
const additionalExplicitCount = Math.max(orderedExplicitSlugs.length - MAX_EXPLICIT_SKILLS, 0);
|
||||
const cappedExplicitSlugs = orderedExplicitSlugs.slice(0, MAX_EXPLICIT_SKILLS);
|
||||
const explicitSlugSet = new Set(cappedExplicitSlugs);
|
||||
const readySkillsBySlug = new Map(readySkills.map((skill) => [skill.slug, skill]));
|
||||
const explicitSkills = [];
|
||||
const unavailableExplicitSlugs = [];
|
||||
for (const slug of cappedExplicitSlugs) {
|
||||
const skill = readySkillsBySlug.get(slug);
|
||||
if (skill) {
|
||||
explicitSkills.push(skill);
|
||||
} else {
|
||||
unavailableExplicitSlugs.push(slug);
|
||||
}
|
||||
}
|
||||
|
||||
const matchedSkills = readySkills
|
||||
.filter((skill) => !explicitSlugSet.has(skill.slug))
|
||||
.map((skill) => ({ skill, score: scoreSkillMatch(trimmedPrompt, skill) }))
|
||||
.filter((entry) => entry.score >= 2)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, MAX_MATCHED_SKILLS)
|
||||
.map((entry) => entry.skill);
|
||||
|
||||
const finalSkills = [...explicitSkills, ...matchedSkills];
|
||||
|
||||
const parts = [
|
||||
"User-managed skills are installed in Netcatty.",
|
||||
`Available user skills: ${indexLine}${remainingCount > 0 ? `; and ${remainingCount} more.` : "."}`,
|
||||
"Use a user-managed skill only when it clearly matches the current request.",
|
||||
];
|
||||
|
||||
if (additionalExplicitCount > 0) {
|
||||
parts.push(
|
||||
`The user selected ${additionalExplicitCount} additional Netcatty user skills that were omitted to stay within the prompt budget.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (unavailableExplicitSlugs.length > 0) {
|
||||
parts.push(
|
||||
`The user explicitly selected these Netcatty user skills for this request, but their content is currently unavailable: ${summarizeSkillSlugs(unavailableExplicitSlugs)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (finalSkills.length > 0) {
|
||||
const includedSkillSections = [];
|
||||
const omittedSkills = [];
|
||||
const truncatedSkills = [];
|
||||
let remainingSkillChars = MAX_TOTAL_INJECTED_SKILL_CHARS;
|
||||
let budgetStopIndex = finalSkills.length;
|
||||
|
||||
for (let index = 0; index < finalSkills.length; index += 1) {
|
||||
const skill = finalSkills[index];
|
||||
const heading = `### ${skill.name}\n`;
|
||||
const maxBodyChars = Math.min(
|
||||
MAX_MATCHED_SKILL_CHARS,
|
||||
Math.max(remainingSkillChars - heading.length, 0),
|
||||
);
|
||||
if (maxBodyChars <= 0) {
|
||||
omittedSkills.push(skill);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawBody = String(skill.body || "").trim();
|
||||
if (!rawBody) {
|
||||
omittedSkills.push(skill);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rawBody.length > maxBodyChars && includedSkillSections.length > 0) {
|
||||
omittedSkills.push(skill);
|
||||
budgetStopIndex = index;
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = rawBody.slice(0, maxBodyChars);
|
||||
if (!body) {
|
||||
omittedSkills.push(skill);
|
||||
continue;
|
||||
}
|
||||
|
||||
includedSkillSections.push(`${heading}${body}`);
|
||||
remainingSkillChars -= heading.length + body.length;
|
||||
|
||||
if (body.length < rawBody.length) {
|
||||
truncatedSkills.push(skill);
|
||||
budgetStopIndex = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
parts.push("Matched user-managed skills for this request:");
|
||||
|
||||
if (includedSkillSections.length > 0) {
|
||||
parts.push(...includedSkillSections);
|
||||
}
|
||||
|
||||
const omittedAfterIncluded = finalSkills.slice(budgetStopIndex);
|
||||
for (const skill of omittedAfterIncluded) {
|
||||
if (!omittedSkills.includes(skill) && !truncatedSkills.includes(skill)) {
|
||||
omittedSkills.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
if (truncatedSkills.length > 0) {
|
||||
parts.push(
|
||||
`Some matched user-managed skill content was truncated to stay within the prompt budget: ${summarizeSkillSlugs(truncatedSkills)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (omittedSkills.length > 0) {
|
||||
parts.push(
|
||||
`Additional matched user-managed skills were omitted to stay within the prompt budget: ${summarizeSkillSlugs(omittedSkills)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
context: parts.join("\n\n"),
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function toPublicUserSkillsStatus(status) {
|
||||
if (!status || typeof status !== "object") {
|
||||
return status;
|
||||
}
|
||||
const publicStatus = { ...status };
|
||||
delete publicStatus._readySkills;
|
||||
return publicStatus;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
USER_SKILLS_DIR_NAME,
|
||||
getUserSkillsDir,
|
||||
ensureUserSkillsDir,
|
||||
ensureUserSkillsReadme,
|
||||
scanUserSkills,
|
||||
buildUserSkillsContext,
|
||||
toPublicUserSkillsStatus,
|
||||
};
|
||||
399
electron/bridges/ai/userSkills.test.cjs
Normal file
399
electron/bridges/ai/userSkills.test.cjs
Normal file
@@ -0,0 +1,399 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { buildUserSkillsContext, scanUserSkills } = require("./userSkills.cjs");
|
||||
|
||||
async function withUserSkills(skillDefinitions, run) {
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-user-skills-"));
|
||||
const userDataDir = path.join(rootDir, "userData");
|
||||
const skillsDir = path.join(userDataDir, "Skills");
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
|
||||
for (const skill of skillDefinitions) {
|
||||
const skillDir = path.join(skillsDir, skill.directoryName);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
const content = [
|
||||
"---",
|
||||
`name: ${skill.name}`,
|
||||
`description: ${skill.description}`,
|
||||
"---",
|
||||
"",
|
||||
skill.body,
|
||||
"",
|
||||
].join("\n");
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), content, "utf8");
|
||||
}
|
||||
|
||||
const electronApp = {
|
||||
getPath(key) {
|
||||
return key === "userData" ? userDataDir : "";
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await run(electronApp);
|
||||
} finally {
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("does not auto-match a user skill from an absolute path segment", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "Tmp Helper",
|
||||
name: "tmp",
|
||||
description: "Helper for scratch space workflows.",
|
||||
body: "Body for tmp",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"please inspect /tmp/netcatty.log",
|
||||
[],
|
||||
);
|
||||
|
||||
assert.equal(result.context.includes("Matched user-managed skills for this request:"), false);
|
||||
assert.equal(result.context.includes("Body for tmp"), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps every explicitly selected skill in the built context", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "Alpha One",
|
||||
name: "Alpha One",
|
||||
description: "Alpha helper.",
|
||||
body: "Body for Alpha One",
|
||||
},
|
||||
{
|
||||
directoryName: "Beta Two",
|
||||
name: "Beta Two",
|
||||
description: "Beta helper.",
|
||||
body: "Body for Beta Two",
|
||||
},
|
||||
{
|
||||
directoryName: "Gamma Three",
|
||||
name: "Gamma Three",
|
||||
description: "Gamma helper.",
|
||||
body: "Body for Gamma Three",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"plain prompt",
|
||||
["alpha-one", "beta-two", "gamma-three"],
|
||||
);
|
||||
|
||||
assert.equal(result.context.includes("Body for Alpha One"), true);
|
||||
assert.equal(result.context.includes("Body for Beta Two"), true);
|
||||
assert.equal(result.context.includes("Body for Gamma Three"), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
[
|
||||
{
|
||||
directoryName: "Beta",
|
||||
name: "Beta",
|
||||
description: "Beta helper.",
|
||||
body: "Body for Beta",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"plain prompt",
|
||||
["missing-skill"],
|
||||
);
|
||||
|
||||
assert.equal(result.context.includes("Available user skills: Beta: Beta helper."), true);
|
||||
assert.equal(result.context.includes("/missing-skill"), true);
|
||||
assert.match(result.context, /explicitly selected/i);
|
||||
assert.match(result.context, /unavailable/i);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("initializing an empty skills directory creates only an instructions file", async () => {
|
||||
await withUserSkills([], async (electronApp) => {
|
||||
const status = await scanUserSkills(electronApp);
|
||||
const entries = await fs.readdir(status.directoryPath);
|
||||
|
||||
assert.deepEqual(status.skills, []);
|
||||
assert.equal(status.readyCount, 0);
|
||||
assert.equal(status.warningCount, 0);
|
||||
assert.deepEqual(entries.sort(), ["README.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("unreadable SKILL.md becomes a warning instead of aborting the entire scan", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "Working Skill",
|
||||
name: "Working Skill",
|
||||
description: "A valid skill.",
|
||||
body: "Working body",
|
||||
},
|
||||
{
|
||||
directoryName: "Broken Skill",
|
||||
name: "Broken Skill",
|
||||
description: "This file will be unreadable.",
|
||||
body: "Broken body",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const unreadablePath = path.join(
|
||||
electronApp.getPath("userData"),
|
||||
"Skills",
|
||||
"Broken Skill",
|
||||
"SKILL.md",
|
||||
);
|
||||
|
||||
await fs.chmod(unreadablePath, 0o000);
|
||||
|
||||
try {
|
||||
const status = await scanUserSkills(electronApp);
|
||||
const workingSkill = status.skills.find((skill) => skill.name === "Working Skill");
|
||||
const brokenSkill = status.skills.find((skill) => skill.directoryName === "Broken Skill");
|
||||
|
||||
assert.equal(status.readyCount, 1);
|
||||
assert.equal(status.warningCount, 1);
|
||||
assert.equal(workingSkill?.status, "ready");
|
||||
assert.equal(brokenSkill?.status, "warning");
|
||||
assert.match(brokenSkill?.warnings?.[0] || "", /Failed to read SKILL\.md/i);
|
||||
} finally {
|
||||
await fs.chmod(unreadablePath, 0o644);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("symlinked SKILL.md is downgraded to a warning and never injected", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "Working Skill",
|
||||
name: "Working Skill",
|
||||
description: "A valid skill.",
|
||||
body: "Working body",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const skillsDir = path.join(electronApp.getPath("userData"), "Skills");
|
||||
const linkedDir = path.join(skillsDir, "Linked Skill");
|
||||
const externalTarget = path.join(skillsDir, "..", "outside-secret.md");
|
||||
await fs.mkdir(linkedDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
externalTarget,
|
||||
[
|
||||
"---",
|
||||
"name: Linked Skill",
|
||||
"description: Linked helper.",
|
||||
"---",
|
||||
"",
|
||||
"TOPSECRET",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.symlink(externalTarget, path.join(linkedDir, "SKILL.md"));
|
||||
|
||||
const status = await scanUserSkills(electronApp);
|
||||
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["linked-skill"]);
|
||||
const linkedSkill = status.skills.find((skill) => skill.directoryName === "Linked Skill");
|
||||
|
||||
assert.equal(status.readyCount, 1);
|
||||
assert.equal(status.warningCount, 1);
|
||||
assert.equal(linkedSkill?.status, "warning");
|
||||
assert.match(linkedSkill?.warnings?.[0] || "", /symbolic link/i);
|
||||
assert.equal(result.context.includes("TOPSECRET"), false);
|
||||
assert.match(result.context, /linked-skill/i);
|
||||
assert.match(result.context, /unavailable/i);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("duplicate normalized slugs are downgraded to warnings and not injected explicitly", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "Foo Bar",
|
||||
name: "Foo Bar",
|
||||
description: "First skill.",
|
||||
body: "Body for Foo Bar",
|
||||
},
|
||||
{
|
||||
directoryName: "foo-bar",
|
||||
name: "foo-bar",
|
||||
description: "Second skill.",
|
||||
body: "Body for foo-bar",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const status = await scanUserSkills(electronApp);
|
||||
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["foo-bar"]);
|
||||
|
||||
assert.equal(status.readyCount, 0);
|
||||
assert.equal(status.warningCount, 2);
|
||||
assert.equal(status.skills.every((skill) => skill.status === "warning"), true);
|
||||
assert.equal(
|
||||
status.skills.every((skill) =>
|
||||
skill.warnings.some((warning) => warning.includes('Duplicate skill slug "foo-bar"')),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(result.context.includes("Body for Foo Bar"), false);
|
||||
assert.equal(result.context.includes("Body for foo-bar"), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("skills without a usable ASCII slug are downgraded to warnings", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "部署助手",
|
||||
name: "部署助手",
|
||||
description: "Deployment helper.",
|
||||
body: "Body for 部署助手",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const status = await scanUserSkills(electronApp);
|
||||
|
||||
assert.equal(status.readyCount, 0);
|
||||
assert.equal(status.warningCount, 1);
|
||||
assert.equal(status.skills[0]?.status, "warning");
|
||||
assert.equal(status.skills[0]?.slug, "");
|
||||
assert.match(
|
||||
status.skills[0]?.warnings?.[0] || "",
|
||||
/usable slug/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit selections are capped to stay within the prompt budget", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "Skill One",
|
||||
name: "Skill One",
|
||||
description: "Helper one.",
|
||||
body: "BODY_ONE_" + "a".repeat(3500),
|
||||
},
|
||||
{
|
||||
directoryName: "Skill Two",
|
||||
name: "Skill Two",
|
||||
description: "Helper two.",
|
||||
body: "BODY_TWO_" + "b".repeat(3500),
|
||||
},
|
||||
{
|
||||
directoryName: "Skill Three",
|
||||
name: "Skill Three",
|
||||
description: "Helper three.",
|
||||
body: "BODY_THREE_" + "c".repeat(3500),
|
||||
},
|
||||
{
|
||||
directoryName: "Skill Four",
|
||||
name: "Skill Four",
|
||||
description: "Helper four.",
|
||||
body: "BODY_FOUR_" + "d".repeat(3500),
|
||||
},
|
||||
{
|
||||
directoryName: "Skill Five",
|
||||
name: "Skill Five",
|
||||
description: "Helper five.",
|
||||
body: "BODY_FIVE_" + "e".repeat(3500),
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"plain prompt",
|
||||
["skill-one", "skill-two", "skill-three", "skill-four", "skill-five"],
|
||||
);
|
||||
|
||||
assert.equal(result.context.includes("BODY_ONE_"), true);
|
||||
assert.equal(result.context.includes("BODY_TWO_"), true);
|
||||
assert.equal(result.context.includes("BODY_THREE_"), true);
|
||||
assert.equal(result.context.includes("BODY_FOUR_"), false);
|
||||
assert.equal(result.context.includes("BODY_FIVE_"), false);
|
||||
assert.match(result.context, /prompt budget|additional selected/i);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,11 @@ const { existsSync } = fs;
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
const { getCliLauncherPath, TOOL_CLI_DISCOVERY_ENV_VAR } = require("../cli/discoveryPath.cjs");
|
||||
const {
|
||||
scanUserSkills,
|
||||
buildUserSkillsContext,
|
||||
toPublicUserSkillsStatus,
|
||||
} = require("./ai/userSkills.cjs");
|
||||
|
||||
// ── Extracted modules ──
|
||||
const {
|
||||
@@ -24,6 +29,7 @@ const {
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
serializeStreamChunk,
|
||||
toUnpackedAsarPath,
|
||||
} = require("./ai/shellUtils.cjs");
|
||||
@@ -35,6 +41,9 @@ const {
|
||||
toCodexLoginSessionResponse,
|
||||
getActiveCodexLoginSession,
|
||||
normalizeCodexIntegrationState,
|
||||
readCodexCustomProviderConfig,
|
||||
getCodexAuthOverride,
|
||||
getCodexCustomConfigPreflightError,
|
||||
extractCodexError,
|
||||
isCodexAuthError,
|
||||
getCodexAuthFingerprint,
|
||||
@@ -95,7 +104,8 @@ function getSkillsCliInvocation() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defaultTargetSession }) {
|
||||
function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defaultTargetSession, userSkillsContext }) {
|
||||
const userSkillsPreamble = userSkillsContext ? `${userSkillsContext}\n\n` : "";
|
||||
if (mode === "skills") {
|
||||
const { commandPrefix: cliCommandPrefix, launcherPath, usesLauncher } = getSkillsCliInvocation();
|
||||
const skillHint = existsSync(NETCATTY_TOOL_SKILL_PATH)
|
||||
@@ -133,6 +143,7 @@ function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defau
|
||||
: `Start with \`${cliCommandPrefix} env --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to discover available sessions and their IDs. `;
|
||||
|
||||
return (
|
||||
`${userSkillsPreamble}` +
|
||||
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
|
||||
`${skillHint}` +
|
||||
`${cliHint}` +
|
||||
@@ -161,6 +172,7 @@ function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defau
|
||||
}
|
||||
|
||||
return (
|
||||
`${userSkillsPreamble}` +
|
||||
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
|
||||
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
|
||||
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
|
||||
@@ -234,6 +246,34 @@ function resolveProviderApiKey(providerId) {
|
||||
};
|
||||
}
|
||||
|
||||
function getAcpProviderAuthFingerprint(apiKey, provider, customConfig) {
|
||||
const parts = [
|
||||
typeof apiKey === "string" ? apiKey.trim() : "",
|
||||
typeof provider?.id === "string" ? provider.id.trim() : "",
|
||||
typeof provider?.providerId === "string" ? provider.providerId.trim() : "",
|
||||
typeof provider?.baseURL === "string" ? provider.baseURL.trim() : "",
|
||||
customConfig
|
||||
? [
|
||||
"custom",
|
||||
customConfig.providerName || "",
|
||||
customConfig.baseUrl || "",
|
||||
customConfig.envKey || "",
|
||||
customConfig.envKeyPresent ? "1" : "0",
|
||||
// authHash changes when the user rotates their hardcoded api_key
|
||||
// or the env_key's resolved value; without it a cached ACP
|
||||
// provider would keep serving the stale key.
|
||||
customConfig.authHash || "",
|
||||
].join(":")
|
||||
: "",
|
||||
].filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getCodexAuthFingerprint(parts.join("\n"));
|
||||
}
|
||||
|
||||
/** Check if TLS verification should be skipped for a given provider. */
|
||||
function shouldSkipTLSVerify(providerId) {
|
||||
if (!providerId) return false;
|
||||
@@ -734,6 +774,41 @@ function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ai:user-skills:status", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const status = await scanUserSkills(electronModule?.app);
|
||||
return { ok: true, ...toPublicUserSkillsStatus(status) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:user-skills:open", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const status = await scanUserSkills(electronModule?.app);
|
||||
const openResult = await electronModule?.shell?.openPath?.(status.directoryPath);
|
||||
return {
|
||||
ok: !openResult,
|
||||
error: openResult || undefined,
|
||||
...toPublicUserSkillsStatus(status),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:user-skills:build-context", async (event, { prompt, selectedSkillSlugs }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const { context, status } = await buildUserSkillsContext(electronModule?.app, prompt, selectedSkillSlugs);
|
||||
return { ok: true, context, status: toPublicUserSkillsStatus(status) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// ── Provider config sync (renderer → main, keys stay encrypted) ──
|
||||
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false };
|
||||
@@ -1689,8 +1764,14 @@ function registerHandlers(ipcMain) {
|
||||
return { path: resolvedPath, version, available: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event) => {
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// When the user clicks "Refresh Status" in Settings we also want to
|
||||
// rescan the shell env — otherwise a newly-exported variable in
|
||||
// .zshrc stays invisible until they restart netcatty entirely.
|
||||
if (options && options.refreshShellEnv) {
|
||||
invalidateShellEnvCache();
|
||||
}
|
||||
try {
|
||||
const result = await runCodexCli(["login", "status"]);
|
||||
const rawOutput = [result.stdout, result.stderr]
|
||||
@@ -1724,11 +1805,33 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
// `codex login status` only reflects ~/.codex/auth.json. A user who
|
||||
// configured a custom provider directly in ~/.codex/config.toml is
|
||||
// functional from the CLI but would look "not_logged_in" here. Probe
|
||||
// config.toml so we can surface that as a valid ready state instead of
|
||||
// pushing the user into the ChatGPT login flow.
|
||||
let customConfig = null;
|
||||
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
|
||||
try {
|
||||
const shellEnv = await getShellEnv();
|
||||
customConfig = readCodexCustomProviderConfig(shellEnv);
|
||||
if (customConfig) {
|
||||
state = "connected_custom_config";
|
||||
}
|
||||
} catch {
|
||||
customConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
|
||||
isConnected:
|
||||
state === "connected_chatgpt" ||
|
||||
state === "connected_api_key" ||
|
||||
state === "connected_custom_config",
|
||||
rawOutput: effectiveRawOutput,
|
||||
exitCode: result.exitCode,
|
||||
customConfig,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -1736,6 +1839,7 @@ function registerHandlers(ipcMain) {
|
||||
isConnected: false,
|
||||
rawOutput: err?.message || String(err),
|
||||
exitCode: null,
|
||||
customConfig: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1847,7 +1951,10 @@ function registerHandlers(ipcMain) {
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
|
||||
isConnected:
|
||||
state === "connected_chatgpt" ||
|
||||
state === "connected_api_key" ||
|
||||
state === "connected_custom_config",
|
||||
rawOutput,
|
||||
logoutOutput: [logoutResult.stdout, logoutResult.stderr]
|
||||
.filter((chunk) => chunk.trim().length > 0)
|
||||
@@ -2102,10 +2209,29 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
const apiKey = resolvedProvider?.apiKey || undefined;
|
||||
|
||||
// Mirror the stream handler's pre-flight: if Codex is pointed at a
|
||||
// config.toml custom provider whose env_key is not exported, surface
|
||||
// a targeted error instead of spawning codex-acp and letting it fail
|
||||
// mid-init with an opaque message.
|
||||
if (isCodexAgent && !apiKey) {
|
||||
const preflight = getCodexCustomConfigPreflightError(
|
||||
readCodexCustomProviderConfig(shellEnv),
|
||||
);
|
||||
if (preflight) {
|
||||
return { ok: false, models: [], error: preflight };
|
||||
}
|
||||
}
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||
if (apiKey) {
|
||||
if (isCodexAgent && apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
|
||||
agentEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
|
||||
}
|
||||
// Claude agent auth is owned entirely by its CLI config/login state
|
||||
// (`claude auth login`, ~/.claude settings, or ANTHROPIC_* in the user's
|
||||
// shell env). netcatty's provider list must not override it.
|
||||
|
||||
if (isCopilotAgent) {
|
||||
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
|
||||
@@ -2134,7 +2260,7 @@ function registerHandlers(ipcMain) {
|
||||
mcpServers: [],
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
? getCodexAuthOverride(apiKey, shellEnv)
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
@@ -2182,7 +2308,7 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession }) => {
|
||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
@@ -2191,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) {
|
||||
@@ -2198,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);
|
||||
@@ -2245,7 +2387,28 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
const apiKey = resolvedProvider?.apiKey || undefined;
|
||||
|
||||
if (isCodexAgent && !apiKey) {
|
||||
// Probe ~/.codex/config.toml first so we can tell a ChatGPT user
|
||||
// (needs login validation) from a custom-provider user (must NOT be
|
||||
// forced through ChatGPT validation, since their auth lives in
|
||||
// config.toml / shell env, not auth.json).
|
||||
const codexCustomConfig = isCodexAgent && !apiKey
|
||||
? readCodexCustomProviderConfig(shellEnv)
|
||||
: null;
|
||||
|
||||
// Fail loud: custom-provider config is set but has no usable auth
|
||||
// material yet (env_key is named but not exported in the shell env,
|
||||
// and no api_key is hardcoded). Don't spawn — codex-acp would fail
|
||||
// mid-request with an opaque "Missing environment variable" error.
|
||||
const preflightError = getCodexCustomConfigPreflightError(codexCustomConfig);
|
||||
if (preflightError) {
|
||||
safeSend(event.sender, "netcatty:ai:acp:error", {
|
||||
requestId,
|
||||
error: preflightError,
|
||||
});
|
||||
return { ok: false, error: `Missing env var ${codexCustomConfig.envKey}` };
|
||||
}
|
||||
|
||||
if (isCodexAgent && !apiKey && !codexCustomConfig) {
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
if (!validation.ok) {
|
||||
@@ -2266,7 +2429,9 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
const authFingerprint = isCodexAgent ? getCodexAuthFingerprint(apiKey) : null;
|
||||
const authFingerprint = isCodexAgent
|
||||
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
|
||||
: null;
|
||||
const mcpSnapshot = isCodexAgent
|
||||
? await resolveCodexMcpSnapshot(sessionCwd)
|
||||
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||||
@@ -2327,15 +2492,58 @@ 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 });
|
||||
if (apiKey) {
|
||||
if (isCodexAgent && apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
|
||||
agentEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
|
||||
}
|
||||
// See comment above: Claude auth is CLI-owned, not provider-driven.
|
||||
let copilotConfigInfo = null;
|
||||
if (isCopilotAgent) {
|
||||
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
|
||||
@@ -2366,7 +2574,7 @@ function registerHandlers(ipcMain) {
|
||||
},
|
||||
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
? getCodexAuthOverride(apiKey, shellEnv)
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
@@ -2378,7 +2586,7 @@ function registerHandlers(ipcMain) {
|
||||
resolvedCommand,
|
||||
resolvedArgs,
|
||||
mcpServerNames: mcpSnapshot.mcpServers.map(server => server.name),
|
||||
authMethodId: isCodexAgent ? (apiKey ? "codex-api-key" : "chatgpt") : null,
|
||||
authMethodId: isCodexAgent ? (getCodexAuthOverride(apiKey, shellEnv).authMethodId || null) : null,
|
||||
});
|
||||
|
||||
if (isCopilotAgent) {
|
||||
@@ -2402,7 +2610,7 @@ function registerHandlers(ipcMain) {
|
||||
authFingerprint,
|
||||
mcpFingerprint: mcpSnapshot.fingerprint,
|
||||
permissionMode: currentPermissionMode,
|
||||
historyReplayFallback: false,
|
||||
historyReplayFallback: preserveHistoryReplayFallback,
|
||||
};
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
}
|
||||
@@ -2452,8 +2660,12 @@ function registerHandlers(ipcMain) {
|
||||
: acpArgs || [],
|
||||
env: (() => {
|
||||
const fallbackEnv = withCliDiscoveryEnv(
|
||||
apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
|
||||
isCodexAgent && apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
|
||||
);
|
||||
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
|
||||
fallbackEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
|
||||
}
|
||||
// See comment above: Claude auth is CLI-owned, not provider-driven.
|
||||
if (isCopilotAgent) {
|
||||
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
|
||||
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
|
||||
@@ -2465,7 +2677,7 @@ function registerHandlers(ipcMain) {
|
||||
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
? getCodexAuthOverride(apiKey, shellEnv)
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
@@ -2513,6 +2725,7 @@ function registerHandlers(ipcMain) {
|
||||
prompt,
|
||||
chatSessionId,
|
||||
defaultTargetSession,
|
||||
userSkillsContext,
|
||||
});
|
||||
|
||||
// Build message content: text + optional attachments
|
||||
@@ -2568,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],
|
||||
@@ -2661,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 };
|
||||
@@ -2713,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);
|
||||
});
|
||||
@@ -34,11 +34,138 @@ let trayMenuData = {
|
||||
let trayPanelWindow = null;
|
||||
|
||||
let trayPanelRefreshTimer = null;
|
||||
// Watchdog: if `leave-full-screen` never arrives (edge case / stuck transition)
|
||||
// we eventually give up and force a hide attempt. Better a visible window than
|
||||
// a hung close-to-tray path.
|
||||
const FULLSCREEN_LEAVE_WATCHDOG_MS = 5000;
|
||||
// After `leave-full-screen` fires, macOS emits a trailing `show` event while
|
||||
// the native space transition finishes. Calling `win.hide()` before that show
|
||||
// causes the window to pop back on screen. We wait for the trailing show, or
|
||||
// fall back on this timeout — whichever comes first.
|
||||
const FULLSCREEN_TRAILING_SHOW_FALLBACK_MS = 300;
|
||||
const pendingFullscreenHideByWindow = new WeakMap();
|
||||
|
||||
function clearPendingFullscreenHide(win) {
|
||||
if (!win || typeof win !== "object") return;
|
||||
const pending = pendingFullscreenHideByWindow.get(win);
|
||||
if (!pending) return;
|
||||
|
||||
if (pending.watchdogTimer) {
|
||||
clearTimeout(pending.watchdogTimer);
|
||||
pending.watchdogTimer = null;
|
||||
}
|
||||
if (pending.trailingShowTimer) {
|
||||
clearTimeout(pending.trailingShowTimer);
|
||||
pending.trailingShowTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (pending.onLeaveFullScreen) {
|
||||
win.removeListener?.("leave-full-screen", pending.onLeaveFullScreen);
|
||||
}
|
||||
if (pending.onClosed) {
|
||||
win.removeListener?.("closed", pending.onClosed);
|
||||
}
|
||||
if (pending.onTrailingShow) {
|
||||
win.removeListener?.("show", pending.onTrailingShow);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
pendingFullscreenHideByWindow.delete(win);
|
||||
}
|
||||
|
||||
function performPendingFullscreenHide(win) {
|
||||
const pending = pendingFullscreenHideByWindow.get(win);
|
||||
if (!pending) return "cancelled";
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
clearPendingFullscreenHide(win);
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
clearPendingFullscreenHide(win);
|
||||
|
||||
try {
|
||||
win.hide();
|
||||
return "hidden";
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error hiding window after leaving fullscreen:", err);
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
function handleLeaveFullScreenForPendingHide(win) {
|
||||
const pending = pendingFullscreenHideByWindow.get(win);
|
||||
if (!pending) return;
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
clearPendingFullscreenHide(win);
|
||||
return;
|
||||
}
|
||||
|
||||
pending.leaveFullScreenFired = true;
|
||||
|
||||
if (pending.watchdogTimer) {
|
||||
clearTimeout(pending.watchdogTimer);
|
||||
pending.watchdogTimer = null;
|
||||
}
|
||||
|
||||
// Wait for the trailing `show` that macOS emits as the space transition
|
||||
// finishes, then hide on top of it. If it never fires within the fallback
|
||||
// window, hide anyway.
|
||||
pending.onTrailingShow = () => {
|
||||
pending.onTrailingShow = null;
|
||||
if (pending.trailingShowTimer) {
|
||||
clearTimeout(pending.trailingShowTimer);
|
||||
pending.trailingShowTimer = null;
|
||||
}
|
||||
performPendingFullscreenHide(win);
|
||||
};
|
||||
try {
|
||||
win.once?.("show", pending.onTrailingShow);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
pending.trailingShowTimer = setTimeout(() => {
|
||||
pending.trailingShowTimer = null;
|
||||
if (pending.onTrailingShow) {
|
||||
try {
|
||||
win.removeListener?.("show", pending.onTrailingShow);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
pending.onTrailingShow = null;
|
||||
}
|
||||
performPendingFullscreenHide(win);
|
||||
}, FULLSCREEN_TRAILING_SHOW_FALLBACK_MS);
|
||||
}
|
||||
|
||||
function startPendingFullscreenHideWatchdog(win) {
|
||||
const pending = pendingFullscreenHideByWindow.get(win);
|
||||
if (!pending) return;
|
||||
|
||||
pending.watchdogTimer = setTimeout(() => {
|
||||
pending.watchdogTimer = null;
|
||||
if (!pendingFullscreenHideByWindow.has(win)) return;
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
clearPendingFullscreenHide(win);
|
||||
return;
|
||||
}
|
||||
if (pending.leaveFullScreenFired) return;
|
||||
|
||||
console.warn("[GlobalShortcut] Timed out waiting for leave-full-screen before hiding to tray; forcing hide");
|
||||
// Give up and hide anyway. Simulate the leave path so the trailing-show
|
||||
// wait still applies (defence in depth against spurious show events).
|
||||
handleLeaveFullScreenForPendingHide(win);
|
||||
}, FULLSCREEN_LEAVE_WATCHDOG_MS);
|
||||
}
|
||||
|
||||
function openMainWindow() {
|
||||
const { app } = electronModule;
|
||||
const win = getMainWindow();
|
||||
if (!win) return;
|
||||
clearPendingFullscreenHide(win);
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
@@ -218,6 +345,65 @@ function getMainWindow() {
|
||||
return mainWins && mainWins.length ? mainWins[0] : null;
|
||||
}
|
||||
|
||||
function hideWindowRespectingMacFullscreen(win) {
|
||||
if (!win || win.isDestroyed?.()) return false;
|
||||
|
||||
clearPendingFullscreenHide(win);
|
||||
|
||||
if (process.platform === "darwin" && win.isFullScreen?.()) {
|
||||
// Close-to-tray on a native-fullscreen window on macOS has two traps:
|
||||
//
|
||||
// 1. `isFullScreen()` can flip to false BEFORE the exit animation
|
||||
// completes. Polling it and calling `win.hide()` at that moment
|
||||
// hides the window mid-transition, which macOS then undoes when
|
||||
// the animation finishes.
|
||||
// 2. Right after the real `leave-full-screen` event, macOS emits an
|
||||
// internal `show` event as part of finalizing the space transition
|
||||
// — this show undoes any earlier hide.
|
||||
//
|
||||
// Strategy: wait for `leave-full-screen`, then wait for the trailing
|
||||
// `show` that follows it (or a short timeout), and only then hide.
|
||||
// All legitimate "bring the window back" entry points (openMainWindow,
|
||||
// toggleWindowVisibility, setCloseToTray(false), app.on("activate"),
|
||||
// closed) explicitly call clearPendingFullscreenHide so we never race
|
||||
// with genuine user intent.
|
||||
const pending = {
|
||||
watchdogTimer: null,
|
||||
trailingShowTimer: null,
|
||||
leaveFullScreenFired: false,
|
||||
onLeaveFullScreen: null,
|
||||
onClosed: null,
|
||||
onTrailingShow: null,
|
||||
};
|
||||
pending.onLeaveFullScreen = () => {
|
||||
handleLeaveFullScreenForPendingHide(win);
|
||||
};
|
||||
pending.onClosed = () => {
|
||||
clearPendingFullscreenHide(win);
|
||||
};
|
||||
|
||||
try {
|
||||
pendingFullscreenHideByWindow.set(win, pending);
|
||||
win.once?.("leave-full-screen", pending.onLeaveFullScreen);
|
||||
win.once?.("closed", pending.onClosed);
|
||||
startPendingFullscreenHideWatchdog(win);
|
||||
win.setFullScreen(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
clearPendingFullscreenHide(win);
|
||||
console.warn("[GlobalShortcut] Error leaving fullscreen before hiding window:", err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
win.hide();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error hiding window:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hotkey string from frontend format to Electron accelerator format
|
||||
* e.g., "⌘ + Space" -> "CommandOrControl+Space"
|
||||
@@ -283,6 +469,7 @@ function toggleWindowVisibility() {
|
||||
try {
|
||||
// Check if window is minimized first - minimized windows may still report isVisible() = true
|
||||
if (win.isMinimized()) {
|
||||
clearPendingFullscreenHide(win);
|
||||
win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
@@ -295,9 +482,10 @@ function toggleWindowVisibility() {
|
||||
} else if (win.isVisible()) {
|
||||
if (win.isFocused()) {
|
||||
// Window is visible and focused - hide it
|
||||
win.hide();
|
||||
hideWindowRespectingMacFullscreen(win);
|
||||
} else {
|
||||
// Window is visible but not focused - focus it
|
||||
clearPendingFullscreenHide(win);
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
try {
|
||||
@@ -308,6 +496,7 @@ function toggleWindowVisibility() {
|
||||
}
|
||||
} else {
|
||||
// Window is hidden - show and focus it
|
||||
clearPendingFullscreenHide(win);
|
||||
win.show();
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
@@ -437,17 +626,7 @@ function buildTrayMenuTemplate() {
|
||||
menuTemplate.push({
|
||||
label: "Open Main Window",
|
||||
click: () => {
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
openMainWindow();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -587,6 +766,7 @@ function setCloseToTray(enabled) {
|
||||
createTray();
|
||||
}
|
||||
} else {
|
||||
clearPendingFullscreenHide(getMainWindow());
|
||||
// Destroy tray if it exists
|
||||
destroyTray();
|
||||
}
|
||||
@@ -617,7 +797,7 @@ function getHotkeyStatus() {
|
||||
function handleWindowClose(event, win) {
|
||||
if (closeToTray && tray) {
|
||||
event.preventDefault();
|
||||
win.hide();
|
||||
hideWindowRespectingMacFullscreen(win);
|
||||
return true; // Prevented close
|
||||
}
|
||||
return false; // Allow close
|
||||
@@ -727,5 +907,6 @@ module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
handleWindowClose,
|
||||
clearPendingFullscreenHide,
|
||||
cleanup,
|
||||
};
|
||||
|
||||
492
electron/bridges/globalShortcutBridge.test.cjs
Normal file
492
electron/bridges/globalShortcutBridge.test.cjs
Normal file
@@ -0,0 +1,492 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
function withPatchedTimers(run) {
|
||||
const originalSetTimeout = global.setTimeout;
|
||||
const originalClearTimeout = global.clearTimeout;
|
||||
let nextTimerId = 1;
|
||||
const timers = new Map();
|
||||
|
||||
global.setTimeout = (fn, _delay, ...args) => {
|
||||
const id = nextTimerId++;
|
||||
timers.set(id, () => fn(...args));
|
||||
return id;
|
||||
};
|
||||
|
||||
global.clearTimeout = (id) => {
|
||||
timers.delete(id);
|
||||
};
|
||||
|
||||
const flushNextTimer = () => {
|
||||
const nextEntry = timers.entries().next().value;
|
||||
if (!nextEntry) return false;
|
||||
const [id, fn] = nextEntry;
|
||||
timers.delete(id);
|
||||
fn();
|
||||
return true;
|
||||
};
|
||||
|
||||
const getPendingTimerCount = () => timers.size;
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => run({ flushNextTimer, getPendingTimerCount }))
|
||||
.finally(() => {
|
||||
global.setTimeout = originalSetTimeout;
|
||||
global.clearTimeout = originalClearTimeout;
|
||||
});
|
||||
}
|
||||
|
||||
function withPatchedDateNow(initialValue, run) {
|
||||
const originalDateNow = Date.now;
|
||||
let currentValue = initialValue;
|
||||
|
||||
Date.now = () => currentValue;
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() =>
|
||||
run({
|
||||
setNow(nextValue) {
|
||||
currentValue = nextValue;
|
||||
},
|
||||
}))
|
||||
.finally(() => {
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
}
|
||||
|
||||
function loadBridge() {
|
||||
const bridgePath = require.resolve("./globalShortcutBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
return require("./globalShortcutBridge.cjs");
|
||||
}
|
||||
|
||||
function createElectronStub() {
|
||||
class FakeTray {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
}
|
||||
|
||||
setToolTip() {}
|
||||
setContextMenu() {}
|
||||
destroy() {}
|
||||
|
||||
on(eventName, handler) {
|
||||
this.handlers.set(eventName, handler);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Tray: FakeTray,
|
||||
Menu: {},
|
||||
BrowserWindow: {
|
||||
getAllWindows() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
globalShortcut: {
|
||||
register() {
|
||||
return true;
|
||||
},
|
||||
unregister() {},
|
||||
},
|
||||
nativeImage: {
|
||||
createFromPath() {
|
||||
return {
|
||||
resize() {
|
||||
return this;
|
||||
},
|
||||
setTemplateImage() {},
|
||||
};
|
||||
},
|
||||
createEmpty() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
app: {
|
||||
getAppPath() {
|
||||
return process.cwd();
|
||||
},
|
||||
quit() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIpcMainStub() {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeWindow extends EventEmitter {
|
||||
constructor({ fullscreen = false } = {}) {
|
||||
super();
|
||||
this.fullscreen = fullscreen;
|
||||
this.hideCalls = 0;
|
||||
this.showCalls = 0;
|
||||
this.focusCalls = 0;
|
||||
this.restoreCalls = 0;
|
||||
this.setFullScreenCalls = [];
|
||||
this.destroyed = false;
|
||||
this.minimized = false;
|
||||
this.visible = true;
|
||||
this.focused = true;
|
||||
}
|
||||
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
|
||||
isFullScreen() {
|
||||
return this.fullscreen;
|
||||
}
|
||||
|
||||
setFullScreen(nextValue) {
|
||||
this.setFullScreenCalls.push(nextValue);
|
||||
if (nextValue) {
|
||||
this.fullscreen = true;
|
||||
}
|
||||
}
|
||||
|
||||
isMinimized() {
|
||||
return this.minimized;
|
||||
}
|
||||
|
||||
restore() {
|
||||
this.restoreCalls += 1;
|
||||
this.minimized = false;
|
||||
}
|
||||
|
||||
isVisible() {
|
||||
return this.visible;
|
||||
}
|
||||
|
||||
isFocused() {
|
||||
return this.focused;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.hideCalls += 1;
|
||||
this.visible = false;
|
||||
this.focused = false;
|
||||
}
|
||||
|
||||
show() {
|
||||
this.showCalls += 1;
|
||||
this.visible = true;
|
||||
this.emit("show");
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.focusCalls += 1;
|
||||
this.focused = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function withPlatform(platform, run) {
|
||||
const original = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: platform });
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", original);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCloseToTray(bridge, electronModule = createElectronStub()) {
|
||||
bridge.init({ electronModule });
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: true });
|
||||
return { ipcMain, electronModule };
|
||||
}
|
||||
|
||||
test("handleWindowClose allows normal close when close-to-tray is disabled", () => {
|
||||
const bridge = loadBridge();
|
||||
const win = new FakeWindow();
|
||||
let prevented = false;
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
|
||||
test("close-to-tray on a mac fullscreen window defers hide until after leave-full-screen and the trailing show", async () => {
|
||||
// Observed macOS sequence after the red close on a fullscreen window:
|
||||
// setFullScreen(false) → (animation) → leave-full-screen → trailing show
|
||||
// Hiding before the trailing show causes macOS to pop the window back
|
||||
// during the final space transition. The fix waits for the trailing show
|
||||
// (or a fallback timer) before calling win.hide().
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
let prevented = false;
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(win.setFullScreenCalls, [false]);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
// Watchdog timer is pending. No show listener yet — macOS's
|
||||
// pre-leave-full-screen internal `show` events must not trigger hide.
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.listenerCount("show"), 0);
|
||||
|
||||
// Spurious early show (mid-animation) does nothing.
|
||||
win.emit("show");
|
||||
assert.equal(win.hideCalls, 0);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
// leave-full-screen arrives. Watchdog cancelled; now we arm a `show`
|
||||
// listener + trailing-show fallback timer. Still no hide.
|
||||
win.fullscreen = false;
|
||||
win.emit("leave-full-screen");
|
||||
assert.equal(win.hideCalls, 0);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.listenerCount("show"), 1);
|
||||
|
||||
// Trailing show from macOS finalizing the space transition runs the hide.
|
||||
win.emit("show");
|
||||
assert.equal(win.hideCalls, 1);
|
||||
assert.equal(win.listenerCount("show"), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("fallback timer hides the window when the trailing show never arrives", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
|
||||
bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
win.fullscreen = false;
|
||||
win.emit("leave-full-screen");
|
||||
|
||||
// Watchdog cleared; trailing-show fallback timer is pending.
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
assert.equal(win.listenerCount("show"), 1);
|
||||
|
||||
// No show ever arrives. Fallback timer runs.
|
||||
flushNextTimer();
|
||||
|
||||
assert.equal(win.hideCalls, 1);
|
||||
assert.equal(win.listenerCount("show"), 0);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("watchdog forces the hide path if leave-full-screen never arrives", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
|
||||
bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
// Watchdog fires (simulates 5s with no leave-full-screen). It forces
|
||||
// the leave path — which arms the trailing-show listener + fallback.
|
||||
flushNextTimer();
|
||||
assert.equal(win.hideCalls, 0);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.listenerCount("show"), 1);
|
||||
|
||||
// Trailing-show fallback fires → hide.
|
||||
flushNextTimer();
|
||||
assert.equal(win.hideCalls, 1);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("app activate clears a pending fullscreen hide", async () => {
|
||||
// Regression for the close-to-tray + fullscreen bug where the internal
|
||||
// `show` emitted during the fullscreen exit animation was cancelling the
|
||||
// hide. main.cjs's app.on("activate") handler now calls into this bridge
|
||||
// to cancel the pending hide when the user actually re-activates the app.
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
bridge.clearPendingFullscreenHide(win);
|
||||
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
assert.equal(flushNextTimer(), false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("focusing a visible window cancels a pending fullscreen hide", async () => {
|
||||
await withPatchedTimers(async ({ getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
win.focused = false;
|
||||
electronModule.BrowserWindow.getAllWindows = () => [win];
|
||||
let toggleWindow = null;
|
||||
electronModule.globalShortcut.register = (_accelerator, handler) => {
|
||||
toggleWindow = handler;
|
||||
return true;
|
||||
};
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
await ipcMain.handlers.get("netcatty:globalHotkey:register")(null, { hotkey: "Ctrl + `" });
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
toggleWindow();
|
||||
|
||||
assert.equal(win.focusCalls, 1);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("openMainWindow cancels a pending fullscreen hide before showing the window", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
win.show = function showWithoutEmit() {
|
||||
this.showCalls += 1;
|
||||
this.visible = true;
|
||||
};
|
||||
electronModule.BrowserWindow.getAllWindows = () => [win];
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
await ipcMain.handlers.get("netcatty:trayPanel:openMainWindow")();
|
||||
|
||||
assert.equal(win.showCalls, 1);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
|
||||
const flushed = flushNextTimer();
|
||||
assert.equal(flushed, false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("closing the window clears a pending fullscreen hide", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 1);
|
||||
assert.equal(win.listenerCount("closed"), 1);
|
||||
|
||||
win.destroyed = true;
|
||||
win.emit("closed");
|
||||
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
assert.equal(flushNextTimer(), false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("disabling close-to-tray clears a pending fullscreen hide", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
electronModule.BrowserWindow.getAllWindows = () => [win];
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
|
||||
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
assert.equal(flushNextTimer(), false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("handleWindowClose hides immediately when tray close is used outside fullscreen", async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: false });
|
||||
let prevented = false;
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(win.setFullScreenCalls, []);
|
||||
assert.equal(win.hideCalls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("handleWindowClose stays in close-to-tray mode even if hide fails", async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: false });
|
||||
win.hide = function failingHide() {
|
||||
throw new Error("hide failed");
|
||||
};
|
||||
let prevented = false;
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(win.visible, true);
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
);
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
595
electron/bridges/vaultBackupBridge.cjs
Normal file
595
electron/bridges/vaultBackupBridge.cjs
Normal file
@@ -0,0 +1,595 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const BACKUP_DIR_NAME = "vault-backups";
|
||||
const BACKUP_FILE_PREFIX = "vault-backup-";
|
||||
const BACKUP_FILE_EXT = ".json";
|
||||
|
||||
// The renderer is the untrusted input boundary for this bridge, so every
|
||||
// piece of user-controlled data is validated before it reaches disk or
|
||||
// propagates back into the UI. Keep these limits in sync with the
|
||||
// renderer's `sanitizeLocalVaultBackupMaxCount` constants.
|
||||
const MIN_MAX_COUNT = 1;
|
||||
const MAX_MAX_COUNT = 100;
|
||||
const DEFAULT_MAX_COUNT = 20;
|
||||
// 25 MiB — two orders of magnitude above any realistic vault. A payload
|
||||
// exceeding this is either a runaway test harness or a misbehaving/compromised
|
||||
// renderer; refusing here prevents disk-fill DoS. The vault proper is capped
|
||||
// at a much smaller size elsewhere in the app, so legitimate users never hit
|
||||
// this limit.
|
||||
const MAX_PAYLOAD_BYTES = 25 * 1024 * 1024;
|
||||
const ALLOWED_REASONS = new Set(["app_version_change", "before_restore"]);
|
||||
// Version strings are persisted and surfaced in the Settings UI, so they
|
||||
// must not carry control chars that would break logs, parsing, or
|
||||
// display. Keep alphanumerics + a handful of punctuation that covers
|
||||
// SemVer-ish and prerelease tags.
|
||||
const VERSION_STRING_PATTERN = /^[A-Za-z0-9._+\-]{1,64}$/;
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Normalize a payload into a form that hashes stably across runs:
|
||||
// - object keys sorted so JSON.stringify output is deterministic
|
||||
// - undefined values dropped (they'd stringify as gaps anyway)
|
||||
// - the TOP-LEVEL `syncedAt` timestamp is zeroed so semantically-equal
|
||||
// payloads produced seconds apart still dedupe. Nested `syncedAt`
|
||||
// fields (e.g. a future per-record mtime) are preserved — zeroing
|
||||
// them would silently collide two semantically-different payloads
|
||||
// into the same fingerprint and cause the version-change / protective
|
||||
// backup dedupe to drop a backup that should have been written.
|
||||
//
|
||||
// INVARIANT: array order is treated as semantically meaningful and is
|
||||
// NOT canonicalized. Every domain array that flows through SyncPayload
|
||||
// (hosts, keys, snippets, identities, portForwardingRules, …) is
|
||||
// produced by a store that iterates its internal `Map`/`Set` in a
|
||||
// stable, insertion-ordered way, so two semantically-equal payloads
|
||||
// built in the same renderer session produce identical orderings. If a
|
||||
// future refactor introduces a non-deterministic iteration source,
|
||||
// fingerprints will flap and the dedupe will miss — sort at the
|
||||
// producer, not here. Sorting inside the hash function would require
|
||||
// choosing a stable key per array type and would silently hide
|
||||
// intentionally-reordered payloads (user dragged a host in the list)
|
||||
// as "the same backup," which would be a safety regression.
|
||||
function normalizePayloadForHash(value, isRoot = true) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizePayloadForHash(item, false));
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const entries = Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
return entries.reduce((acc, [entryKey, entryValue]) => {
|
||||
acc[entryKey] =
|
||||
isRoot && entryKey === "syncedAt"
|
||||
? 0
|
||||
: normalizePayloadForHash(entryValue, false);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
return JSON.stringify(normalizePayloadForHash(value));
|
||||
}
|
||||
|
||||
function computePayloadFingerprint(payload) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(stableStringify(payload))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function buildPreview(payload) {
|
||||
return {
|
||||
hostCount: Array.isArray(payload?.hosts) ? payload.hosts.length : 0,
|
||||
keyCount: Array.isArray(payload?.keys) ? payload.keys.length : 0,
|
||||
snippetCount: Array.isArray(payload?.snippets) ? payload.snippets.length : 0,
|
||||
identityCount: Array.isArray(payload?.identities) ? payload.identities.length : 0,
|
||||
portForwardingRuleCount: Array.isArray(payload?.portForwardingRules) ? payload.portForwardingRules.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toBackupSummary(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
reason: record.reason,
|
||||
syncDataVersion: record.syncDataVersion,
|
||||
sourceAppVersion: record.sourceAppVersion,
|
||||
targetAppVersion: record.targetAppVersion,
|
||||
preview: record.preview,
|
||||
fingerprint: record.fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
// Clamp an unvalidated maxCount to the supported range. Returns
|
||||
// DEFAULT_MAX_COUNT for anything non-finite or non-numeric so callers
|
||||
// without a configured retention still get a sane cap.
|
||||
function sanitizeMaxCount(rawMaxCount) {
|
||||
const numeric = Number(rawMaxCount);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_MAX_COUNT;
|
||||
return Math.max(MIN_MAX_COUNT, Math.min(MAX_MAX_COUNT, Math.floor(numeric)));
|
||||
}
|
||||
|
||||
function sanitizeReason(rawReason) {
|
||||
// Fall back to the "before_restore" default rather than throwing — the
|
||||
// default is the safer label for an unknown-cause backup, since it
|
||||
// implies "this was taken defensively" in the UI.
|
||||
if (typeof rawReason === "string" && ALLOWED_REASONS.has(rawReason)) {
|
||||
return rawReason;
|
||||
}
|
||||
return "before_restore";
|
||||
}
|
||||
|
||||
function sanitizeOptionalVersionString(value) {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!VERSION_STRING_PATTERN.test(trimmed)) return undefined;
|
||||
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
|
||||
// labels would report ~12.5 MiB against the 25 MiB cap when the on-wire
|
||||
// size was actually 25+ MiB. `Buffer.byteLength(..., 'utf8')` gives the
|
||||
// true bytes-on-disk figure.
|
||||
function estimatePayloadSize(payload) {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(payload), "utf8");
|
||||
} catch {
|
||||
return Infinity;
|
||||
}
|
||||
}
|
||||
|
||||
// Error thrown when the platform has no secure storage available. Backups
|
||||
// would contain plaintext credentials (passwords, private keys, passphrases)
|
||||
// in fields that SyncPayload carries unencrypted, so falling back to a
|
||||
// plain-json file on disk would regress the vault's security posture below
|
||||
// what the normal encrypted localStorage vault provides. We refuse rather
|
||||
// than silently weaken the user's protection.
|
||||
class VaultBackupEncryptionUnavailableError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Secure storage is unavailable on this platform; vault backups cannot be created or read safely.",
|
||||
);
|
||||
this.name = "VaultBackupEncryptionUnavailableError";
|
||||
this.code = "VAULT_BACKUP_ENCRYPTION_UNAVAILABLE";
|
||||
}
|
||||
}
|
||||
|
||||
class VaultBackupTooLargeError extends Error {
|
||||
constructor(size) {
|
||||
super(
|
||||
`Vault backup payload exceeds maximum allowed size (${size} > ${MAX_PAYLOAD_BYTES}).`,
|
||||
);
|
||||
this.name = "VaultBackupTooLargeError";
|
||||
this.code = "VAULT_BACKUP_TOO_LARGE";
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeStorageAvailable(safeStorage) {
|
||||
return Boolean(safeStorage?.isEncryptionAvailable?.());
|
||||
}
|
||||
|
||||
function encodePayload(payload, safeStorage) {
|
||||
if (!isSafeStorageAvailable(safeStorage)) {
|
||||
throw new VaultBackupEncryptionUnavailableError();
|
||||
}
|
||||
const raw = JSON.stringify(payload);
|
||||
return {
|
||||
encoding: "safeStorage-v1",
|
||||
data: safeStorage.encryptString(raw).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePayload(record, safeStorage) {
|
||||
if (record.payloadEncoding === "safeStorage-v1") {
|
||||
if (!safeStorage?.decryptString || !isSafeStorageAvailable(safeStorage)) {
|
||||
throw new VaultBackupEncryptionUnavailableError();
|
||||
}
|
||||
const decrypted = safeStorage.decryptString(Buffer.from(record.payloadData, "base64"));
|
||||
return JSON.parse(decrypted);
|
||||
}
|
||||
|
||||
// Legacy "plain-json-v1" records may exist from an earlier build; read
|
||||
// them once so users can migrate their data, but never write new ones.
|
||||
if (record.payloadEncoding === "plain-json-v1") {
|
||||
return JSON.parse(record.payloadData);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported vault backup encoding: ${record.payloadEncoding}`);
|
||||
}
|
||||
|
||||
// Upper bound for a backup file on disk. The plaintext payload is capped
|
||||
// at MAX_PAYLOAD_BYTES on write; the encrypted-and-base64-encoded record
|
||||
// plus JSON envelope inflates that by ~2x worst case (base64 adds ~33%,
|
||||
// JSON formatting adds some, and the record metadata rounds up). A 2x
|
||||
// multiplier leaves comfortable headroom for legitimate backups while
|
||||
// still rejecting a 100+ MiB file that a user (or attacker) dropped
|
||||
// into the backup directory manually.
|
||||
const MAX_BACKUP_FILE_BYTES = MAX_PAYLOAD_BYTES * 2;
|
||||
|
||||
async function readBackupRecord(filePath) {
|
||||
// Refuse oversized files BEFORE readFile. `fs.readFile` buffers the
|
||||
// whole file into memory, so an attacker (or a corrupted state) that
|
||||
// places a huge file in the backup dir could OOM the renderer during
|
||||
// listBackups enumeration. Stat-then-read keeps the failure mode to
|
||||
// a cheap rejection.
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.promises.stat(filePath);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to stat vault backup ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (stat.size > MAX_BACKUP_FILE_BYTES) {
|
||||
throw new VaultBackupTooLargeError(stat.size);
|
||||
}
|
||||
const raw = await fs.promises.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") {
|
||||
throw new Error(`Invalid vault backup record: ${filePath}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function listBackupRecords(dirPath) {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
const records = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith(BACKUP_FILE_PREFIX) || !entry.name.endsWith(BACKUP_FILE_EXT)) continue;
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
try {
|
||||
const record = await readBackupRecord(fullPath);
|
||||
records.push({ record, filePath: fullPath });
|
||||
} catch (error) {
|
||||
console.warn("[vaultBackupBridge] Failed to parse backup:", fullPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
records.sort((a, b) => {
|
||||
const aTime = Number(a.record.createdAt || 0);
|
||||
const bTime = Number(b.record.createdAt || 0);
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
// Stable, deterministic tiebreak when two backups share a millisecond
|
||||
// (rapid successive creates, clock quantization). Without this the
|
||||
// retention trimmer's "delete the oldest" pass is order-dependent and
|
||||
// can drop a different record across list() → prune() passes.
|
||||
const aId = String(a.record.id || '');
|
||||
const bId = String(b.record.id || '');
|
||||
return bId.localeCompare(aId);
|
||||
});
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// Delete old backups, trusting the caller-provided `records` list when
|
||||
// supplied to avoid a redundant directory scan. `createBackup` has just
|
||||
// scanned + written, so it passes its freshly-enumerated records through
|
||||
// here. External callers (retention-change UI, trim IPC) rescan.
|
||||
async function pruneBackupRecords(dirPath, maxCount, records = null) {
|
||||
const sanitizedMaxCount = sanitizeMaxCount(maxCount);
|
||||
const sourceRecords = records ?? (await listBackupRecords(dirPath));
|
||||
const toDelete = sourceRecords.slice(sanitizedMaxCount);
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const entry of toDelete) {
|
||||
try {
|
||||
await fs.promises.unlink(entry.filePath);
|
||||
deletedCount += 1;
|
||||
} catch (error) {
|
||||
console.warn("[vaultBackupBridge] Failed to delete old backup:", entry.filePath, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deletedCount,
|
||||
keptCount: Math.min(sourceRecords.length, sanitizedMaxCount),
|
||||
};
|
||||
}
|
||||
|
||||
function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
if (!app?.getPath) {
|
||||
throw new Error("Electron app is unavailable.");
|
||||
}
|
||||
|
||||
const getBackupDir = () => path.join(app.getPath("userData"), BACKUP_DIR_NAME);
|
||||
|
||||
// Serialize createBackup so two concurrent calls (version-change backup
|
||||
// running at startup + an explicit protective-before-restore triggered
|
||||
// by the user's click, etc.) observe each other's writes. Without this,
|
||||
// both observers would see an empty directory, compute the same
|
||||
// fingerprint, skip the dedupe, and write two identical files.
|
||||
let createBackupLock = Promise.resolve();
|
||||
// Monotonically increasing `createdAt` per service instance. `Date.now()`
|
||||
// has 1ms resolution and back-to-back async calls (version-change backup
|
||||
// followed immediately by a protective backup) can land in the same
|
||||
// millisecond, producing ties that `listBackupRecords` cannot resolve
|
||||
// (the sort has no tiebreaker). Bumping ensures strict ordering so
|
||||
// callers always see the true newest record first.
|
||||
let lastCreatedAt = 0;
|
||||
|
||||
return {
|
||||
isEncryptionAvailable() {
|
||||
return isSafeStorageAvailable(safeStorage);
|
||||
},
|
||||
|
||||
async createBackup(options = {}) {
|
||||
const next = createBackupLock.then(() => doCreateBackup(options));
|
||||
// Swallow the rejection on the lock chain so one caller's error
|
||||
// does not poison subsequent calls; each individual await sees its
|
||||
// own rejection via the `next` return.
|
||||
createBackupLock = next.catch(() => undefined);
|
||||
return next;
|
||||
},
|
||||
|
||||
async listBackups() {
|
||||
const records = await listBackupRecords(getBackupDir());
|
||||
return records.map(({ record }) => toBackupSummary(record));
|
||||
},
|
||||
|
||||
async readBackup(options = {}) {
|
||||
const backupId = typeof options.id === "string" ? options.id : "";
|
||||
if (!backupId) {
|
||||
throw new Error("Missing vault backup id.");
|
||||
}
|
||||
|
||||
const records = await listBackupRecords(getBackupDir());
|
||||
const match = records.find(({ record }) => record.id === backupId);
|
||||
if (!match) {
|
||||
throw new Error("Vault backup not found.");
|
||||
}
|
||||
|
||||
return {
|
||||
backup: toBackupSummary(match.record),
|
||||
payload: decodePayload(match.record, safeStorage),
|
||||
};
|
||||
},
|
||||
|
||||
async trimBackups(options = {}) {
|
||||
return pruneBackupRecords(getBackupDir(), options.maxCount);
|
||||
},
|
||||
|
||||
async openBackupDir() {
|
||||
const dirPath = getBackupDir();
|
||||
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
if (shell?.openPath) {
|
||||
const errorMessage = await shell.openPath(dirPath);
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
path: dirPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async function doCreateBackup(options) {
|
||||
const payload = options.payload;
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
throw new Error("Missing vault backup payload.");
|
||||
}
|
||||
|
||||
// Refuse early when the payload is too large to prevent a
|
||||
// misbehaving or compromised renderer from filling the disk. The
|
||||
// check runs before any side effect so callers see a deterministic
|
||||
// failure rather than a partial write.
|
||||
const estimatedSize = estimatePayloadSize(payload);
|
||||
if (estimatedSize > MAX_PAYLOAD_BYTES) {
|
||||
throw new VaultBackupTooLargeError(estimatedSize);
|
||||
}
|
||||
|
||||
// Refuse before doing anything side-effectful so callers get a clear
|
||||
// error rather than a silently-weakened plaintext backup.
|
||||
if (!isSafeStorageAvailable(safeStorage)) {
|
||||
throw new VaultBackupEncryptionUnavailableError();
|
||||
}
|
||||
|
||||
const dirPath = getBackupDir();
|
||||
const existingRecords = await listBackupRecords(dirPath);
|
||||
const fingerprint = computePayloadFingerprint(payload);
|
||||
const latest = existingRecords[0]?.record ?? null;
|
||||
if (latest?.fingerprint === fingerprint) {
|
||||
return {
|
||||
created: false,
|
||||
backup: toBackupSummary(latest),
|
||||
};
|
||||
}
|
||||
|
||||
let createdAt = Date.now();
|
||||
if (createdAt <= lastCreatedAt) createdAt = lastCreatedAt + 1;
|
||||
lastCreatedAt = createdAt;
|
||||
const id = crypto.randomUUID();
|
||||
const preview = buildPreview(payload);
|
||||
const encoded = encodePayload(payload, safeStorage);
|
||||
const record = {
|
||||
formatVersion: 1,
|
||||
id,
|
||||
createdAt,
|
||||
reason: sanitizeReason(options.reason),
|
||||
syncDataVersion: sanitizeOptionalSyncDataVersion(options.syncDataVersion),
|
||||
sourceAppVersion: sanitizeOptionalVersionString(options.sourceAppVersion),
|
||||
targetAppVersion: sanitizeOptionalVersionString(options.targetAppVersion),
|
||||
fingerprint,
|
||||
preview,
|
||||
payloadEncoding: encoded.encoding,
|
||||
payloadData: encoded.data,
|
||||
};
|
||||
|
||||
const filePath = path.join(
|
||||
dirPath,
|
||||
`${BACKUP_FILE_PREFIX}${createdAt}-${id}${BACKUP_FILE_EXT}`,
|
||||
);
|
||||
// Durable atomic write: serialize to a sibling tmp file, fsync the
|
||||
// file's data+metadata to stable storage, rename into place, then
|
||||
// fsync the directory entry itself. Without the file fsync a system
|
||||
// crash between writeFile and rename can leave the OS with a
|
||||
// successfully-renamed entry whose data blocks are still only in
|
||||
// page cache — the file is visible but reads back as zeros or torn
|
||||
// content. Without the directory fsync the rename itself may not be
|
||||
// durable: on recovery listBackups sees an empty directory even
|
||||
// though the file's blocks made it to disk. Both matter for the
|
||||
// protective-before-restore case, where the user is about to
|
||||
// overwrite their vault and the safety net MUST survive a crash
|
||||
// between backup and restore.
|
||||
const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
|
||||
let tmpHandle;
|
||||
try {
|
||||
tmpHandle = await fs.promises.open(tmpPath, 'w', 0o600);
|
||||
await tmpHandle.writeFile(`${JSON.stringify(record, null, 2)}\n`);
|
||||
await tmpHandle.sync();
|
||||
} finally {
|
||||
if (tmpHandle) {
|
||||
try {
|
||||
await tmpHandle.close();
|
||||
} catch {
|
||||
/* ignore — close failure after successful sync still leaves
|
||||
data durable on disk */
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.promises.rename(tmpPath, filePath);
|
||||
} catch (renameError) {
|
||||
// Best-effort cleanup; swallow unlink errors so the rename error
|
||||
// surfaces to the caller.
|
||||
try {
|
||||
await fs.promises.unlink(tmpPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw renameError;
|
||||
}
|
||||
// fsync the directory so the rename itself is durably recorded.
|
||||
// On Linux this is required; on macOS it is a no-op at the FS
|
||||
// layer but still safe and portable. On Windows fs.open on a
|
||||
// directory is not supported — the rename is durable as part of
|
||||
// NTFS's journal, so skip the sync there.
|
||||
if (process.platform !== 'win32') {
|
||||
let dirHandle;
|
||||
try {
|
||||
dirHandle = await fs.promises.open(dirPath, 'r');
|
||||
await dirHandle.sync();
|
||||
} catch (dirSyncError) {
|
||||
// Directory fsync is a defense-in-depth hardening step — if
|
||||
// the filesystem refuses (tmpfs, some network mounts) the
|
||||
// rename already happened and the file is reachable, so a
|
||||
// failure here should not abort the backup. Log so a
|
||||
// systematic issue is diagnosable.
|
||||
console.warn('[vaultBackupBridge] Directory fsync failed:', dirSyncError);
|
||||
} finally {
|
||||
if (dirHandle) {
|
||||
try {
|
||||
await dirHandle.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse the enumeration we already did for dedupe, prepending the
|
||||
// newly-written record so pruneBackupRecords can trim without
|
||||
// re-scanning the directory. Records are ordered newest-first.
|
||||
const nextRecords = [{ record, filePath }, ...existingRecords];
|
||||
await pruneBackupRecords(dirPath, options.maxCount, nextRecords);
|
||||
|
||||
return {
|
||||
created: true,
|
||||
backup: toBackupSummary(record),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain, electronModule) {
|
||||
const service = createVaultBackupService({
|
||||
app: electronModule?.app,
|
||||
safeStorage: electronModule?.safeStorage,
|
||||
shell: electronModule?.shell,
|
||||
});
|
||||
|
||||
const BrowserWindow = electronModule?.BrowserWindow;
|
||||
|
||||
// Broadcast a backup-changed event to every renderer so other windows
|
||||
// (notably the Settings window's backup list) can refresh without the
|
||||
// user manually navigating. Any successful create / trim path calls
|
||||
// this. Failures fall through silently — a dropped notification is
|
||||
// recoverable on the next manual refresh, while re-throwing here
|
||||
// would turn a harmless broadcast failure into a user-visible error.
|
||||
const broadcastBackupsChanged = () => {
|
||||
if (!BrowserWindow?.getAllWindows) return;
|
||||
try {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed?.()) continue;
|
||||
try {
|
||||
win.webContents?.send?.("netcatty:vaultBackups:changed");
|
||||
} catch (error) {
|
||||
console.warn("[vaultBackupBridge] Failed to notify window:", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[vaultBackupBridge] Broadcast failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.handle("netcatty:vaultBackups:capabilities", async () => {
|
||||
return { encryptionAvailable: service.isEncryptionAvailable() };
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:create", async (_event, payload) => {
|
||||
const result = await service.createBackup(payload || {});
|
||||
// Only broadcast when a new record was actually written; a
|
||||
// deduped (created=false) return means the on-disk state did not
|
||||
// change, so other windows already show the latest backup.
|
||||
if (result?.created) {
|
||||
broadcastBackupsChanged();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:list", async () => {
|
||||
return service.listBackups();
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:read", async (_event, payload) => {
|
||||
return service.readBackup(payload || {});
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:trim", async (_event, payload) => {
|
||||
const result = await service.trimBackups(payload || {});
|
||||
if (result?.deletedCount) {
|
||||
broadcastBackupsChanged();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:openDir", async () => {
|
||||
return service.openBackupDir();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BACKUP_DIR_NAME,
|
||||
BACKUP_FILE_EXT,
|
||||
BACKUP_FILE_PREFIX,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
VaultBackupEncryptionUnavailableError,
|
||||
VaultBackupTooLargeError,
|
||||
buildPreview,
|
||||
computePayloadFingerprint,
|
||||
createVaultBackupService,
|
||||
registerHandlers,
|
||||
};
|
||||
690
electron/bridges/vaultBackupBridge.test.cjs
Normal file
690
electron/bridges/vaultBackupBridge.test.cjs
Normal file
@@ -0,0 +1,690 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
BACKUP_DIR_NAME,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
VaultBackupEncryptionUnavailableError,
|
||||
VaultBackupTooLargeError,
|
||||
createVaultBackupService,
|
||||
} = require("./vaultBackupBridge.cjs");
|
||||
|
||||
function createTempRoot() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-vault-backup-"));
|
||||
}
|
||||
|
||||
// All tests default to encrypted=true because the bridge now refuses to
|
||||
// write plaintext backups (I1). Individual tests opt out to verify the
|
||||
// refusal path.
|
||||
function createService(rootDir, { encrypted = true } = {}) {
|
||||
const app = {
|
||||
getPath(key) {
|
||||
if (key !== "userData") throw new Error(`Unexpected path key: ${key}`);
|
||||
return rootDir;
|
||||
},
|
||||
};
|
||||
|
||||
const safeStorage = encrypted
|
||||
? {
|
||||
isEncryptionAvailable() {
|
||||
return true;
|
||||
},
|
||||
encryptString(value) {
|
||||
return Buffer.from(`enc:${value}`, "utf8");
|
||||
},
|
||||
decryptString(buffer) {
|
||||
const decoded = Buffer.from(buffer).toString("utf8");
|
||||
if (!decoded.startsWith("enc:")) throw new Error("Bad payload");
|
||||
return decoded.slice(4);
|
||||
},
|
||||
}
|
||||
: {
|
||||
isEncryptionAvailable() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
return createVaultBackupService({
|
||||
app,
|
||||
safeStorage,
|
||||
shell: {
|
||||
openPath: async () => "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function samplePayload(overrides = {}) {
|
||||
return {
|
||||
hosts: [
|
||||
{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod",
|
||||
username: "root",
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
},
|
||||
],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("vault backups round-trip and dedupe identical payloads", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const payload = samplePayload();
|
||||
|
||||
try {
|
||||
const first = await service.createBackup({
|
||||
payload,
|
||||
reason: "app_version_change",
|
||||
sourceAppVersion: "1.0.89",
|
||||
targetAppVersion: "1.0.90",
|
||||
maxCount: 5,
|
||||
});
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(first.backup.reason, "app_version_change");
|
||||
|
||||
const duplicate = await service.createBackup({
|
||||
payload: { ...payload, syncedAt: Date.now() + 1000 },
|
||||
reason: "before_restore",
|
||||
maxCount: 5,
|
||||
});
|
||||
assert.equal(duplicate.created, false);
|
||||
assert.equal(duplicate.backup.id, first.backup.id);
|
||||
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].preview.hostCount, 1);
|
||||
|
||||
const restored = await service.readBackup({ id: first.backup.id });
|
||||
assert.equal(restored.backup.id, first.backup.id);
|
||||
assert.equal(restored.payload.hosts[0].label, "prod");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("vault backups honor retention trimming and can use encrypted payload storage", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir, { encrypted: true });
|
||||
|
||||
try {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await service.createBackup({
|
||||
payload: {
|
||||
hosts: [{ id: `h${index}`, label: `host-${index}`, hostname: `host-${index}`, username: "root", port: 22, os: "linux", group: "", tags: [], protocol: "ssh" }],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: Date.now() + index,
|
||||
},
|
||||
reason: "before_restore",
|
||||
maxCount: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 2);
|
||||
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const fileNames = fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"));
|
||||
assert.equal(fileNames.length, 2);
|
||||
|
||||
const newest = listed[0];
|
||||
const restored = await service.readBackup({ id: newest.id });
|
||||
assert.equal(restored.payload.hosts[0].id, "h2");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// I1 — plaintext refusal when safeStorage is unavailable
|
||||
// ============================================================================
|
||||
|
||||
test("createBackup refuses when safeStorage is unavailable (I1)", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir, { encrypted: false });
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: samplePayload() }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof VaultBackupEncryptionUnavailableError);
|
||||
assert.equal(err.code, "VAULT_BACKUP_ENCRYPTION_UNAVAILABLE");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// Critical: nothing should have been written to disk. Earlier versions
|
||||
// silently wrote a plain-json-v1 record here, leaking plaintext
|
||||
// credentials (see review I1).
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const files = fs.existsSync(backupDir)
|
||||
? fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"))
|
||||
: [];
|
||||
assert.equal(files.length, 0);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("isEncryptionAvailable reports safeStorage state accurately", () => {
|
||||
const rootDir = createTempRoot();
|
||||
try {
|
||||
assert.equal(createService(rootDir, { encrypted: true }).isEncryptionAvailable(), true);
|
||||
assert.equal(createService(rootDir, { encrypted: false }).isEncryptionAvailable(), false);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Atomic writes and listBackups resilience
|
||||
// ============================================================================
|
||||
|
||||
test("listBackups ignores .tmp files left by an interrupted write", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
|
||||
// Simulate a crash mid-write: drop a dangling .tmp file matching the
|
||||
// backup naming convention but with the atomic-write suffix.
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const tmpPath = path.join(
|
||||
backupDir,
|
||||
`vault-backup-${Date.now()}-abc.json.tmp-deadbeef`,
|
||||
);
|
||||
fs.writeFileSync(tmpPath, "{ half written", { mode: 0o600 });
|
||||
|
||||
const listed = await service.listBackups();
|
||||
// The legitimate backup is still there; the .tmp file is ignored
|
||||
// because it does not end in ".json".
|
||||
assert.equal(listed.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("listBackups tolerates a corrupted backup file by skipping it", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const ok = await service.createBackup({ payload: samplePayload() });
|
||||
assert.ok(ok.created);
|
||||
|
||||
// Drop a syntactically-invalid backup alongside the real one.
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const bogusPath = path.join(backupDir, `vault-backup-${Date.now() + 1}-bad.json`);
|
||||
fs.writeFileSync(bogusPath, "{ this is not json", { mode: 0o600 });
|
||||
|
||||
// Must not throw — the bad file is logged-and-skipped.
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 1, "corrupted file should be skipped, valid remains");
|
||||
assert.equal(listed[0].id, ok.backup.id);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Legacy plain-json-v1 migration path
|
||||
// ============================================================================
|
||||
|
||||
test("readBackup can still read legacy plain-json-v1 records for migration", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
try {
|
||||
// Hand-craft a legacy record that would have been produced by the
|
||||
// pre-I1 code path. Users on that build must still be able to read
|
||||
// and migrate off of these files.
|
||||
const createdAt = Date.now();
|
||||
const id = "legacy-record-id";
|
||||
const payload = samplePayload();
|
||||
const record = {
|
||||
formatVersion: 1,
|
||||
id,
|
||||
createdAt,
|
||||
reason: "before_restore",
|
||||
fingerprint: "legacy",
|
||||
preview: {
|
||||
hostCount: 1,
|
||||
keyCount: 0,
|
||||
snippetCount: 0,
|
||||
identityCount: 0,
|
||||
portForwardingRuleCount: 0,
|
||||
},
|
||||
payloadEncoding: "plain-json-v1",
|
||||
payloadData: JSON.stringify(payload),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(backupDir, `vault-backup-${createdAt}-${id}.json`),
|
||||
JSON.stringify(record, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const restored = await service.readBackup({ id });
|
||||
assert.equal(restored.payload.hosts[0].id, "h1");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readBackup throws a clear error for unknown payloadEncoding", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
try {
|
||||
const record = {
|
||||
formatVersion: 1,
|
||||
id: "future-record",
|
||||
createdAt: Date.now(),
|
||||
reason: "before_restore",
|
||||
fingerprint: "future",
|
||||
preview: { hostCount: 0, keyCount: 0, snippetCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
|
||||
payloadEncoding: "future-algo-v9",
|
||||
payloadData: "unreadable",
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(backupDir, `vault-backup-${record.createdAt}-future.json`),
|
||||
JSON.stringify(record),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.readBackup({ id: "future-record" }),
|
||||
/Unsupported vault backup encoding/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Hash normalization (I8)
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Input validation (review Important #4)
|
||||
// ============================================================================
|
||||
|
||||
test("createBackup rejects a payload larger than MAX_PAYLOAD_BYTES", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// Build a payload whose JSON serialization exceeds the cap. A single
|
||||
// large string field is the cheapest way to push past the limit without
|
||||
// an actual 25MB in-memory blob per field.
|
||||
const giant = "x".repeat(MAX_PAYLOAD_BYTES + 1);
|
||||
const oversized = samplePayload({ __bloat: giant });
|
||||
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: oversized }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof VaultBackupTooLargeError);
|
||||
assert.equal(err.code, "VAULT_BACKUP_TOO_LARGE");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const files = fs.existsSync(backupDir)
|
||||
? fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"))
|
||||
: [];
|
||||
assert.equal(files.length, 0, "oversized payload must not land on disk");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup normalizes an out-of-range reason to 'before_restore'", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const first = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "__INJECTED__\r\nlog-spoofed",
|
||||
});
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(
|
||||
first.backup.reason,
|
||||
"before_restore",
|
||||
"unknown reason must fall back to the safe enum default",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup strips version strings with control chars or weird punctuation", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "app_version_change",
|
||||
sourceAppVersion: "1.0.0\nrm -rf /",
|
||||
targetAppVersion: " ",
|
||||
});
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backup.sourceAppVersion, undefined);
|
||||
assert.equal(result.backup.targetAppVersion, undefined);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup accepts a legitimate SemVer-ish version string", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "app_version_change",
|
||||
sourceAppVersion: "1.0.89",
|
||||
targetAppVersion: "2.0.0-rc.1",
|
||||
});
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backup.sourceAppVersion, "1.0.89");
|
||||
assert.equal(result.backup.targetAppVersion, "2.0.0-rc.1");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: [] }),
|
||||
/Missing vault backup payload/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("trimBackups clamps out-of-range maxCount instead of silently defaulting", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// Seed several backups.
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await service.createBackup({
|
||||
payload: samplePayload({ hosts: [{ id: `h${i}`, label: `h${i}`, hostname: `h${i}`, username: "u", port: 22, os: "linux", group: "", tags: [], protocol: "ssh" }] }),
|
||||
});
|
||||
}
|
||||
|
||||
// maxCount = 0 is out of range → clamped to DEFAULT (20), nothing deleted.
|
||||
const zeroResult = await service.trimBackups({ maxCount: 0 });
|
||||
assert.equal(zeroResult.deletedCount, 0);
|
||||
assert.equal((await service.listBackups()).length, 3);
|
||||
|
||||
// maxCount = 200 clamps to 100, no-op on a 3-entry set.
|
||||
const hugeResult = await service.trimBackups({ maxCount: 200 });
|
||||
assert.equal(hugeResult.deletedCount, 0);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Concurrency (review Important #5)
|
||||
// ============================================================================
|
||||
|
||||
test("concurrent createBackup calls with identical payloads dedupe via the mutex", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const payload = samplePayload();
|
||||
|
||||
try {
|
||||
// Fire N parallel requests with the same payload. Without the mutex,
|
||||
// each call would observe an empty directory in its own tick, skip
|
||||
// dedupe, and write a distinct file. With the mutex, the first call
|
||||
// writes and each subsequent call observes the previous write and
|
||||
// dedupes.
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () =>
|
||||
service.createBackup({ payload, reason: "before_restore" }),
|
||||
),
|
||||
);
|
||||
|
||||
const created = results.filter((r) => r.created);
|
||||
const deduped = results.filter((r) => !r.created);
|
||||
assert.equal(created.length, 1, "exactly one concurrent call should create a new backup");
|
||||
assert.equal(deduped.length, 4);
|
||||
// All results point at the same id — the first one's.
|
||||
const canonicalId = created[0].backup.id;
|
||||
for (const r of deduped) {
|
||||
assert.equal(r.backup.id, canonicalId);
|
||||
}
|
||||
|
||||
// Disk state confirms only one file landed.
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a failing createBackup does not poison the mutex for subsequent calls", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// First call rejects (invalid payload).
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: null }),
|
||||
/Missing vault backup payload/,
|
||||
);
|
||||
|
||||
// Next call must still succeed — the mutex chain kept moving.
|
||||
const ok = await service.createBackup({ payload: samplePayload() });
|
||||
assert.equal(ok.created, true);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fingerprint is stable when top-level syncedAt drifts", async () => {
|
||||
// The bridge zeros top-level syncedAt inside normalizePayloadForHash
|
||||
// so semantically-equal payloads dedupe. This guards the dedupe path
|
||||
// the createBackup test already covers, from the reverse direction.
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const base = samplePayload({ syncedAt: 0 });
|
||||
const first = await service.createBackup({ payload: { ...base, syncedAt: 1 } });
|
||||
const second = await service.createBackup({ payload: { ...base, syncedAt: 9_999_999 } });
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(second.created, false, "differs only by top-level syncedAt → dedupe");
|
||||
assert.equal(second.backup.id, first.backup.id);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fingerprint treats nested syncedAt as load-bearing (C1)", async () => {
|
||||
// The top-level `syncedAt` is zeroed so two payloads that differ only in
|
||||
// when-they-were-packaged still dedupe. But that zeroing must NOT cascade
|
||||
// into nested objects — a future schema where any child record carries
|
||||
// its own `syncedAt` could otherwise collide into a false dedupe, and
|
||||
// the version-change / protective backup would be silently skipped.
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const makeNested = (nestedSyncedAt) =>
|
||||
samplePayload({
|
||||
syncedAt: 0,
|
||||
hosts: [
|
||||
{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod",
|
||||
username: "root",
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
syncedAt: nestedSyncedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await service.createBackup({ payload: makeNested(111) });
|
||||
const second = await service.createBackup({ payload: makeNested(222) });
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(
|
||||
second.created,
|
||||
true,
|
||||
"nested syncedAt must NOT be zeroed — payloads are semantically different",
|
||||
);
|
||||
assert.notEqual(second.backup.id, first.backup.id);
|
||||
assert.notEqual(second.backup.fingerprint, first.backup.fingerprint);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readBackupRecord rejects oversized files before buffering them", async () => {
|
||||
// Write-path already caps at MAX_PAYLOAD_BYTES; this guards the READ
|
||||
// path against a pre-existing or externally-placed file larger than
|
||||
// the bound, which would otherwise be slurped into memory by
|
||||
// fs.readFile inside listBackups/readBackup and risk OOMing the
|
||||
// renderer. The cap is 2x the write cap to allow for the base64 +
|
||||
// JSON-envelope inflation of legitimate records.
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// Seed a legitimate backup so the directory exists and listBackups
|
||||
// has something to iterate past.
|
||||
const ok = await service.createBackup({ payload: samplePayload() });
|
||||
assert.ok(ok.created);
|
||||
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const hugePath = path.join(
|
||||
backupDir,
|
||||
`vault-backup-${Date.now() + 1}-huge.json`,
|
||||
);
|
||||
// MAX_PAYLOAD_BYTES * 2 = 50 MiB; we write one byte past that.
|
||||
const hugeSize = MAX_PAYLOAD_BYTES * 2 + 1;
|
||||
// Pre-allocate the file without actually writing 50 MiB of content:
|
||||
// `ftruncate` produces a sparse file of the requested size on every
|
||||
// supported filesystem, so the test stays fast and uses minimal disk.
|
||||
const fd = fs.openSync(hugePath, "w", 0o600);
|
||||
try {
|
||||
fs.ftruncateSync(fd, hugeSize);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
// listBackups now enumerates both files; the huge one should be
|
||||
// skipped with a warning (matching the corrupted-file behavior) and
|
||||
// the valid one must still come back.
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(
|
||||
listed.length,
|
||||
1,
|
||||
"oversized file should be skipped during enumeration",
|
||||
);
|
||||
assert.equal(listed[0].id, ok.backup.id);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1097,14 +1097,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 +1312,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 +1321,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;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,8 @@ const getCredentialBridge = createLazyModule("./bridges/credentialBridge.cjs");
|
||||
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.
|
||||
@@ -332,6 +334,12 @@ function focusMainWindow() {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Cancel any in-flight close-to-tray hide so second-instance / dock-click
|
||||
// re-entry beats a pending leave-full-screen → hide sequence.
|
||||
try {
|
||||
getGlobalShortcutBridge().clearPendingFullscreenHide?.(win);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (win.isMinimized && win.isMinimized()) win.restore();
|
||||
} catch {}
|
||||
@@ -408,6 +416,7 @@ const registerBridges = (win) => {
|
||||
const credentialBridge = getCredentialBridge();
|
||||
const autoUpdateBridge = getAutoUpdateBridge();
|
||||
const aiBridge = getAiBridge();
|
||||
const vaultBackupBridge = getVaultBackupBridge();
|
||||
|
||||
const getCloudSyncPasswordPath = () => {
|
||||
try {
|
||||
@@ -507,6 +516,7 @@ const registerBridges = (win) => {
|
||||
autoUpdateBridge.registerHandlers(ipcMain);
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
crashLogBridge.registerHandlers(ipcMain);
|
||||
vaultBackupBridge.registerHandlers(ipcMain, electronModule);
|
||||
|
||||
// ZMODEM cancel handler
|
||||
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
|
||||
@@ -674,6 +684,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 {
|
||||
@@ -1068,6 +1112,12 @@ if (!gotLock) {
|
||||
try {
|
||||
const mainWin = getWindowManager().getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
// If a close-to-tray hide is still pending (fullscreen exit animation
|
||||
// not finished yet), cancel it — user intent to bring the window
|
||||
// back overrides the pending hide.
|
||||
try {
|
||||
getGlobalShortcutBridge().clearPendingFullscreenHide?.(mainWin);
|
||||
} catch {}
|
||||
if (mainWin.isMinimized?.()) mainWin.restore();
|
||||
mainWin.show?.();
|
||||
mainWin.focus?.();
|
||||
|
||||
@@ -858,6 +858,39 @@ 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) =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:create", payload),
|
||||
listVaultBackups: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:list"),
|
||||
readVaultBackup: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:read", payload),
|
||||
trimVaultBackups: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:trim", payload),
|
||||
openVaultBackupDir: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:openDir"),
|
||||
// Subscribe to cross-window "backups changed" events emitted by the
|
||||
// main process whenever a create/trim actually mutated the on-disk
|
||||
// set. Returns an unsubscribe function so React-style consumers can
|
||||
// release the listener on unmount without leaking IPC handlers.
|
||||
onVaultBackupsChanged: (handler) => {
|
||||
if (typeof handler !== "function") return () => {};
|
||||
const listener = () => {
|
||||
try { handler(); } catch (error) {
|
||||
console.warn("[preload] onVaultBackupsChanged handler threw:", error);
|
||||
}
|
||||
};
|
||||
ipcRenderer.on("netcatty:vaultBackups:changed", listener);
|
||||
return () => {
|
||||
try { ipcRenderer.removeListener("netcatty:vaultBackups:changed", listener); }
|
||||
catch { /* ignore */ }
|
||||
};
|
||||
},
|
||||
|
||||
// Tell main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady: () => ipcRenderer.send("netcatty:renderer:ready"),
|
||||
@@ -1184,8 +1217,8 @@ const api = {
|
||||
aiResolveCli: async (params) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);
|
||||
},
|
||||
aiCodexGetIntegration: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:get-integration");
|
||||
aiCodexGetIntegration: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:get-integration", options);
|
||||
},
|
||||
aiCodexStartLogin: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:start-login");
|
||||
@@ -1230,6 +1263,15 @@ const api = {
|
||||
aiMcpSetToolIntegrationMode: async (mode) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:set-tool-integration-mode", { mode });
|
||||
},
|
||||
aiUserSkillsGetStatus: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:user-skills:status");
|
||||
},
|
||||
aiUserSkillsOpenFolder: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:user-skills:open");
|
||||
},
|
||||
aiUserSkillsBuildContext: async (prompt, selectedSkillSlugs) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:user-skills:build-context", { prompt, selectedSkillSlugs });
|
||||
},
|
||||
// MCP approval gate: renderer receives approval requests from main process
|
||||
onMcpApprovalRequest: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
@@ -1246,8 +1288,8 @@ const api = {
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
|
||||
},
|
||||
// ACP streaming
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession });
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext });
|
||||
},
|
||||
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
|
||||
|
||||
143
global.d.ts
vendored
143
global.d.ts
vendored
@@ -6,16 +6,13 @@ declare module "*.cjs" {
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// Extend HTMLInputElement to support webkitdirectory attribute
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
webkitdirectory?: string;
|
||||
}, HTMLInputElement>;
|
||||
}
|
||||
declare module 'react' {
|
||||
interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
|
||||
webkitdirectory?: string | boolean;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// Proxy configuration for SSH connections
|
||||
interface NetcattyProxyConfig {
|
||||
type: 'http' | 'socks5';
|
||||
@@ -515,6 +512,77 @@ 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;
|
||||
reason: 'app_version_change' | 'before_restore';
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
maxCount?: number;
|
||||
}): Promise<{
|
||||
created: boolean;
|
||||
backup: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: 'app_version_change' | 'before_restore';
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
} | null;
|
||||
}>;
|
||||
listVaultBackups?(): Promise<Array<{
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: 'app_version_change' | 'before_restore';
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
}>>;
|
||||
readVaultBackup?(payload: { id: string }): Promise<{
|
||||
backup: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: 'app_version_change' | 'before_restore';
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
};
|
||||
payload: import('./domain/sync').SyncPayload;
|
||||
}>;
|
||||
trimVaultBackups?(payload: { maxCount: number }): Promise<{ deletedCount: number; keptCount: number }>;
|
||||
openVaultBackupDir?(): Promise<{ success: boolean; path: string }>;
|
||||
// Subscribe to main-process-driven "vault backups changed" events.
|
||||
// Returns an unsubscribe callback. Undefined in non-Electron builds.
|
||||
onVaultBackupsChanged?(handler: () => void): () => void;
|
||||
|
||||
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady?(): void;
|
||||
@@ -732,11 +800,21 @@ declare global {
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
}>>;
|
||||
aiCodexGetIntegration?(): Promise<{
|
||||
state: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
|
||||
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean }): Promise<{
|
||||
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
customConfig?: {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
envKeyPresent: boolean;
|
||||
hasHardcodedApiKey: boolean;
|
||||
model: string | null;
|
||||
authHash: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
aiCodexStartLogin?(): Promise<{
|
||||
ok: boolean;
|
||||
@@ -795,11 +873,54 @@ declare global {
|
||||
connected: boolean;
|
||||
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
aiMcpSetToolIntegrationMode?(mode: 'mcp' | 'skills'): Promise<{ ok: boolean; error?: string }>;
|
||||
aiUserSkillsGetStatus?(): Promise<{
|
||||
ok: boolean;
|
||||
directoryPath?: string;
|
||||
readyCount?: number;
|
||||
warningCount?: number;
|
||||
skills?: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
directoryName: string;
|
||||
directoryPath: string;
|
||||
skillPath: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'ready' | 'warning';
|
||||
warnings: string[];
|
||||
}>;
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}>;
|
||||
aiUserSkillsOpenFolder?(): Promise<{
|
||||
ok: boolean;
|
||||
directoryPath?: string;
|
||||
readyCount?: number;
|
||||
warningCount?: number;
|
||||
skills?: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
directoryName: string;
|
||||
directoryPath: string;
|
||||
skillPath: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'ready' | 'warning';
|
||||
warnings: string[];
|
||||
}>;
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}>;
|
||||
aiUserSkillsBuildContext?(prompt: string, selectedSkillSlugs?: string[]): Promise<{
|
||||
ok: boolean;
|
||||
context?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }, userSkillsContext?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
|
||||
|
||||
@@ -48,6 +48,7 @@ interface AcpBridge {
|
||||
images?: FileAttachment[],
|
||||
toolIntegrationMode?: AIToolIntegrationMode,
|
||||
defaultTargetSession?: DefaultTargetSessionHint,
|
||||
userSkillsContext?: string,
|
||||
): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||
@@ -87,6 +88,7 @@ export async function runAcpAgentTurn(
|
||||
images?: FileAttachment[],
|
||||
toolIntegrationMode?: AIToolIntegrationMode,
|
||||
defaultTargetSession?: DefaultTargetSessionHint,
|
||||
userSkillsContext?: string,
|
||||
): Promise<void> {
|
||||
const acpBridge = bridge as unknown as AcpBridge;
|
||||
|
||||
@@ -161,6 +163,7 @@ export async function runAcpAgentTurn(
|
||||
images?.length ? images : undefined,
|
||||
toolIntegrationMode,
|
||||
defaultTargetSession,
|
||||
userSkillsContext,
|
||||
).then((result) => {
|
||||
if (result?.ok === false) {
|
||||
settle(() => {
|
||||
|
||||
@@ -14,10 +14,11 @@ export interface SystemPromptContext {
|
||||
}>;
|
||||
permissionMode: 'observer' | 'confirm' | 'autonomous';
|
||||
webSearchEnabled?: boolean;
|
||||
userSkillsContext?: string;
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(context: SystemPromptContext): string {
|
||||
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled } = context;
|
||||
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled, userSkillsContext } = context;
|
||||
|
||||
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
|
||||
const hostList = buildHostList(hosts);
|
||||
@@ -59,7 +60,8 @@ ${permissionRules}
|
||||
|
||||
10. **Network device sessions.** Sessions with \`protocol: serial\` (shell: raw) or \`deviceType: network\` (SSH-connected network equipment) are connected to network devices or embedded systems. They do NOT run a standard shell (bash/zsh/etc). Commands are sent as-is without shell wrapping. Do not use shell syntax (pipes, redirects, environment variables, subshells). Use the device's native CLI commands (e.g. Cisco IOS, Huawei VRP, Juniper JunOS). Exit codes are unavailable. Consider disabling pagination first (\`screen-length 0 temporary\` for Huawei, \`terminal length 0\` for Cisco). SFTP is not available for serial sessions.${webSearchEnabled ? `
|
||||
|
||||
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}`;
|
||||
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}
|
||||
${userSkillsContext ? `\n\n## User Skills\n\n${userSkillsContext}` : ''}`;
|
||||
}
|
||||
|
||||
function buildScopeDescription(
|
||||
|
||||
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;
|
||||
|
||||
@@ -67,3 +67,4 @@ export function getManagedAgentStoredPath(
|
||||
);
|
||||
return fallbackAgent?.command ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,27 @@ export interface ChatMessageAttachment {
|
||||
filePath?: string; // original filesystem path (for ACP agents to read directly)
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string;
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export interface AIDraft {
|
||||
text: string;
|
||||
agentId: string;
|
||||
attachments: UploadedFile[];
|
||||
selectedUserSkillSlugs: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type AIPanelView =
|
||||
| { mode: 'draft' }
|
||||
| { mode: 'session'; sessionId: string };
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user