Compare commits

..
Author SHA1 Message Date
陈大猫 8e550e9783 ci: retrigger after unrelated plugin manager UI flake 2026-10-29 20:21:46 +08:00
陈大猫 1662fb6922 ci: retrigger lint-and-test after stuck runner 2026-10-29 20:08:03 +08:00
陈大猫 73ff25a314 fix(sftp): resolve trusted local tar before cwd switch
Use the Windows System32 tar.exe for local availability checks and compression so an upload sibling tar.exe cannot hijack execution. Keep Unix tar lookup unchanged and update command assertions.

Residual P2: exclude patterns still interpret glob metacharacters in archive paths; glob escaping is intentionally deferred.
2026-10-29 19:46:53 +08:00
陈大猫 4abe993250 fix(sftp): exclude in-tree tar archive under GNU tar stdout 2026-10-29 19:42:59 +08:00
noon.li b98dce721d fix(sftp): keep non-ASCII local paths intact in Windows compressed upload
Windows tar.exe converts argv to the ANSI code page, turning non-ASCII
characters into '?'. Run tar with cwd instead of -C and stream the
archive through stdout instead of -f, so local paths never pass through
argv. Folders whose own name is non-ASCII fall back to per-file upload
on Windows.
2026-10-29 17:56:33 +08:00
326 changed files with 1983 additions and 16480 deletions

No files matched your search

-3
View File
@@ -50,9 +50,6 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
*/
function App() {
useEffect(() => {
performance.mark('netcatty:renderer:app-mounted');
}, []);
return (
<AppLocalStateProvider>
<MonitorRuntimeHost />
@@ -1,84 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleCreateLocalTerminalImpl } from './AppHandlers';
type CreatedLocal = {
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
localStartDir?: string;
shellType?: string;
};
function createCtx(overrides?: {
localShell?: string;
localShellArgs?: string[];
discoveredShells?: Array<{ id: string; name: string; command: string; args?: string[]; icon?: string }>;
}) {
const created: CreatedLocal[] = [];
const logs: Array<{ hostLabel: string }> = [];
const localShell = overrides?.localShell ?? '';
const localShellArgs = overrides?.localShellArgs ?? [];
const discoveredShells = overrides?.discoveredShells ?? [];
const getCtx = () => ({
addConnectionLog: (entry: { hostLabel: string }) => {
logs.push(entry);
},
classifyLocalShellType: () => 'unknown',
createLocalTerminal: (options: CreatedLocal) => {
created.push(options);
return 'session-1';
},
discoveredShells,
resolveShellSetting: (
value: string,
shells: Array<{ id: string; command: string; args?: string[] }>,
customArgs?: string[],
) => {
if (!value) return null;
const match = shells.find((shell) => shell.id === value);
if (match) {
return { command: match.command, args: customArgs?.length ? customArgs : match.args };
}
return { command: value, args: customArgs?.length ? customArgs : undefined };
},
systemInfoRef: { current: { username: 'qi', hostname: 'box' } },
terminalSettings: { localShell, localShellArgs },
});
return { getCtx, created, logs };
}
test('create local terminal resolves Settings custom absolute path when no shell arg is passed', () => {
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
const { getCtx, created } = createCtx({ localShell: custom });
handleCreateLocalTerminalImpl(getCtx as never);
assert.equal(created.length, 1);
assert.equal(created[0].shell, custom);
});
test('create local terminal ignores MouseEvent-like first args and still uses Settings shell (#4085)', () => {
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
const { getCtx, created } = createCtx({ localShell: custom });
// Vault header historically used onClick={handleCreateLocalTerminal}, so React
// passes the click event as the first argument.
const fakeClickEvent = { type: 'click', target: {}, preventDefault() {} };
handleCreateLocalTerminalImpl(getCtx as never, fakeClickEvent as never);
assert.equal(created.length, 1);
assert.equal(created[0].shell, custom);
});
test('create local terminal still honors an explicit shell object', () => {
const { getCtx, created } = createCtx({ localShell: 'pwsh' });
handleCreateLocalTerminalImpl(getCtx as never, {
command: String.raw`D:\portable\zsh\zsh.exe`,
args: ['-l'],
name: 'Portable Zsh',
icon: 'zsh',
});
assert.equal(created.length, 1);
assert.equal(created[0].shell, String.raw`D:\portable\zsh\zsh.exe`);
assert.deepEqual(created[0].shellArgs, ['-l']);
assert.equal(created[0].shellName, 'Portable Zsh');
assert.equal(created[0].shellIcon, 'zsh');
});
+7 -12
View File
@@ -1138,18 +1138,11 @@ export function handleCreateLocalTerminalImpl(
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{
const { username, hostname } = systemInfoRef.current;
// Vault/header buttons historically wired `onClick={handleCreateLocalTerminal}`.
// React then passes the MouseEvent as the first arg; treat only real shell
// objects (string command) as explicit so Settings localShell still applies (#4085).
const explicitShell = shell && typeof shell.command === 'string' ? shell : undefined;
const resolved = explicitShell
?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
const matchedShell = !explicitShell
? discoveredShells.find(s => s.id === terminalSettings.localShell)
: undefined;
const shellName = explicitShell?.name ?? matchedShell?.name;
const shellIcon = explicitShell?.icon ?? matchedShell?.icon;
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
const shellName = shell?.name ?? matchedShell?.name;
const shellIcon = shell?.icon ?? matchedShell?.icon;
const sessionId = createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
@@ -1279,14 +1272,16 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
}
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost } = getCtx();
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
{
if (hasMultipleProtocols(host)) {
setProtocolSelectHost(resolveEffectiveHost(host));
setIsQuickSwitcherOpen(false);
setQuickSearch('');
} else {
handleConnectToHost(host);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}
}
}
+2 -11
View File
@@ -139,21 +139,12 @@ test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
});
test('DialogsHost builds from local dialog state without vault runtime hooks', () => {
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
const source = hostSources['DialogsHost.tsx'];
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
// Quick switcher search is owned by QuickSwitcherOverlay; republishing the
// dialogs bag per keystroke re-rendered the whole shell.
assert.doesNotMatch(source, /quickSearch|getHostSearchMatch/);
});
test('QuickSwitcherOverlay owns the quick switcher query', () => {
const overlay = readFileSync(join(here, 'QuickSwitcherOverlay.tsx'), 'utf8');
assert.match(overlay, /useState\(''\)/);
const store = readFileSync(join(here, 'appLocalUiStore.ts'), 'utf8');
assert.doesNotMatch(store, /quickSearch/);
});
test('AppSideEffects may use runtime hooks; App must not', () => {
+8 -24
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { registerAppHandlers, stabilizeAppHandlers, type StableAppHandlerCache } from './appHandlersBridge';
import { registerAppHandlers } from './appHandlersBridge';
import { publishAppLocalUi } from './appLocalUiStore';
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { useAutoSync } from '../state/useAutoSync';
@@ -19,8 +19,6 @@ import {
} from '../state/connectionLogsStore';
import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { terminalReconnectRegistry } from '../state/terminalReconnectRegistry';
import { requestAgentSessionReconnect } from '../state/agentSessionReconnect';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
@@ -136,6 +134,7 @@ export function AppSideEffects() {
| { mode: 'create' }
| null
>(null);
const [quickSearch, setQuickSearch] = useState('');
// Protocol selection dialog state for QuickSwitcher
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections
@@ -1528,19 +1527,6 @@ export function AppSideEffects() {
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const reconnectSessionForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
return requestAgentSessionReconnect(sessionId, session?.status, terminalReconnectRegistry);
}, [sessions]);
const inspectSessionRecoveryForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
if (!session) return { ok: false, error: `Session "${sessionId}" was not found.` };
const live = terminalReconnectRegistry.inspect(sessionId);
return { ok: true, sessionId, status: session.status, waitingForInput: false,
reconnecting: false, reconnectAllowed: false, ...live };
}, [sessions]);
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1585,8 +1571,6 @@ export function AppSideEffects() {
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
reconnectSession: reconnectSessionForVaultAgent,
inspectSessionRecovery: inspectSessionRecoveryForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
@@ -1963,7 +1947,7 @@ export function AppSideEffects() {
const hasMultipleProtocols = useCallback((host: Host) => { return hasMultipleProtocolsImpl(() => ({ host, resolveEffectiveHost }), host); }, [resolveEffectiveHost]);
// Handle host connect with protocol selection (used by QuickSwitcher)
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
// Handle protocol selection from dialog
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => { return handleProtocolSelectImpl(() => ({ handleConnectToHost, port, protocol, protocolSelectHost, setProtocolSelectHost }), protocol, port); }, [protocolSelectHost, handleConnectToHost]);
@@ -2031,11 +2015,8 @@ export function AppSideEffects() {
// Hosts assemble domain bags from stores + this flat glue. Do NOT register
// catalog arrays or prepared vault/terminal/chrome/dialogs domain bags here.
// Handlers are registered behind stable per-key wrappers so session/host
// churn does not re-notify every Host (see stabilizeAppHandlers).
const appHandlerCacheRef = useRef<StableAppHandlerCache>(new Map());
useLayoutEffect(() => {
registerAppHandlers(stabilizeAppHandlers(appHandlerCacheRef.current, {
registerAppHandlers({
// Vault glue
handleAddKnownHost,
handleDeleteHost,
@@ -2095,13 +2076,15 @@ export function AppSideEffects() {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
handleCancelDeleteHost,
handleConfirmDeleteHost,
}));
});
publishAppLocalUi({
isQuickSwitcherOpen,
isCreateWorkspaceOpen,
addToWorkspaceDialog,
quickSearch,
protocolSelectHost,
navigateToSection,
deepLinkHostDraft,
@@ -2167,6 +2150,7 @@ export function AppSideEffects() {
isQuickSwitcherOpen,
isCreateWorkspaceOpen,
addToWorkspaceDialog,
quickSearch,
protocolSelectHost,
navigateToSection,
deepLinkHostDraft,
+54 -51
View File
@@ -46,7 +46,6 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
import { QuickSwitcherOverlay } from './QuickSwitcherOverlay';
import {
appViewDomainsEqual,
mergeAppViewDomains,
@@ -54,6 +53,9 @@ import {
} from './appViewDomains';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
const LazyCreateWorkspaceDialog = lazy(() =>
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
);
@@ -61,9 +63,6 @@ const LazyTextEditorTabView = lazy(() =>
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
);
// Module-level so TopTabs' memo comparator sees a stable callback.
const focusEditorWindowTab = (id: string) => { void focusEditorInWindow(id); };
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
const isVisible = useIsEditorTabActive(tabId);
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
@@ -243,10 +242,12 @@ function AppViewInner({ domains }: AppViewProps) {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
} = ctx;
const dismissTransientOverlays = useCallback(() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setIsCreateWorkspaceOpen(false);
setProtocolSelectHost(null);
setAddToWorkspaceDialog(null);
@@ -259,6 +260,7 @@ function AppViewInner({ domains }: AppViewProps) {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
]);
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
@@ -275,7 +277,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
@@ -504,16 +506,6 @@ function AppViewInner({ domains }: AppViewProps) {
await popOutEditorTab(id, host?.label);
}, [hostById]);
// Stable editor-tab callbacks so memoized TextEditorTabView/TextEditorPane
// skip re-rendering when unrelated AppView state changes.
const requestCloseEditorTabFromView = useCallback(
(id: string) => handleRequestCloseEditorTabRef.current(id),
[handleRequestCloseEditorTabRef],
);
const popOutEditorTabFromView = useCallback((id: string) => {
void handlePopOutEditorTab(id);
}, [handlePopOutEditorTab]);
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
if (!name.trim()) return;
@@ -591,7 +583,7 @@ function AppViewInner({ domains }: AppViewProps) {
onClosePluginViewTab={closePluginViewTab}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
onPopOutEditorTab={handlePopOutEditorTab}
onFocusEditorWindowTab={focusEditorWindowTab}
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
hostById={hostById}
/>
@@ -855,8 +847,8 @@ function AppViewInner({ domains }: AppViewProps) {
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={requestCloseEditorTabFromView}
onPopOut={popOutEditorTabFromView}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
/>
</Suspense>
</LazyLoadBoundary>
@@ -945,39 +937,50 @@ function AppViewInner({ domains }: AppViewProps) {
)}
{isQuickSwitcherOpen && (
<QuickSwitcherOverlay
hosts={hosts}
isOpen={isQuickSwitcherOpen}
sessions={sessions}
workspaces={workspaces}
showSftpTab={showSftpTab}
onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
handleEditHostFromOverlay(host);
}}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
paneMagnificationState={paneMagnificationState}
onMagnifyCurrentPane={handleMagnifyCurrentPane}
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
/>
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}>
<Suspense fallback={null}>
<LazyQuickSwitcher
isOpen={isQuickSwitcherOpen}
query={quickSearch}
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
handleEditHostFromOverlay(host);
}}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
paneMagnificationState={paneMagnificationState}
onMagnifyCurrentPane={handleMagnifyCurrentPane}
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
/>
</Suspense>
</LazyLoadBoundary>
)}
<RenameDraftDialog
-48
View File
@@ -1,48 +0,0 @@
import React, { Suspense, lazy, useMemo, useState } from 'react';
import type { QuickSwitcherProps } from '../../components/QuickSwitcher';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import { getHostSearchMatch } from '../../lib/searchMatcher';
import type { Host } from '../../types';
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
/** Hosts matching the quick switcher query, best match first. */
export function filterQuickSwitcherHosts(hosts: Host[], query: string): Host[] {
const term = query.trim();
if (!term) return hosts;
return hosts
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}
export type QuickSwitcherOverlayProps =
Omit<QuickSwitcherProps, 'query' | 'results' | 'onQueryChange'> & { hosts: Host[] };
/**
* Owns the quick switcher query. It used to live in AppSideEffects and travel
* through the app-local UI store, so every keystroke re-rendered the whole
* shell (AppSideEffects, every host island, AppShell, AppView and TopTabs).
* The overlay is only mounted while the switcher is open, so closing it resets
* the query just like the old `setQuickSearch('')` calls did.
*/
export function QuickSwitcherOverlay({ hosts, ...props }: QuickSwitcherOverlayProps) {
const [query, setQuery] = useState('');
const results = useMemo(() => filterQuickSwitcherHosts(hosts, query), [hosts, query]);
return (
<LazyLoadBoundary name="Quick switcher" resetKey={query}>
<Suspense fallback={null}>
<LazyQuickSwitcher {...props} query={query} results={results} onQueryChange={setQuery} />
</Suspense>
</LazyLoadBoundary>
);
}
-45
View File
@@ -1,45 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppHandlers,
registerAppHandlers,
stabilizeAppHandlers,
subscribeAppHandlers,
type StableAppHandlerCache,
} from './appHandlersBridge';
test('stabilized handlers keep their identity and call the latest implementation', () => {
const cache: StableAppHandlerCache = new Map();
const ref = { current: 1 };
const calls: string[] = [];
const first = stabilizeAppHandlers(cache, {
closeTabsBatch: (id: string) => calls.push(`v1:${id}`),
toggleSidePanelRef: ref,
});
const second = stabilizeAppHandlers(cache, {
closeTabsBatch: (id: string) => calls.push(`v2:${id}`),
toggleSidePanelRef: ref,
});
assert.equal(second.closeTabsBatch, first.closeTabsBatch);
assert.equal(second.toggleSidePanelRef, ref, 'non-function values pass through');
(first.closeTabsBatch as (id: string) => void)('a');
assert.deepEqual(calls, ['v2:a']);
});
test('the bridge does not notify Hosts when only handler implementations change', () => {
const cache: StableAppHandlerCache = new Map();
let notified = 0;
const unsubscribe = subscribeAppHandlers(() => { notified += 1; });
try {
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v1' }));
const afterFirst = notified;
// A session status change recreated the callback (it closes over hosts).
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v2' }));
assert.equal(notified, afterFirst);
assert.equal((getAppHandlers()!.handleDeleteHost as () => string)(), 'v2');
} finally {
unsubscribe();
registerAppHandlers(null);
}
});
-31
View File
@@ -51,37 +51,6 @@ class AppHandlersBridge {
const bridge = new AppHandlersBridge();
export type StableAppHandlerCache = Map<string, { latest: unknown; stable: unknown }>;
/**
* Give every function-valued handler a per-key wrapper with a stable identity
* that always calls the latest implementation; non-functions (refs) pass
* through. Many glue callbacks close over sessions/workspaces/hosts, so their
* identities changed on every session status change and host update (connect
* bursts, last-connected times), and the bridge re-notified every Host island,
* rebuilding all shell bags and re-rendering AppView/TopTabs each time. The
* handlers are actions invoked from events, never read during render.
*/
export function stabilizeAppHandlers(cache: StableAppHandlerCache, handlers: AppHandlers): AppHandlers {
const stableHandlers: AppHandlers = {};
for (const [key, value] of Object.entries(handlers)) {
if (typeof value !== 'function') {
stableHandlers[key] = value;
continue;
}
let entry = cache.get(key);
if (!entry) {
const created: { latest: unknown; stable: unknown } = { latest: value, stable: null };
created.stable = (...args: unknown[]) => (created.latest as (...callArgs: unknown[]) => unknown)(...args);
cache.set(key, created);
entry = created;
}
entry.latest = value;
stableHandlers[key] = entry.stable;
}
return stableHandlers;
}
export function registerAppHandlers(handlers: AppHandlers | null): void {
bridge.set(handlers);
}
+3
View File
@@ -24,6 +24,7 @@ export type AppLocalUiSnapshot = {
| { mode: 'append'; workspaceId: string }
| { mode: 'create' }
| null;
quickSearch: string;
protocolSelectHost: Host | null;
navigateToSection: VaultSection | null;
deepLinkHostDraft: Host | null;
@@ -42,6 +43,7 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
isQuickSwitcherOpen: false,
isCreateWorkspaceOpen: false,
addToWorkspaceDialog: null,
quickSearch: '',
protocolSelectHost: null,
navigateToSection: null,
deepLinkHostDraft: null,
@@ -74,6 +76,7 @@ class AppLocalUiStore {
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
&& prev.quickSearch === next.quickSearch
&& prev.protocolSelectHost === next.protocolSelectHost
&& prev.navigateToSection === next.navigateToSection
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
+29 -3
View File
@@ -1,6 +1,9 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getHostSearchMatch } from '../../../lib/searchMatcher';
import type { Host } from '../../../types';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellDomainSlice,
@@ -8,14 +11,16 @@ import {
} from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
const EMPTY_HOST_RESULTS: Host[] = [];
/**
* Dialogs island: local dialog/queue state from `appLocalUiStore`. The quick
* switcher owns its query and host results (QuickSwitcherOverlay), so typing
* there no longer republishes this bag.
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
* selective vault hosts subscription for quick-search results when open.
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
*/
export function DialogsHost() {
const local = useAppLocalUiStore();
const vault = useVaultSnapshot();
const editorTabs = useEditorTabChromeList();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
@@ -23,6 +28,22 @@ export function DialogsHost() {
getAppHandlers,
);
const quickResults = useMemo(() => {
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
const term = local.quickSearch.trim();
if (!term) return vault.hosts as Host[];
return (vault.hosts as Host[])
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
const dialogsDomain = useMemo(() => {
if (!handlers) return null;
return {
@@ -47,11 +68,14 @@ export function DialogsHost() {
passphraseQueue: local.passphraseQueue,
fidoPromptQueue: local.fidoPromptQueue,
protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
setProtocolSelectHost: handlers.setProtocolSelectHost,
setQuickSearch: handlers.setQuickSearch,
};
}, [
editorTabs,
@@ -64,6 +88,8 @@ export function DialogsHost() {
local.passphraseQueue,
local.fidoPromptQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
]);
const overlays = useMemo(() => {
@@ -14,22 +14,7 @@ function loadComponent(path: string, name: string, bindings: Record<string, unkn
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
});
// Extracting the function drops imports, so inject bare React hooks/APIs into the sandbox.
return vm.runInNewContext(`${code}; ${name}`, {
React,
useState: React.useState,
useEffect: React.useEffect,
useLayoutEffect: React.useLayoutEffect,
useMemo: React.useMemo,
useCallback: React.useCallback,
useRef: React.useRef,
useReducer: React.useReducer,
useContext: React.useContext,
memo: React.memo,
createElement: React.createElement,
Fragment: React.Fragment,
...bindings,
}) as React.ComponentType;
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
}
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
@@ -47,7 +32,6 @@ test('monitor controls update their runtime without rendering unrelated app sibl
};
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
const App = loadComponent('../../App.tsx', 'App', {
performance: { mark: () => {} },
useMonitorRuntime,
MonitorRuntimeHost,
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
@@ -1,39 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
// Guards for what the startup bundle (index.html entry) may pull in. Each of
// these used to land in the entry chunk that every window parses at launch.
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (path: string) => readFileSync(join(root, path), 'utf8');
const staticImports = (source: string) =>
[...source.matchAll(/^\s*import\s+(?!type\b)[^;]*?from\s+['"]([^'"]+)['"]/gm)].map((match) => match[1]);
test('only the English dictionary is bundled with the startup entry', () => {
const imports = staticImports(read('application/i18n/messages.ts'));
assert.deepEqual(imports.filter((path) => path.startsWith('./locales/')), ['./locales/en']);
});
test('startup modules do not statically import the AI runtime or SDK providers', () => {
const runtimeModules = /harness\/globalAgentRuntime|plugin\/trustedHostRuntime|plugin\/configuredModel|pluginAgent\/trustedAgentRuntime|sdk\/providers/;
for (const path of [
'application/state/aiStateSnapshots.ts',
'application/state/usePluginAIContextHost.ts',
'application/state/usePluginAgentHost.ts',
]) {
const offending = staticImports(read(path)).filter((specifier) => runtimeModules.test(specifier));
assert.deepEqual(offending, [], `${path} must load the AI runtime lazily`);
}
});
test("Vite's preload helper stays out of the vendor-ai chunk", () => {
// The entry imports the helper for its dynamic imports; if it is grouped
// with the AI SDK, the whole vendor-ai chunk is preloaded at startup.
assert.match(read('vite.config.ts'), /vite\/preload-helper'\)\) return 'preload-helper'/);
});
test('the QR decoder is loaded on demand', () => {
assert.ok(!staticImports(read('components/HostTotpFields.tsx')).includes('jsqr'));
});
+13 -20
View File
@@ -293,10 +293,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
terminalSettings,
});
// Sync tray menu data + handle tray actions. Hosts are only re-sent when the
// host list changed: session status updates (connect bursts) used to resend
// and re-serialize every host and rebuild the macOS Dock menu each time.
const lastTrayHostsRef = useRef<unknown>(null);
// Sync tray menu data + handle tray actions
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
@@ -319,21 +316,17 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
});
const hostsChanged = lastTrayHostsRef.current !== hosts;
lastTrayHostsRef.current = hosts;
const hostsForSystemMenu = hostsChanged
? hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}))
: undefined;
const hostsForSystemMenu = hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}));
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
@@ -341,7 +334,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
...rule,
canStop: hasRuntimeTunnel(rule.id),
})),
...(hostsForSystemMenu ? { hosts: hostsForSystemMenu } : {}),
hosts: hostsForSystemMenu,
});
}, 250);
@@ -21,20 +21,13 @@ Object.defineProperty(globalThis, 'localStorage', {
});
const {
applyLegacySyncPayload,
convergentConflictAddressKey,
createConvergentSyncStateFromPayload,
dotKey,
mergeConvergentSyncStates,
planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
stripConvergentSyncEnvelope,
} = await import('../domain/convergentSync/index.ts');
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
const {
initializePreparedConvergentMigration,
prepareConvergentSyncMigration,
resolvePreparedConvergentMigrationConflict,
} = await import('./convergentSyncMigration.ts');
function payload(): SyncPayload {
@@ -51,42 +44,6 @@ test.beforeEach(() => {
localStorageValues.clear();
});
test('preview resolution uses the manager device identity and only updates the prepared plan', () => {
const baseline: SyncPayload = { ...payload(), settings: { theme: 'system' } };
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const state = mergeConvergentSyncStates(
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'light' } }, 'left', NOW + 1),
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'dark' } }, 'right', NOW + 2),
);
const plan = refreshConvergentSyncMigrationPlan(planConvergentSyncMigration({
localPayload: baseline, localTrustedBaseline: null, providers: [], deviceId: 'local', now: NOW,
}), state);
const prepared = { plan, localSnapshot: baseline, providerBaselines: [] };
const conflict = plan.preview.conflicts[0];
const addressKey = convergentConflictAddressKey(conflict.address);
const selected = conflict.candidates.find((candidate) => candidate.value === 'light')!;
const manager = { getState: () => ({ deviceId: 'resolver' }) } as unknown as CloudSyncManager;
const resolved = resolvePreparedConvergentMigrationConflict(
prepared, addressKey, dotKey(selected.dot), manager, NOW + 3,
);
assert.equal(resolved.plan.preview.canInitialize, true);
assert.equal(resolved.plan.preview.conflictCount, 0);
assert.deepEqual(resolved.plan.preview.blockedReasons, []);
assert.equal(resolved.plan.payload?.settings?.theme, 'light');
assert.ok(resolved.plan.state?.vector.resolver);
assert.equal(resolved.localSnapshot, prepared.localSnapshot);
assert.equal(resolved.providerBaselines, prepared.providerBaselines);
assert.equal(prepared.plan.preview.conflictCount, 1);
assert.equal(prepared.plan.payload, null);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, 'missing', dotKey(selected.dot), manager,
), /conflict no longer exists/);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, addressKey, 'missing', manager,
), /candidate no longer exists/);
});
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
const remotePayload: SyncPayload = {
...payload(),
-22
View File
@@ -6,11 +6,8 @@ import type {
} from '../domain/sync';
import {
cloudSyncPayloadsEqual,
convergentConflictAddressKey,
materializeSyncPayloadFromConvergentState,
planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
resolveConvergentFieldConflict,
stripConvergentSyncEnvelope,
validateConvergentSyncPayload,
type ConvergentMigrationPlan,
@@ -28,25 +25,6 @@ export interface PreparedConvergentMigration {
localSnapshot: SyncPayload;
}
export function resolvePreparedConvergentMigrationConflict(
prepared: PreparedConvergentMigration,
addressKey: string,
candidateDot: string,
manager: CloudSyncManager = getCloudSyncManager(),
now = Date.now(),
): PreparedConvergentMigration {
const conflict = prepared.plan.preview.conflicts.find(
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
);
if (!prepared.plan.state || !conflict) {
throw new Error('The convergent migration conflict no longer exists');
}
const state = resolveConvergentFieldConflict(
prepared.plan.state, conflict, candidateDot, manager.getState().deviceId, now,
);
return { ...prepared, plan: refreshConvergentSyncMigrationPlan(prepared.plan, state) };
}
interface LegacyProviderBaselineSeed {
provider: CloudProvider;
remoteVersion: number;
+12 -46
View File
@@ -1,13 +1,6 @@
import React, { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
import React, { createContext, useContext, useMemo } from 'react';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
getBaseFallbackLocale,
getLoadedLocaleMessages,
getLocaleMessagesVersion,
loadLocaleMessages,
subscribeLocaleMessages,
type Messages,
} from './messages';
import { MESSAGES_BY_LOCALE } from './messages';
type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
@@ -36,59 +29,32 @@ const interpolate = (template: string, values?: InterpolationValues): string =>
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
};
type LocaleDictionaries = {
direct?: Messages;
base?: Messages;
fallback?: Messages;
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
const direct = MESSAGES_BY_LOCALE[resolvedLocale]?.[key];
if (direct) return direct;
const base = resolvedLocale.split('-')[0];
const baseKey = Object.keys(MESSAGES_BY_LOCALE).find((k) => k === base || k.startsWith(`${base}-`));
const baseHit = baseKey ? MESSAGES_BY_LOCALE[baseKey]?.[key] : undefined;
if (baseHit) return baseHit;
return MESSAGES_BY_LOCALE[DEFAULT_UI_LOCALE]?.[key];
};
/**
* Dictionaries consulted for `resolvedLocale`: exact locale, same-base sibling
* (zh-TW -> zh-CN), then English. `_loadedVersion` is only a cache key so a
* dictionary that finishes loading yields a fresh snapshot.
*/
const snapshotLocaleDictionaries = (resolvedLocale: string, _loadedVersion: number): LocaleDictionaries => {
const baseKey = getBaseFallbackLocale(resolvedLocale);
return {
direct: getLoadedLocaleMessages(resolvedLocale),
base: baseKey ? getLoadedLocaleMessages(baseKey) : undefined,
fallback: getLoadedLocaleMessages(DEFAULT_UI_LOCALE),
};
};
const resolveMessage = (dictionaries: LocaleDictionaries, key: string): string | undefined =>
dictionaries.direct?.[key] || dictionaries.base?.[key] || dictionaries.fallback?.[key];
export const I18nProvider: React.FC<{ locale: string; children: React.ReactNode }> = ({
locale,
children,
}) => {
const resolvedLocale = resolveSupportedLocale(locale || DEFAULT_UI_LOCALE);
// Non-English dictionaries load on demand (index.tsx preloads the saved one
// before the first render); re-render consumers once a switch finishes.
const messagesVersion = useSyncExternalStore(
subscribeLocaleMessages,
getLocaleMessagesVersion,
getLocaleMessagesVersion,
);
useEffect(() => {
void loadLocaleMessages(resolvedLocale).catch(() => {});
}, [resolvedLocale]);
const dictionaries = useMemo(
() => snapshotLocaleDictionaries(resolvedLocale, messagesVersion),
[resolvedLocale, messagesVersion],
);
const value = useMemo<I18nContextValue>(() => {
return {
locale,
resolvedLocale,
t: (key, values) => {
const msg = resolveMessage(dictionaries, key) ?? key;
const msg = resolveMessage(resolvedLocale, key) ?? key;
return interpolate(msg, values);
},
};
}, [dictionaries, locale, resolvedLocale]);
}, [locale, resolvedLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
};
-5
View File
@@ -99,7 +99,6 @@ export const enAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh and max are only sent when the selected model supports them; otherwise they are clamped to the deepest supported level.',
// AI Codex
'ai.codex': 'Codex',
@@ -661,10 +660,6 @@ export const enAiMessages: Messages = {
'ai.chatShortcuts.title': 'Chat Shortcuts',
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
'ai.chatHistory.title': 'Chat History',
'ai.chatHistory.messageCap': 'Messages kept per chat',
'ai.chatHistory.messageCap.description': 'Maximum number of messages stored per chat (50–2000). Lowering it drops the oldest messages from storage on the next write. Larger values increase storage and cloud-sync size.',
'ai.chatHistory.messageCap.unit': 'messages',
// 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.',
-38
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
"settings.plugins.github.install": "Install from GitHub",
"settings.plugins.github.description": "Download a published .ncpkg release, then review its source and permissions.",
"settings.plugins.github.repository": "GitHub repository",
"settings.plugins.github.formats": "Accepts https://github.com/owner/repo, github.com/owner/repo or owner/repo.",
"settings.plugins.github.busy": "Downloading, validating or installing the package...",
"settings.plugins.github.review": "Repository ownership and package integrity do not verify publisher trust. No build scripts run. The plugin installs disabled; review permission modes before enabling.",
"settings.plugins.github.accept": "I trust this repository and have reviewed the declared permissions.",
"settings.plugins.github.confirm": "Install disabled",
"settings.plugins.github.download": "Download and review",
"settings.plugins.github.error.invalidRepository": "Enter https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths).",
"settings.plugins.github.error.unavailable": "GitHub plugin installation is unavailable in this session.",
"settings.plugins.github.error.reviewFirst": "Download and review a GitHub plugin first.",
"settings.plugins.github.error.network": "Cannot reach GitHub. Check your network connection and try again.",
"settings.plugins.github.error.timeout": "The GitHub download timed out. Please try again.",
"settings.plugins.github.error.rateLimit": "GitHub API rate limit reached. Try again later.",
"settings.plugins.github.error.notFound": "GitHub repository or asset not found. Only accessible public repositories are supported; import private packages locally.",
"settings.plugins.github.error.accessDenied": "GitHub access denied. Private repositories and sign-in are not supported.",
"settings.plugins.github.error.http": "GitHub download failed (HTTP {status}). Please try again later.",
"settings.plugins.github.error.moved": "This GitHub repository has moved. Enter its current repository URL.",
"settings.plugins.github.error.redirect": "The GitHub download redirect could not be verified. Download a trusted .ncpkg package and import it locally.",
"settings.plugins.github.error.noRelease": "This repository has no published stable release. Ask the author to publish one with a .ncpkg asset, or import a built package locally.",
"settings.plugins.github.error.noAsset": "This release has no .ncpkg asset. Ask the author to build and attach one; Netcatty cannot build source repositories.",
"settings.plugins.github.error.multipleAssets": "This release contains multiple .ncpkg assets. Download the intended package and import it locally.",
"settings.plugins.github.error.metadata": "The GitHub release metadata is invalid. Ask the author to check the release, tag and .ncpkg asset.",
"settings.plugins.github.error.identity": "The package does not match the selected GitHub repository and release. Installation was stopped.",
"settings.plugins.github.error.commit": "The release tag has no accessible commit. Ask the author to repair the release tag.",
"settings.plugins.github.error.size": "The GitHub package size is invalid or exceeds the download limit.",
"settings.plugins.github.error.integrity": "The downloaded package failed its size or SHA-256 check. Download and review it again.",
"settings.plugins.github.error.package": "GitHub did not return a valid .ncpkg ZIP package or release metadata. Ask the author to check the release asset.",
"settings.plugins.github.error.busy": "A GitHub plugin operation is already active. Close the preview and try again.",
"settings.plugins.github.error.expired": "The GitHub package preview expired. Download and review it again.",
"settings.plugins.github.error.advanced": "This package requires advanced developer mode. Normal plugin management accepts browser-only packages.",
"settings.plugins.github.error.incompatible": "This plugin is incompatible with this version of Netcatty. Check the technical details with the author.",
"settings.plugins.github.error.source": "This plugin is already bound to another source. Uninstall it and explicitly clear its old source data before changing sources.",
"settings.plugins.github.error.failed": "The plugin could not be validated or installed. Check the technical details before trying again.",
"settings.plugins.github.error.details": "Technical details",
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
-20
View File
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.directory.open': 'Current directory: {path}. Change directory',
'terminal.directory.unknown': 'Directory unknown',
'terminal.directory.search': 'Search child directories...',
'terminal.directory.directories': 'Directories',
'terminal.directory.parent': '.. (Parent directory)',
'terminal.directory.loading': 'Reading directories...',
'terminal.directory.empty': 'No child directories',
'terminal.directory.noMatches': 'No matching directories',
'terminal.directory.hidden': 'Show hidden directories',
'terminal.directory.refresh': 'Refresh',
'terminal.directory.unavailable': 'Directory navigation is unavailable for this session.',
'terminal.directory.busy': 'Wait for an empty shell prompt.',
'terminal.directory.readFailed': 'Could not read this directory. Try refreshing.',
'terminal.directory.changeFailed': 'Could not confirm the directory change. Refresh before retrying.',
'terminal.directory.unsupportedPath': 'This directory name cannot be sent safely to the terminal.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Show the context toolbar below quick commands in the compose bar.',
'terminal.toolbar.openSessionLog': 'Open session log',
'terminal.toolbar.sessionLogActions': 'Session log actions',
'terminal.toolbar.sessionLog.menu': 'Session log',
@@ -478,7 +460,6 @@ export const enTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.folderWarning': 'This folder alone is not a complete, portable backup. Deleting or wiping the app profile (userData) may make these restore points unreadable.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
'cloudSync.localBackups.maxCount': 'Max backups',
@@ -493,7 +474,6 @@ export const enTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
'cloudSync.localBackups.decryptFailed': 'The current app profile cannot decrypt this backup. The encryption key may be missing, the profile may have changed, or the backup may be damaged.',
'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.',
-5
View File
@@ -90,7 +90,6 @@ export const esAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh y max solo se envían cuando el modelo elegido los admite; de lo contrario se ajustan al nivel más profundo compatible.',
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
'ai.providers.headers.name': 'Nombre de la cabecera',
@@ -661,10 +660,6 @@ export const esAiMessages: Messages = {
'ai.chatShortcuts.title': 'Atajos de chat',
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
'ai.chatHistory.title': 'Historial de chat',
'ai.chatHistory.messageCap': 'Mensajes conservados por chat',
'ai.chatHistory.messageCap.description': 'Número máximo de mensajes almacenados por chat (50–2000). Al reducirlo, los mensajes más antiguos se descartan del almacenamiento en la siguiente escritura. Los valores más altos aumentan el almacenamiento y el tamaño de la sincronización en la nube.',
'ai.chatHistory.messageCap.unit': 'mensajes',
// AI Error
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
-38
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.github.install": "Instalar desde GitHub",
"settings.plugins.github.description": "Descarga una versión .ncpkg publicada y revisa su origen y permisos.",
"settings.plugins.github.repository": "Repositorio de GitHub",
"settings.plugins.github.formats": "Acepta https://github.com/owner/repo, github.com/owner/repo u owner/repo.",
"settings.plugins.github.busy": "Descargando, validando o instalando el paquete...",
"settings.plugins.github.review": "El origen y la integridad no verifican la confianza en el autor. No se ejecutan scripts de compilación. Se instala desactivado; revisa los permisos antes de activarlo.",
"settings.plugins.github.accept": "Confío en este repositorio y he revisado los permisos declarados.",
"settings.plugins.github.confirm": "Instalar desactivado",
"settings.plugins.github.download": "Descargar y revisar",
"settings.plugins.github.error.invalidRepository": "Introduce https://github.com/owner/repo, github.com/owner/repo u owner/repo. Usa solo el repositorio, sin credenciales, parámetros, fragmentos ni rutas adicionales.",
"settings.plugins.github.error.unavailable": "La instalación de plugins desde GitHub no está disponible en esta sesión.",
"settings.plugins.github.error.reviewFirst": "Primero descarga y revisa un plugin de GitHub.",
"settings.plugins.github.error.network": "No se puede conectar con GitHub. Comprueba tu conexión e inténtalo de nuevo.",
"settings.plugins.github.error.timeout": "La descarga de GitHub agotó el tiempo de espera. Inténtalo de nuevo.",
"settings.plugins.github.error.rateLimit": "Se alcanzó el límite de solicitudes de la API de GitHub. Inténtalo más tarde.",
"settings.plugins.github.error.notFound": "No se encontró el repositorio o archivo de GitHub. Solo se admiten repositorios públicos accesibles; importa los paquetes privados desde un archivo local.",
"settings.plugins.github.error.accessDenied": "GitHub denegó el acceso. No se admiten repositorios privados ni inicio de sesión.",
"settings.plugins.github.error.http": "La descarga de GitHub falló (HTTP {status}). Inténtalo más tarde.",
"settings.plugins.github.error.moved": "Este repositorio de GitHub se ha movido. Introduce su dirección actual.",
"settings.plugins.github.error.redirect": "No se pudo verificar la redirección de GitHub. Descarga un paquete .ncpkg de confianza e impórtalo desde un archivo local.",
"settings.plugins.github.error.noRelease": "Este repositorio no tiene una versión estable publicada. Pide al autor que publique una con un archivo .ncpkg o importa un paquete compilado local.",
"settings.plugins.github.error.noAsset": "Esta versión no incluye un archivo .ncpkg. Pide al autor que lo compile y adjunte; Netcatty no puede compilar repositorios de código fuente.",
"settings.plugins.github.error.multipleAssets": "Esta versión contiene varios archivos .ncpkg. Descarga el paquete deseado e impórtalo desde un archivo local.",
"settings.plugins.github.error.metadata": "Los metadatos de la versión de GitHub no son válidos. Pide al autor que revise la versión, la etiqueta y el archivo .ncpkg.",
"settings.plugins.github.error.identity": "El paquete no coincide con el repositorio y la versión de GitHub seleccionados. Se detuvo la instalación.",
"settings.plugins.github.error.commit": "La etiqueta de la versión no tiene un commit accesible. Pide al autor que corrija la etiqueta.",
"settings.plugins.github.error.size": "El tamaño del paquete de GitHub no es válido o supera el límite de descarga.",
"settings.plugins.github.error.integrity": "El paquete descargado no superó la comprobación de tamaño o SHA-256. Descárgalo y revísalo de nuevo.",
"settings.plugins.github.error.package": "GitHub no devolvió un paquete ZIP .ncpkg o metadatos de versión válidos. Pide al autor que revise el archivo publicado.",
"settings.plugins.github.error.busy": "Ya hay una operación de plugin de GitHub en curso. Cierra la vista previa e inténtalo de nuevo.",
"settings.plugins.github.error.expired": "La vista previa del paquete de GitHub caducó. Descárgalo y revísalo de nuevo.",
"settings.plugins.github.error.advanced": "Este paquete requiere el modo de desarrollo avanzado. La gestión normal solo admite paquetes para navegador.",
"settings.plugins.github.error.incompatible": "Este plugin no es compatible con esta versión de Netcatty. Consulta los detalles técnicos con el autor.",
"settings.plugins.github.error.source": "Este plugin ya está vinculado a otro origen. Desinstálalo y borra explícitamente los datos del origen anterior antes de cambiarlo.",
"settings.plugins.github.error.failed": "No se pudo validar o instalar el plugin. Revisa los detalles técnicos antes de intentarlo de nuevo.",
"settings.plugins.github.error.details": "Detalles técnicos",
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
-20
View File
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.directory.open': 'Directorio actual: {path}. Cambiar directorio',
'terminal.directory.unknown': 'Directorio desconocido',
'terminal.directory.search': 'Buscar subdirectorios...',
'terminal.directory.directories': 'Directorios',
'terminal.directory.parent': '.. (Directorio padre)',
'terminal.directory.loading': 'Leyendo directorios...',
'terminal.directory.empty': 'Sin subdirectorios',
'terminal.directory.noMatches': 'Sin directorios coincidentes',
'terminal.directory.hidden': 'Mostrar directorios ocultos',
'terminal.directory.refresh': 'Actualizar',
'terminal.directory.unavailable': 'La navegación de directorios no está disponible para esta sesión.',
'terminal.directory.busy': 'Espera a que el prompt esté vacío.',
'terminal.directory.readFailed': 'No se pudo leer el directorio. Actualiza e inténtalo de nuevo.',
'terminal.directory.changeFailed': 'No se pudo confirmar el cambio. Actualiza antes de reintentar.',
'terminal.directory.unsupportedPath': 'No se puede enviar este nombre de directorio de forma segura.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Mostrar la barra de contexto debajo de los comandos rápidos.',
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
@@ -478,7 +460,6 @@ export const esTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
'cloudSync.localBackups.folderWarning': 'Esta carpeta por sí sola no es una copia de seguridad completa y portátil. Eliminar o vaciar el perfil de la aplicación (userData) puede impedir la lectura de estos puntos de restauración.',
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
'cloudSync.localBackups.maxCount': 'Copias máximas',
@@ -493,7 +474,6 @@ export const esTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
'cloudSync.localBackups.decryptFailed': 'El perfil actual de la aplicación no puede descifrar esta copia. Puede faltar la clave de cifrado, haber cambiado el perfil o estar dañada la copia.',
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getBaseFallbackLocale,
getLoadedLocaleMessages,
getLocaleMessagesVersion,
loadLocaleMessages,
subscribeLocaleMessages,
} from '../messages';
test('only English is loaded until another locale is requested', () => {
assert.ok(getLoadedLocaleMessages('en'));
assert.equal(getLoadedLocaleMessages('ru'), undefined);
});
test('zh-TW resolves through zh-CN, matching the old static lookup order', () => {
assert.equal(getBaseFallbackLocale('zh-TW'), 'zh-CN');
assert.equal(getBaseFallbackLocale('zh-CN'), 'zh-CN');
assert.equal(getBaseFallbackLocale('es'), 'es');
assert.equal(getBaseFallbackLocale('fr'), undefined);
});
test('loading a locale also loads its fallback and notifies subscribers once each', async () => {
const before = getLocaleMessagesVersion();
let notified = 0;
const unsubscribe = subscribeLocaleMessages(() => { notified += 1; });
await Promise.all([loadLocaleMessages('zh-TW'), loadLocaleMessages('zh-TW')]);
unsubscribe();
assert.ok(getLoadedLocaleMessages('zh-TW'));
assert.ok(getLoadedLocaleMessages('zh-CN'), 'base fallback is loaded with zh-TW');
assert.equal(notified, 2, 'one notification per newly loaded dictionary');
assert.equal(getLocaleMessagesVersion(), before + 2);
await loadLocaleMessages('zh-TW');
assert.equal(getLocaleMessagesVersion(), before + 2, 'already loaded locales are not reloaded');
});
@@ -1,30 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SUPPORTED_UI_LOCALES } from '../../../infrastructure/config/i18n';
import { getLoadedLocaleMessages, loadLocaleMessages } from '../messages';
import en from './en';
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
test('GitHub install copy and error interpolation are complete in every supported UI locale', async () => {
const keys = Object.keys(en).filter(key => key.startsWith('settings.plugins.github.'));
assert.ok(keys.length > 30);
for (const { id } of SUPPORTED_UI_LOCALES) {
await loadLocaleMessages(id);
const messages = getLoadedLocaleMessages(id)!;
for (const key of keys) {
assert.ok(messages[key]?.trim(), `${id}: ${key}`);
assert.deepEqual(messages[key].match(/\{\w+\}/g) ?? [], en[key].match(/\{\w+\}/g) ?? [], `${id}: ${key}`);
}
}
});
test('GitHub error presentation handles Electron wrappers, HTTP status and diagnostic fallback', () => {
const expired = describePluginGitHubInstallError("Error invoking remote method 'plugins:github:install': Error: GitHub package preview expired. Download and review it again");
assert.equal(expired.key, 'settings.plugins.github.error.expired');
assert.equal(expired.details, null);
assert.deepEqual(describePluginGitHubInstallError('GitHub download failed (HTTP 503)').values, { status: '503' });
const validator = 'manifest version must be a semantic version';
assert.equal(describePluginGitHubInstallError(validator).details, validator);
assert.equal(describePluginGitHubInstallError(validator).key, 'settings.plugins.github.error.failed');
assert.equal(describePluginGitHubInstallError('Plugin is incompatible: engine version').key, 'settings.plugins.github.error.incompatible');
});
-5
View File
@@ -71,7 +71,6 @@ export const ruAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh и max отправляются только если выбранная модель их поддерживает; иначе значение понижается до максимального поддерживаемого уровня.',
// AI Codex
'ai.codex': 'Codex',
@@ -600,10 +599,6 @@ export const ruAiMessages: Messages = {
'ai.chatShortcuts.title': 'Быстрые действия чата',
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
'ai.chatHistory.title': 'История чата',
'ai.chatHistory.messageCap': 'Сообщений, сохраняемых в чате',
'ai.chatHistory.messageCap.description': 'Максимальное число сообщений, хранимых для одного чата (50–2000). При уменьшении самые старые сообщения удаляются из хранилища при следующей записи. Большие значения увеличивают объём хранилища и синхронизации с облаком.',
'ai.chatHistory.messageCap.unit': 'сообщений',
// AI Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
-38
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const ruCoreMessages: Messages = {
"settings.plugins.github.install": "Установить из GitHub",
"settings.plugins.github.description": "Загрузите опубликованный выпуск .ncpkg и проверьте источник и разрешения.",
"settings.plugins.github.repository": "Репозиторий GitHub",
"settings.plugins.github.formats": "Поддерживаются https://github.com/owner/repo, github.com/owner/repo и owner/repo.",
"settings.plugins.github.busy": "Загрузка, проверка или установка пакета...",
"settings.plugins.github.review": "Источник и целостность пакета не подтверждают доверие к издателю. Скрипты сборки не запускаются. Плагин устанавливается выключенным; проверьте разрешения перед включением.",
"settings.plugins.github.accept": "Я доверяю этому репозиторию и проверил заявленные разрешения.",
"settings.plugins.github.confirm": "Установить выключенным",
"settings.plugins.github.download": "Загрузить и проверить",
"settings.plugins.github.error.invalidRepository": "Введите https://github.com/owner/repo, github.com/owner/repo или owner/repo. Укажите только репозиторий, без учётных данных, параметров, фрагментов и дополнительных путей.",
"settings.plugins.github.error.unavailable": "Установка плагинов из GitHub недоступна в этом сеансе.",
"settings.plugins.github.error.reviewFirst": "Сначала загрузите и проверьте плагин из GitHub.",
"settings.plugins.github.error.network": "Не удалось подключиться к GitHub. Проверьте соединение и повторите попытку.",
"settings.plugins.github.error.timeout": "Время ожидания загрузки из GitHub истекло. Повторите попытку.",
"settings.plugins.github.error.rateLimit": "Достигнут лимит запросов API GitHub. Повторите попытку позже.",
"settings.plugins.github.error.notFound": "Репозиторий или файл GitHub не найден. Поддерживаются только доступные публичные репозитории; импортируйте приватные пакеты из локального файла.",
"settings.plugins.github.error.accessDenied": "GitHub отказал в доступе. Приватные репозитории и вход в аккаунт не поддерживаются.",
"settings.plugins.github.error.http": "Ошибка загрузки из GitHub (HTTP {status}). Повторите попытку позже.",
"settings.plugins.github.error.moved": "Репозиторий GitHub перемещён. Введите его текущий адрес.",
"settings.plugins.github.error.redirect": "Не удалось проверить перенаправление загрузки GitHub. Загрузите доверенный пакет .ncpkg и импортируйте его из локального файла.",
"settings.plugins.github.error.noRelease": "В репозитории нет опубликованного стабильного выпуска. Попросите автора опубликовать выпуск с файлом .ncpkg или импортируйте готовый пакет из локального файла.",
"settings.plugins.github.error.noAsset": "В выпуске нет файла .ncpkg. Попросите автора собрать и прикрепить его; Netcatty не собирает репозитории исходного кода.",
"settings.plugins.github.error.multipleAssets": "В выпуске несколько файлов .ncpkg. Загрузите нужный пакет и импортируйте его из локального файла.",
"settings.plugins.github.error.metadata": "Некорректные метаданные выпуска GitHub. Попросите автора проверить выпуск, тег и файл .ncpkg.",
"settings.plugins.github.error.identity": "Пакет не соответствует выбранному репозиторию и выпуску GitHub. Установка остановлена.",
"settings.plugins.github.error.commit": "У тега выпуска нет доступного коммита. Попросите автора исправить тег.",
"settings.plugins.github.error.size": "Размер пакета GitHub некорректен или превышает лимит загрузки.",
"settings.plugins.github.error.integrity": "Загруженный пакет не прошёл проверку размера или SHA-256. Загрузите и проверьте его снова.",
"settings.plugins.github.error.package": "GitHub не вернул корректный ZIP-пакет .ncpkg или метаданные выпуска. Попросите автора проверить файл выпуска.",
"settings.plugins.github.error.busy": "Операция с плагином GitHub уже выполняется. Закройте предпросмотр и повторите попытку.",
"settings.plugins.github.error.expired": "Предпросмотр пакета GitHub устарел. Загрузите и проверьте его снова.",
"settings.plugins.github.error.advanced": "Для этого пакета нужен расширенный режим разработчика. Обычный менеджер принимает только браузерные пакеты.",
"settings.plugins.github.error.incompatible": "Плагин несовместим с этой версией Netcatty. Уточните технические подробности у автора.",
"settings.plugins.github.error.source": "Плагин уже привязан к другому источнику. Перед сменой источника удалите плагин и явно очистите данные прежнего источника.",
"settings.plugins.github.error.failed": "Не удалось проверить или установить плагин. Изучите технические подробности перед повторной попыткой.",
"settings.plugins.github.error.details": "Технические подробности",
// Common
'common.save': 'Сохранить',
'common.cancel': 'Отмена',
-20
View File
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.directory.open': 'Текущий каталог: {path}. Сменить каталог',
'terminal.directory.unknown': 'Каталог неизвестен',
'terminal.directory.search': 'Поиск подкаталогов...',
'terminal.directory.directories': 'Каталоги',
'terminal.directory.parent': '.. (Родительский каталог)',
'terminal.directory.loading': 'Чтение каталогов...',
'terminal.directory.empty': 'Нет подкаталогов',
'terminal.directory.noMatches': 'Нет подходящих каталогов',
'terminal.directory.hidden': 'Показывать скрытые каталоги',
'terminal.directory.refresh': 'Обновить',
'terminal.directory.unavailable': 'Навигация по каталогам недоступна для этого сеанса.',
'terminal.directory.busy': 'Дождитесь пустой командной строки.',
'terminal.directory.readFailed': 'Не удалось прочитать каталог. Попробуйте обновить.',
'terminal.directory.changeFailed': 'Не удалось подтвердить смену каталога. Обновите перед повтором.',
'terminal.directory.unsupportedPath': 'Это имя каталога нельзя безопасно передать в терминал.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Показывать панель контекста под быстрыми командами.',
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
@@ -496,7 +478,6 @@ export const ruTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
'cloudSync.localBackups.title': 'История локальных резервных копий',
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
'cloudSync.localBackups.folderWarning': 'Эта папка сама по себе не является полной переносимой резервной копией. Удаление или очистка профиля приложения (userData) может сделать эти точки восстановления нечитаемыми.',
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
'cloudSync.localBackups.maxCount': 'Макс. число копий',
@@ -511,7 +492,6 @@ export const ruTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
'cloudSync.localBackups.decryptFailed': 'Текущий профиль приложения не может расшифровать эту копию. Возможно, отсутствует ключ шифрования, профиль изменился или копия повреждена.',
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
-5
View File
@@ -99,7 +99,6 @@ export const zhCNAiMessages: Messages = {
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '提供商默认',
'ai.providers.advancedParams.reasoningEffort.help': '仅当所选模型支持时才会发送 xhigh 和 max,否则自动收敛到该模型支持的最高档位。',
// AI Codex
'ai.codex': 'Codex',
@@ -661,10 +660,6 @@ export const zhCNAiMessages: Messages = {
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
'ai.chatHistory.title': '聊天历史',
'ai.chatHistory.messageCap': '每个会话保留的消息数',
'ai.chatHistory.messageCap.description': '每个会话最多存储的消息条数(50–2000)。调低后会在下一次写入时从存储中丢弃最旧的消息。数值越大,存储占用和云同步体积也越大。',
'ai.chatHistory.messageCap.unit': '条消息',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
-38
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
"settings.plugins.github.install": "从 GitHub 安装",
"settings.plugins.github.description": "下载已发布的 .ncpkg 插件包,安装前请审查来源和所需权限。",
"settings.plugins.github.repository": "GitHub 仓库",
"settings.plugins.github.formats": "支持 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
"settings.plugins.github.busy": "正在下载、验证或安装插件包…",
"settings.plugins.github.review": "确认仓库来源并通过完整性校验,并不代表发布者值得信任。安装过程不会运行构建脚本,插件安装后默认禁用。启用前,请确认来源可信并检查各项权限的授权方式。",
"settings.plugins.github.accept": "已审查并信任此仓库,且已查看声明的权限。",
"settings.plugins.github.confirm": "安装并保持禁用",
"settings.plugins.github.download": "下载并审查",
"settings.plugins.github.error.invalidRepository": "请输入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。请只填写仓库地址,不要包含账号密码、查询参数、片段或额外路径。",
"settings.plugins.github.error.unavailable": "当前会话无法从 GitHub 安装插件。",
"settings.plugins.github.error.reviewFirst": "请先下载并审查 GitHub 插件。",
"settings.plugins.github.error.network": "无法连接 GitHub,请检查网络连接后重试。",
"settings.plugins.github.error.timeout": "从 GitHub 下载超时,请重试。",
"settings.plugins.github.error.rateLimit": "GitHub API 请求次数已达上限,请稍后重试。",
"settings.plugins.github.error.notFound": "未找到 GitHub 仓库或发布附件。仅支持可访问的公开仓库;私有插件包请从本地导入。",
"settings.plugins.github.error.accessDenied": "GitHub 拒绝访问。此功能不支持私有仓库或登录验证。",
"settings.plugins.github.error.http": "从 GitHub 下载失败(HTTP {status}),请稍后重试。",
"settings.plugins.github.error.moved": "此 GitHub 仓库已迁移,请输入当前仓库地址。",
"settings.plugins.github.error.redirect": "无法验证 GitHub 下载重定向。请下载可信的 .ncpkg 插件包,再从本地导入。",
"settings.plugins.github.error.noRelease": "此仓库尚未发布稳定版本。请作者发布包含 .ncpkg 附件的稳定版本,或从本地导入已构建的插件包。",
"settings.plugins.github.error.noAsset": "此版本没有 .ncpkg 附件。请作者构建并上传插件包;Netcatty 无法直接构建源码仓库。",
"settings.plugins.github.error.multipleAssets": "此版本包含多个 .ncpkg 附件。请下载需要的插件包,再从本地导入。",
"settings.plugins.github.error.metadata": "GitHub 发布信息无效。请作者检查版本发布、标签和 .ncpkg 附件。",
"settings.plugins.github.error.identity": "插件包与所选 GitHub 仓库或发布版本不匹配,已停止安装。",
"settings.plugins.github.error.commit": "无法访问发布标签对应的提交,请作者修复发布标签。",
"settings.plugins.github.error.size": "GitHub 插件包大小无效或超出下载限制。",
"settings.plugins.github.error.integrity": "下载的插件包未通过大小或 SHA-256 校验,请重新下载并审查。",
"settings.plugins.github.error.package": "GitHub 返回的内容不是有效的 .ncpkg ZIP 插件包或发布信息,请作者检查发布附件。",
"settings.plugins.github.error.busy": "已有 GitHub 插件操作正在进行,请关闭预览后重试。",
"settings.plugins.github.error.expired": "GitHub 插件包预览已失效,请重新下载并审查。",
"settings.plugins.github.error.advanced": "此插件包需要高级开发者模式。普通插件管理仅接受浏览器运行时插件包。",
"settings.plugins.github.error.incompatible": "此插件与当前 Netcatty 版本不兼容,请联系作者确认技术详情。",
"settings.plugins.github.error.source": "此插件已绑定其他来源。更换来源前,请先卸载插件并明确清除原来源的私有数据。",
"settings.plugins.github.error.failed": "无法验证或安装插件,请查看技术详情后再重试。",
"settings.plugins.github.error.details": "技术详情",
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.directory.open': '当前目录:{path}。更改目录',
'terminal.directory.unknown': '目录未知',
'terminal.directory.search': '搜索当前目录下的文件夹…',
'terminal.directory.directories': '目录',
'terminal.directory.parent': '..(父目录)',
'terminal.directory.loading': '正在读取目录…',
'terminal.directory.empty': '没有子目录',
'terminal.directory.noMatches': '没有匹配的子目录',
'terminal.directory.hidden': '显示隐藏目录',
'terminal.directory.refresh': '刷新',
'terminal.directory.unavailable': '此会话暂不支持目录导航。',
'terminal.directory.busy': '请等待终端回到空命令提示符。',
'terminal.directory.readFailed': '无法读取此目录,请刷新重试。',
'terminal.directory.changeFailed': '未能确认目录切换,请刷新后重试。',
'terminal.directory.unsupportedPath': '此目录名称暂不支持快捷切换。',
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具栏',
'settings.terminal.behavior.contextChips.desc': '在撰写栏的快捷命令下方显示上下文工具栏。',
'terminal.toolbar.openSessionLog': '打开会话日志',
'terminal.toolbar.sessionLogActions': '会话日志操作',
'terminal.toolbar.sessionLog.menu': '会话日志',
-2
View File
@@ -888,7 +888,6 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.folderWarning': '仅复制此文件夹并不构成完整、可迁移的备份。删除或清空应用配置目录(userData)可能导致这些恢复点无法读取。',
'cloudSync.localBackups.retentionTitle': '备份保留数量',
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
'cloudSync.localBackups.maxCount': '最多保留',
@@ -903,7 +902,6 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
'cloudSync.localBackups.decryptFailed': '当前应用配置无法解密此备份。可能是加密密钥丢失、应用配置已更改,或备份已损坏。',
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
-5
View File
@@ -90,7 +90,6 @@ export const zhTWAiMessages: Messages = {
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '供應商預設',
'ai.providers.advancedParams.reasoningEffort.help': '僅當所選模型支援時才會送出 xhigh 與 max,否則自動收斂到該模型支援的最高檔位。',
// AI Codex
'ai.codex': 'Codex',
@@ -651,10 +650,6 @@ export const zhTWAiMessages: Messages = {
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
'ai.chatHistory.title': '聊天歷史',
'ai.chatHistory.messageCap': '每個會話保留的訊息數',
'ai.chatHistory.messageCap.description': '每個會話最多儲存的訊息數(50–2000)。調低後會在下一次寫入時從儲存中捨棄最舊的訊息。數值越大,儲存空間與雲端同步體積也越大。',
'ai.chatHistory.messageCap.unit': '則訊息',
// AI Error
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
-38
View File
@@ -1,44 +1,6 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
"settings.plugins.github.install": "從 GitHub 安裝",
"settings.plugins.github.description": "下載已發布的 .ncpkg 外掛套件,安裝前請審查來源與所需權限。",
"settings.plugins.github.repository": "GitHub 儲存庫",
"settings.plugins.github.formats": "支援 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
"settings.plugins.github.busy": "正在下載、驗證或安裝套件…",
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置腳本,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
"settings.plugins.github.accept": "已審查並信任此儲存庫,且已查看宣告的權限。",
"settings.plugins.github.confirm": "安裝並保持停用",
"settings.plugins.github.download": "下載並審查",
"settings.plugins.github.error.invalidRepository": "請輸入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。請只填寫儲存庫位址,不要包含帳號密碼、查詢參數、片段或額外路徑。",
"settings.plugins.github.error.unavailable": "目前的工作階段無法從 GitHub 安裝外掛。",
"settings.plugins.github.error.reviewFirst": "請先下載並審查 GitHub 外掛。",
"settings.plugins.github.error.network": "無法連線至 GitHub,請檢查網路連線後重試。",
"settings.plugins.github.error.timeout": "從 GitHub 下載逾時,請重試。",
"settings.plugins.github.error.rateLimit": "GitHub API 請求次數已達上限,請稍後重試。",
"settings.plugins.github.error.notFound": "找不到 GitHub 儲存庫或發布附件。僅支援可存取的公開儲存庫;私有外掛套件請從本機匯入。",
"settings.plugins.github.error.accessDenied": "GitHub 拒絕存取。此功能不支援私有儲存庫或登入驗證。",
"settings.plugins.github.error.http": "從 GitHub 下載失敗(HTTP {status}),請稍後重試。",
"settings.plugins.github.error.moved": "此 GitHub 儲存庫已遷移,請輸入目前的儲存庫位址。",
"settings.plugins.github.error.redirect": "無法驗證 GitHub 下載重新導向。請下載可信的 .ncpkg 外掛套件,再從本機匯入。",
"settings.plugins.github.error.noRelease": "此儲存庫尚未發布穩定版本。請作者發布包含 .ncpkg 附件的穩定版本,或從本機匯入已建置的外掛套件。",
"settings.plugins.github.error.noAsset": "此版本沒有 .ncpkg 附件。請作者建置並上傳外掛套件;Netcatty 無法直接建置原始碼儲存庫。",
"settings.plugins.github.error.multipleAssets": "此版本包含多個 .ncpkg 附件。請下載需要的外掛套件,再從本機匯入。",
"settings.plugins.github.error.metadata": "GitHub 發布資訊無效。請作者檢查版本發布、標籤及 .ncpkg 附件。",
"settings.plugins.github.error.identity": "外掛套件與所選 GitHub 儲存庫或發布版本不符,已停止安裝。",
"settings.plugins.github.error.commit": "無法存取發布標籤對應的提交,請作者修復發布標籤。",
"settings.plugins.github.error.size": "GitHub 外掛套件大小無效或超出下載限制。",
"settings.plugins.github.error.integrity": "下載的外掛套件未通過大小或 SHA-256 驗證,請重新下載並審查。",
"settings.plugins.github.error.package": "GitHub 傳回的內容不是有效的 .ncpkg ZIP 外掛套件或發布資訊,請作者檢查發布附件。",
"settings.plugins.github.error.busy": "已有 GitHub 外掛操作正在進行,請關閉預覽後重試。",
"settings.plugins.github.error.expired": "GitHub 外掛套件預覽已失效,請重新下載並審查。",
"settings.plugins.github.error.advanced": "此外掛套件需要進階開發者模式。一般外掛管理僅接受瀏覽器執行環境的套件。",
"settings.plugins.github.error.incompatible": "此外掛與目前的 Netcatty 版本不相容,請聯絡作者確認技術詳細資料。",
"settings.plugins.github.error.source": "此外掛已綁定其他來源。更換來源前,請先解除安裝並明確清除原來源的私有資料。",
"settings.plugins.github.error.failed": "無法驗證或安裝外掛,請查看技術詳細資料後再重試。",
"settings.plugins.github.error.details": "技術詳細資料",
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
@@ -1,24 +1,6 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.directory.open': '目前目錄:{path}。變更目錄',
'terminal.directory.unknown': '目錄未知',
'terminal.directory.search': '搜尋目前目錄下的資料夾…',
'terminal.directory.directories': '目錄',
'terminal.directory.parent': '..(父目錄)',
'terminal.directory.loading': '正在讀取目錄…',
'terminal.directory.empty': '沒有子目錄',
'terminal.directory.noMatches': '沒有符合的子目錄',
'terminal.directory.hidden': '顯示隱藏目錄',
'terminal.directory.refresh': '重新整理',
'terminal.directory.unavailable': '此工作階段暫不支援目錄導覽。',
'terminal.directory.busy': '請等待終端回到空白命令提示字元。',
'terminal.directory.readFailed': '無法讀取此目錄,請重新整理後重試。',
'terminal.directory.changeFailed': '未能確認目錄切換,請重新整理後重試。',
'terminal.directory.unsupportedPath': '此目錄名稱暫不支援快速切換。',
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具列',
'settings.terminal.behavior.contextChips.desc': '在撰寫列的快捷命令下方顯示上下文工具列。',
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
-2
View File
@@ -888,7 +888,6 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
'cloudSync.localBackups.title': '本機備份歷史',
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
'cloudSync.localBackups.folderWarning': '僅複製此資料夾並不構成完整、可攜的備份。刪除或清空應用程式設定目錄(userData)可能導致這些還原點無法讀取。',
'cloudSync.localBackups.retentionTitle': '備份保留數量',
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
'cloudSync.localBackups.maxCount': '最多保留',
@@ -903,7 +902,6 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
'cloudSync.localBackups.decryptFailed': '目前的應用程式設定無法解密此備份。可能是加密金鑰遺失、應用程式設定已變更,或備份已損毀。',
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
+11 -72
View File
@@ -1,78 +1,17 @@
import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
import ru from './locales/ru';
import es from './locales/es';
// Keep keys stable; add new locales by adding a loader below and an entry in
// SUPPORTED_UI_LOCALES (infrastructure/config/i18n.ts).
// Keep keys stable; add new locales by adding another import and map entry.
export { type Messages };
/**
* Lookup order for the "same base language" fallback in I18nProvider (for
* example zh-TW falls back to zh-CN, the first `zh*` entry). Matches the key
* order of the old static MESSAGES_BY_LOCALE map.
*/
export const LOCALE_MESSAGE_KEYS = ['en', 'ru', 'es', 'zh-CN', 'zh-TW'] as const;
/**
* Only English is bundled with the startup chunk; other locales are loaded on
* demand. All five dictionaries used to be parsed by every window at startup
* (~1.3 MB of the entry chunk) even though one locale is in use.
*/
const LOCALE_LOADERS: Record<string, () => Promise<{ default: Messages }>> = {
ru: () => import('./locales/ru'),
es: () => import('./locales/es'),
'zh-CN': () => import('./locales/zh-CN'),
'zh-TW': () => import('./locales/zh-TW'),
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
es,
'zh-CN': zhCN,
'zh-TW': zhTW,
};
const loadedMessages: Record<string, Messages> = { en };
const pendingLoads = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
let messagesVersion = 0;
export function getLoadedLocaleMessages(locale: string): Messages | undefined {
return loadedMessages[locale];
}
export function getLocaleMessagesVersion(): number {
return messagesVersion;
}
export function subscribeLocaleMessages(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** The same-base fallback locale that I18nProvider consults for `locale`. */
export function getBaseFallbackLocale(locale: string): string | undefined {
const base = locale.split('-')[0];
return LOCALE_MESSAGE_KEYS.find((key) => key === base || key.startsWith(`${base}-`));
}
function loadOne(locale: string): Promise<void> {
if (loadedMessages[locale]) return Promise.resolve();
const loader = LOCALE_LOADERS[locale];
if (!loader) return Promise.resolve();
let pending = pendingLoads.get(locale);
if (!pending) {
pending = loader()
.then((module) => {
loadedMessages[locale] = module.default;
messagesVersion += 1;
for (const listener of listeners) listener();
})
.finally(() => {
pendingLoads.delete(locale);
});
pendingLoads.set(locale, pending);
}
return pending;
}
/** Load the dictionaries `locale` resolves through (itself and its base fallback). */
export function loadLocaleMessages(locale: string): Promise<void> {
const needed = new Set([locale]);
const fallback = getBaseFallbackLocale(locale);
if (fallback) needed.add(fallback);
return Promise.all([...needed].map(loadOne)).then(() => undefined);
}
@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestAgentSessionReconnect } from './agentSessionReconnect';
test('reconnect preserves identity and only dispatches disconnected eligible sessions', () => {
let calls = 0;
const registry = { isActive: () => false, requestIfAvailable: () => { calls++; return true; } };
assert.equal(requestAgentSessionReconnect('s', undefined, registry).ok, false);
assert.equal(requestAgentSessionReconnect('s', 'connected', registry).status, 'connected');
assert.equal(requestAgentSessionReconnect('s', 'connecting', registry).started, false);
const result = requestAgentSessionReconnect('s', 'disconnected', registry);
assert.deepEqual(result, { ok: true, started: true, sessionId: 's', status: 'reconnecting', shellReady: false });
assert.equal(calls, 1);
});
test('auto/manual reconnect and unavailable terminal cannot queue another recovery', () => {
const registry = { isActive: () => true, requestIfAvailable: () => { throw Error('must not dispatch'); } };
assert.equal(requestAgentSessionReconnect('s', 'disconnected', registry).started, false);
assert.equal(requestAgentSessionReconnect('s', 'disconnected', { isActive: () => false, requestIfAvailable: () => false }).code, 'RECONNECT_UNAVAILABLE');
});
@@ -1,21 +0,0 @@
type ReconnectRegistry = {
isActive: (sessionId: string) => boolean;
requestIfAvailable: (sessionId: string) => boolean;
};
/** Synchronous dispatch only: no queued recovery after scope/terminal disposal. */
export function requestAgentSessionReconnect(
sessionId: string,
status: string | undefined,
registry: ReconnectRegistry,
): Record<string, unknown> {
if (!status) return { ok: false, started: false, error: `Session "${sessionId}" was not found.` };
if (status === 'connected') return { ok: true, started: false, sessionId, status: 'connected', shellReady: false };
if (status === 'connecting' || registry.isActive(sessionId)) {
return { ok: true, started: false, sessionId, status: 'reconnecting' };
}
if (status !== 'disconnected' || !registry.requestIfAvailable(sessionId)) {
return { ok: false, started: false, sessionId, code: 'RECONNECT_UNAVAILABLE', error: 'Reconnect is unavailable: terminal may be unmounted, waiting for authentication, or recovering. Inspect session state and the Netcatty UI.' };
}
return { ok: true, started: true, sessionId, status: 'reconnecting', shellReady: false };
}
@@ -1,285 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
import {
AI_SESSIONS_META_LAST_WRITE_KEY,
AI_SESSIONS_META_MIGRATION_KEY,
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
idbClearSessions,
idbDeleteSessionIds,
idbGetAllSessions,
idbGetMeta,
idbPutMeta,
idbPutSession,
idbPutSessions,
isIndexedDbAvailable,
loadOrMigrateAiSessions,
mirrorActiveSessionMapToIdb,
openAiSessionsIdb,
readActiveSessionMapFromIdb,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
function makeSession(id: string, updatedAt: number): AISession {
return {
id,
title: id,
agentId: 'agent',
scope: { type: 'terminal', targetId: 't' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as AISession;
}
test('module wrapper roundtrips sessions through the object stores', async () => {
const idb = installFakeIndexedDb();
try {
assert.equal(isIndexedDbAvailable(), true);
const db = await openAiSessionsIdb();
assert.equal(db.objectStoreNames.contains('sessions'), true);
assert.equal(db.objectStoreNames.contains('meta'), true);
await idbPutSessions(db, [makeSession('chat-a', 2), makeSession('chat-b', 1)]);
await idbPutSession(db, { ...makeSession('chat-a', 3), title: 'updated' });
const all = await idbGetAllSessions(db);
assert.deepEqual(all.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
assert.equal(all.find((s) => s.id === 'chat-a')?.title, 'updated');
await idbDeleteSessionIds(db, ['chat-a']);
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['chat-b']);
await idbClearSessions(db);
assert.deepEqual(await idbGetAllSessions(db), []);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('meta store roundtrips schema and active-map records', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, 1);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, 1234);
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY), 1234);
assert.equal(await idbGetMeta<number>(db, 'missing'), undefined);
await mirrorActiveSessionMapToIdb({ 'terminal:t': 'chat-a' });
// Mirror uses its own connection.
assert.deepEqual(await readActiveSessionMapFromIdb(), { 'terminal:t': 'chat-a' });
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions reads IndexedDB first and returns its sessions', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutSessions(db, [makeSession('idb-chat', 1)]);
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy-chat', 2)]));
assert.equal(result.source, 'indexeddb');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions.map((s) => s.id), ['idb-chat']);
// Legacy key content was not consumed for a migration when IDB has data.
assert.equal(idb.db.dumpStore('sessions').length, 1);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions migrates a populated legacy store into empty IndexedDB, keeping the key untouched', async () => {
const idb = installFakeIndexedDb();
try {
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
let readCount = 0;
const result = await loadOrMigrateAiSessions(() => {
readCount += 1;
return legacyRaw;
});
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions.map((s) => s.id).sort(), ['legacy-1', 'legacy-2']);
// Now in IndexedDB (one record per session).
assert.deepEqual(
idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort(),
['legacy-1', 'legacy-2'],
);
// Meta records written for the migration.
const db = await openAiSessionsIdb();
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
assert.ok(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY));
assert.ok(readCount >= 1);
// The legacy raw string itself is only read, never rewritten or removed
// — the callback is the only interface to the legacy store.
assert.equal(readCount >= 1, true);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions returns none for an empty store without legacy data', async () => {
const idb = installFakeIndexedDb();
try {
const result = await loadOrMigrateAiSessions(() => null);
assert.equal(result.source, 'none');
assert.deepEqual(result.sessions, []);
assert.equal(result.idbFailed, false);
assert.equal(result.authoritativeEmpty, false, 'a newly created store still permits disk recovery');
assert.equal(result.migrationPending, false);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions treats a legacy empty array as an intentional wipe (no migration, no restore source)', async () => {
const idb = installFakeIndexedDb();
try {
const result = await loadOrMigrateAiSessions(() => '[]');
assert.equal(result.source, 'none');
assert.deepEqual(result.sessions, []);
assert.equal(idb.db.dumpStore('sessions').length, 0);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is corrupt', async () => {
const idb = installFakeIndexedDb({ failOpen: true });
try {
const legacy = [makeSession('legacy-chat', 1)];
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, true);
assert.deepEqual(result.sessions, legacy);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is unavailable', async () => {
const previous = (globalThis as { indexedDB?: unknown }).indexedDB;
(globalThis as { indexedDB?: unknown }).indexedDB = undefined;
try {
const legacy = [makeSession('legacy-chat', 1)];
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
assert.equal(isIndexedDbAvailable(), false);
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions, legacy);
} finally {
(globalThis as { indexedDB?: unknown }).indexedDB = previous;
resetAiSessionsIdbForTests();
}
});
// ── Issue #4125 review P1②: the migration is one-shot via a persisted marker ──
test('a completed migration is one-shot: a chat deleted afterwards stays deleted on the next boot', async () => {
const idb = installFakeIndexedDb();
try {
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
const first = await loadOrMigrateAiSessions(() => legacyRaw);
assert.equal(first.source, 'legacy-localstorage');
// The marker was persisted after the migration data became durable.
let db = await openAiSessionsIdb();
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
// The user deletes a chat; the legacy key stays behind by design.
await idbDeleteSessionIds(db, ['legacy-1']);
// Next boot: the marker is present, so the legacy key is never
// re-migrated and the deletion is not resurrected.
const second = await loadOrMigrateAiSessions(() => legacyRaw);
assert.equal(second.source, 'indexeddb');
assert.deepEqual(second.sessions.map((s) => s.id), ['legacy-2']);
assert.equal((await openAiSessionsIdb()).objectStoreNames.contains('sessions'), true);
db = await openAiSessionsIdb();
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['legacy-2']);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('an empty IndexedDB store with the migration marker ignores a populated legacy key', async () => {
const idb = installFakeIndexedDb();
try {
// First boot with nothing anywhere records the marker.
const first = await loadOrMigrateAiSessions(() => null);
assert.equal(first.source, 'none');
assert.ok(await idbGetMeta(await openAiSessionsIdb(), AI_SESSIONS_META_MIGRATION_KEY));
// The user deleted every chat, but the stale legacy key is still there.
const second = await loadOrMigrateAiSessions(
() => JSON.stringify([makeSession('resurrected', 1)]),
);
assert.equal(second.source, 'none');
assert.equal(second.authoritativeEmpty, true);
assert.deepEqual(second.sessions, []);
assert.equal(idb.db.dumpStore('sessions').length, 0);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('partial disk migration retries missing IDs beside existing IDB records until all are durable', async () => {
const idb = installFakeIndexedDb();
try {
const primary = makeSession('primary', 3);
const diskA = makeSession('disk-a', 2);
const diskB = makeSession('disk-b', 1);
const diskIds = [diskA.id, diskB.id];
const db = await openAiSessionsIdb();
const first = await loadOrMigrateAiSessions(() => JSON.stringify([primary]), diskIds);
assert.equal(first.migrationPending, true);
assert.deepEqual(await idbGetAllSessions(db), [primary]);
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
const updated = { ...primary, title: 'edited in IndexedDB', updatedAt: 4 };
await idbPutSession(db, updated);
const partial = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA]), diskIds);
assert.equal(partial.migrationPending, true, 'one recovered disk ID does not complete migration');
assert.deepEqual(partial.sessions, [updated, diskA]);
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
const complete = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA, diskB]), diskIds);
assert.equal(complete.source, 'indexeddb');
assert.equal(complete.migrationPending, false);
assert.deepEqual(complete.sessions, [updated, diskA, diskB]);
assert.deepEqual(await idbGetAllSessions(db), [updated, diskA, diskB]);
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
await idbClearSessions(db);
const deleted = await loadOrMigrateAiSessions(() => {
assert.fail('completed migration must not retry the stale overflow markers');
}, diskIds);
assert.equal(deleted.authoritativeEmpty, true);
assert.deepEqual(deleted.sessions, []);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('a pre-marker store that already holds sessions records the marker on boot', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutSessions(db, [makeSession('already-here', 1)]);
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy', 2)]));
assert.equal(result.source, 'indexeddb');
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(idb.db.dumpStore('sessions').length, 1);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
-372
View File
@@ -1,372 +0,0 @@
import type { AISession } from '../../infrastructure/ai/types';
/**
* IndexedDB persistence for AI chat sessions (issue #4123).
*
* The legacy store kept every session as one JSON blob in localStorage under
* a 2 MB byte budget. Each write re-serialized the whole store, and when the
* budget was exceeded the degradation ladder dropped whole oldest sessions —
* silently destroying capped chats (~1 MB each) on every write.
*
* IndexedDB removes both halves of that failure mode: records are written
* per session (no global re-serialization), and there is no shared byte
* quota to defend, so byte-driven eviction disappears entirely.
*
* Layout:
* - db `netcatty_ai_sessions`
* - object store `sessions` (keyPath `id`): one record per AISession
* - object store `meta` (keyPath `key`): schemaVersion, lastWriteAt and the
* migrated `netcatty_ai_active_session_map_v1` active-pointer map
*
* The legacy localStorage key `netcatty_ai_sessions_v1` is kept UNTOUCHED for
* one release as a read-only fallback; it is no longer written or pruned.
*/
export const AI_SESSIONS_IDB_NAME = 'netcatty_ai_sessions';
export const AI_SESSIONS_IDB_VERSION = 1;
export const AI_SESSIONS_IDB_SESSION_STORE = 'sessions';
export const AI_SESSIONS_IDB_META_STORE = 'meta';
export const AI_SESSIONS_META_SCHEMA_VERSION_KEY = 'schemaVersion';
export const AI_SESSIONS_META_LAST_WRITE_KEY = 'lastWriteAt';
export const AI_SESSIONS_META_ACTIVE_MAP_KEY = 'activeSessionMap';
export const AI_SESSIONS_META_MIGRATION_KEY = 'migrationCompleted';
/** Cross-window change notification (replaces the localStorage `storage` event for sessions). */
const AI_SESSIONS_IDB_CHANGE_CHANNEL = 'netcatty_ai_sessions_changed';
export function isIndexedDbAvailable(): boolean {
return typeof indexedDB !== 'undefined' && indexedDB !== null;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
});
}
let openPromise: Promise<IDBDatabase> | null = null;
function upgradeDatabase(db: IDBDatabase) {
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_SESSION_STORE)) {
db.createObjectStore(AI_SESSIONS_IDB_SESSION_STORE, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_META_STORE)) {
db.createObjectStore(AI_SESSIONS_IDB_META_STORE, { keyPath: 'key' });
}
}
export function openAiSessionsIdb(): Promise<IDBDatabase> {
if (!isIndexedDbAvailable()) {
return Promise.reject(new Error('IndexedDB is not available'));
}
if (!openPromise) {
openPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(AI_SESSIONS_IDB_NAME, AI_SESSIONS_IDB_VERSION);
request.onupgradeneeded = () => upgradeDatabase(request.result);
request.onsuccess = () => {
const db = request.result;
// Drop the cached handle so a later open() re-establishes it instead
// of handing out a closed connection.
db.onclose = () => { openPromise = null; };
db.onversionchange = () => {
db.close();
openPromise = null;
};
resolve(db);
};
request.onerror = () => {
openPromise = null;
reject(request.error ?? new Error('Failed to open IndexedDB'));
};
request.onblocked = () => {
openPromise = null;
};
});
}
return openPromise;
}
export function resetAiSessionsIdbForTests() {
openPromise = null;
}
export async function idbGetAllSessions(db?: IDBDatabase): Promise<AISession[]> {
const database = db ?? await openAiSessionsIdb();
const transaction = database.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readonly');
const result = await requestToPromise(
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).getAll() as IDBRequest<AISession[]>,
);
return Array.isArray(result) ? result : [];
}
export async function idbPutSessions(db: IDBDatabase, sessions: AISession[]): Promise<void> {
if (sessions.length === 0) return;
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
for (const session of sessions) store.put(session);
await transactionDone(transaction);
}
export async function idbPutSession(db: IDBDatabase, session: AISession): Promise<void> {
await idbPutSessions(db, [session]);
}
export async function idbDeleteSessionIds(db: IDBDatabase, ids: string[]): Promise<void> {
if (ids.length === 0) return;
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
for (const id of ids) store.delete(id);
await transactionDone(transaction);
}
export async function idbClearSessions(db: IDBDatabase): Promise<void> {
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).clear();
await transactionDone(transaction);
}
export async function idbGetMeta<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readonly');
const record = await requestToPromise(
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).get(key) as IDBRequest<{ key: string; value: T } | undefined>,
);
return record?.value;
}
export async function idbPutMeta<T>(db: IDBDatabase, key: string, value: T): Promise<void> {
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readwrite');
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).put({ key, value });
await transactionDone(transaction);
}
// ── Cross-window change notification ──
let changeChannel: BroadcastChannel | null = null;
function getChangeChannel(): BroadcastChannel | null {
if (typeof BroadcastChannel === 'undefined') return null;
try {
if (!changeChannel) {
changeChannel = new BroadcastChannel(AI_SESSIONS_IDB_CHANGE_CHANNEL);
// The channel must never keep the process alive (Node exposes unref).
(changeChannel as unknown as { unref?: () => void }).unref?.();
}
return changeChannel;
} catch {
return null;
}
}
/** Announce a session-store change to OTHER windows (never the sender). */
export function publishAiSessionsIdbChange(): void {
try {
getChangeChannel()?.postMessage(Date.now());
} catch {
// Notification is best-effort; the write itself already succeeded.
}
}
export function subscribeAiSessionsIdbChange(listener: () => void): () => void {
const channel = getChangeChannel();
if (!channel) return () => {};
const handler = () => listener();
channel.addEventListener('message', handler);
return () => channel.removeEventListener('message', handler);
}
// ── Boot load + one-release legacy migration ──
export interface AiSessionsBootLoadResult {
sessions: AISession[];
/** Where the returned sessions came from. */
source: 'indexeddb' | 'legacy-localstorage' | 'none';
/** True when IndexedDB itself could not be opened (corrupt/blocked). */
idbFailed: boolean;
/** The store was already initialized before this boot; empty means deleted. */
authoritativeEmpty: boolean;
/** Marked disk-only records are still missing; keep their backup generations. */
migrationPending: boolean;
}
/**
* Boot-time store resolution, shared with tests:
* 1. IndexedDB `getAll()` — the primary store.
* 2. If empty, migrate the legacy localStorage store into IndexedDB (the
* legacy key itself stays untouched as a read-only fallback for one
* release).
* 3. If IndexedDB is corrupt/unavailable, fall back to legacy localStorage.
*
* The migration is one-shot (issue #4125 review P1②): a `migrationCompleted`
* marker is persisted in the meta store after the first successful boot
* resolution. Once present, the legacy key is never consulted again —
* otherwise a boot whose migration ran while the legacy key could not be
* removed would re-migrate the stale legacy list and resurrect deleted
* chats. The marker also distinguishes "intentionally empty" (the user
* deleted every chat) from "uninitialized".
*
* Callers decide what to do when both sources are empty (the #4018 backup
* generations remain the last restore step in useAIState).
* The legacy reader may await disk-only recovery before migration completes.
* Missing disk-only IDs keep migration resumable, including in a populated IDB.
*/
export async function loadOrMigrateAiSessions(
readLegacyRaw: () => string | null | undefined | Promise<string | null | undefined>,
diskOnlySessionIds: readonly string[] = [],
): Promise<AiSessionsBootLoadResult> {
const hasUnresolvedDiskSessions = (sessions: AISession[]) => {
const ids = new Set(sessions.map(session => session.id));
return diskOnlySessionIds.some(id => !ids.has(id));
};
const legacyFallback = async (idbFailed: boolean): Promise<AiSessionsBootLoadResult> => {
const legacy = parseLegacySessions(await readLegacyRaw());
return {
sessions: legacy,
source: legacy.length > 0 ? 'legacy-localstorage' : 'none',
idbFailed,
authoritativeEmpty: false,
migrationPending: hasUnresolvedDiskSessions(legacy),
};
};
if (!isIndexedDbAvailable()) {
return legacyFallback(false);
}
let db: IDBDatabase;
try {
db = await openAiSessionsIdb();
} catch (error) {
console.warn('[AIState] IndexedDB unavailable, falling back to the legacy localStorage store:', error);
return legacyFallback(true);
}
let sessions: AISession[];
try {
sessions = await idbGetAllSessions(db);
} catch (error) {
console.warn('[AIState] IndexedDB read failed, falling back to the legacy localStorage store:', error);
return legacyFallback(true);
}
let marker: AiSessionsMigrationMarker | undefined;
try {
marker = await idbGetMeta<AiSessionsMigrationMarker>(db, AI_SESSIONS_META_MIGRATION_KEY);
} catch {
marker = undefined;
}
if (marker || (sessions.length > 0 && !hasUnresolvedDiskSessions(sessions))) {
if (!marker) {
// First boot on a store that already holds sessions (e.g. migrated by
// a build without the marker): record one-shot completion so the
// legacy key can never re-seed the store.
await writeMigrationMarker(db).catch(() => {});
}
return {
sessions,
source: sessions.length > 0 ? 'indexeddb' : 'none',
idbFailed: false,
authoritativeEmpty: sessions.length === 0,
migrationPending: false,
};
}
// Unfinished migration (empty or partially populated IDB). The legacy key is
// intentionally NOT removed: it stays as a read-only fallback for one
// release.
const legacy = parseLegacySessions(await readLegacyRaw());
// A partial migration may already have primary/chunk records in IDB. Only
// fill missing marked IDs; never replay stale primary data over live IDB.
const existingIds = new Set(sessions.map(session => session.id));
const diskOnlyIds = new Set(diskOnlySessionIds);
const recovered = sessions.length === 0
? legacy
: legacy.filter(session => diskOnlyIds.has(session.id) && !existingIds.has(session.id));
const merged = [...sessions, ...recovered].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
const migrationPending = hasUnresolvedDiskSessions(merged);
const result: AiSessionsBootLoadResult = {
sessions: merged,
source: sessions.length > 0 ? 'indexeddb' : merged.length > 0 ? 'legacy-localstorage' : 'none',
idbFailed: false,
authoritativeEmpty: false,
migrationPending,
};
if (merged.length === 0) {
// Nothing to migrate: mark the store initialized so this state is
// distinguishable from "uninitialized" on the next boot.
if (!migrationPending) await writeMigrationMarker(db).catch(() => {});
return result;
}
try {
await idbPutSessions(db, recovered);
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
// Written AFTER the migration data is durable: a crash before this point
// leaves the marker absent and the next boot safely re-migrates.
if (!migrationPending) await writeMigrationMarker(db);
} catch (error) {
console.warn('[AIState] IndexedDB migration write failed; keeping legacy store authoritative:', error);
return { ...result, idbFailed: true };
}
console.info(`[AIState] Migrated ${recovered.length} AI session(s) from localStorage to IndexedDB.`);
return result;
}
interface AiSessionsMigrationMarker {
migrationCompleted: true;
migrationAt: number;
}
async function writeMigrationMarker(db: IDBDatabase): Promise<void> {
await idbPutMeta<AiSessionsMigrationMarker>(
db,
AI_SESSIONS_META_MIGRATION_KEY,
{ migrationCompleted: true, migrationAt: Date.now() },
);
}
function parseLegacySessions(raw: string | null | undefined): AISession[] {
if (raw == null || raw === '') return [];
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? parsed as AISession[] : [];
} catch {
return [];
}
}
// ── Active-session map mirror (meta store) ──
/**
* Mirror the active-pointer map into the IndexedDB meta store. The
* localStorage key remains written as before (small, quota-safe) and stays
* the read-mostly authority for this release; the mirror makes the pointer
* available to future store work without another migration.
*/
export async function mirrorActiveSessionMapToIdb(
map: Record<string, string | null>,
): Promise<void> {
try {
const db = await openAiSessionsIdb();
await idbPutMeta(db, AI_SESSIONS_META_ACTIVE_MAP_KEY, map);
} catch (error) {
console.warn('[AIState] Failed to mirror the active-session map to IndexedDB:', error);
}
}
/**
* Read the mirrored active-pointer map (boot restore aid when the
* localStorage key is missing).
*/
export async function readActiveSessionMapFromIdb(): Promise<Record<string, string | null> | null> {
try {
const db = await openAiSessionsIdb();
const value = await idbGetMeta<Record<string, string | null>>(db, AI_SESSIONS_META_ACTIVE_MAP_KEY);
return value && typeof value === 'object' ? value : null;
} catch {
return null;
}
}
+2 -4
View File
@@ -66,12 +66,10 @@ test('parseBackupSessions ignores corrupt payloads and drops malformed entries',
});
test('restore preserves all sessions when cloud sync is enabled', () => {
// Issue #4123: the retention cap is a session count (raised to 1000) —
// there is no byte budget any more.
const sessions = Array.from({ length: 1001 }, (_, index) => makeSession(`s${index}`, index));
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
const local = prepareRestoredSessions(sessions, false);
assert.equal(local.length, 1000);
assert.equal(local.length, 50);
assert.equal(local.some(session => session.id === 's0'), false);
});
+13 -19
View File
@@ -18,34 +18,28 @@ export interface AiSessionsBackupBridge {
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
}
/**
* Parse the live sessions store once, returning both whether it is usable and
* its sessions. The history is often several MB; the helpers below used to
* parse it twice per call, and mount ran them back to back (five parses).
*/
export function parseSessionsStore(raw: string | null | undefined): { valid: boolean; sessions: AISession[] } {
if (raw == null || raw === '') return { valid: false, sessions: [] };
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? { valid: true, sessions: parsed as AISession[] }
: { valid: false, sessions: [] };
} catch {
return { valid: false, sessions: [] };
}
}
/** Restore only when the live store is missing or malformed. A valid empty
* array represents an intentional deletion and must remain authoritative. */
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
return !parseSessionsStore(raw).valid;
if (raw == null || raw === '') return true;
try {
return !Array.isArray(JSON.parse(raw));
} catch {
return true;
}
}
/** Read the live sessions array from a raw localStorage string. Returns [] when
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
* treat a non-array as AISession[]. */
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
return parseSessionsStore(raw).sessions;
if (isMissingOrCorruptSessionsStore(raw)) return [];
try {
const parsed = JSON.parse(raw as string);
return Array.isArray(parsed) ? parsed as AISession[] : [];
} catch {
return [];
}
}
/**
File diff suppressed because it is too large. Load diff
+313 -254
View File
@@ -2,13 +2,8 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import {
DEFAULT_AI_SESSION_MESSAGE_CAP,
normalizeAiSessionMessageCap,
} from '../../domain/aiSessionMessageCap';
import type {
AIDraft,
AIPanelView,
@@ -16,6 +11,7 @@ import type {
AIPermissionMode,
AIToolIntegrationMode,
} from '../../infrastructure/ai/types';
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
import {
bumpDraftMutationVersionState,
@@ -27,23 +23,12 @@ import {
pruneInactiveScopedState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
import {
idbDeleteSessionIds,
idbPutMeta,
idbPutSessions,
isIndexedDbAvailable,
openAiSessionsIdb,
publishAiSessionsIdbChange,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiPrivacyClearSession?: (chatSessionId: string) => Promise<{ ok: boolean; error?: string }>;
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
@@ -75,29 +60,11 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
}
/**
* Session ids explicitly deleted by the user (or by an authoritative cloud
* merge). Their disappearance from the persisted set must not trigger the
* pre-eviction backup generation — only cap-driven drops should.
*/
const intentionallyDeletedSessionIds = new Set<string>();
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (sessionIds.length === 0) return;
// Only the startup-light handle is imported here; the runtime registers it
// when created. Without a runtime there is no in-memory runtime state, but
// the reversible-privacy flag (set outside the runtime) and the
// main-process privacy mapping may still exist, so always clear those.
const runtime = peekAgentRuntimeCleanup();
for (const sessionId of sessionIds) {
intentionallyDeletedSessionIds.add(sessionId);
if (runtime) {
runtime.clearChatSession(sessionId);
} else {
setReversibleModelPrivacy(sessionId, false);
void bridge?.aiPrivacyClearSession?.(sessionId).catch(() => {});
}
getAgentRuntime().clearChatSession(sessionId);
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
}
@@ -105,9 +72,8 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
const bridge = getAIBridge();
const runtime = peekAgentRuntimeCleanup();
for (const terminalSessionId of new Set(terminalSessionIds)) {
runtime?.clearTerminalSession(terminalSessionId);
getAgentRuntime().clearTerminalSession(terminalSessionId);
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
}
}
@@ -127,11 +93,8 @@ export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
latestAIActiveTargetIdsSnapshot = activeTargetIds;
// Child maintenance effects run before the provider's boot effects. Keep
// the live targets for post-hydration cleanup without adopting stale legacy
// history as authoritative state while IndexedDB is still loading.
if (!isAiSessionsHydrationComplete()) return;
const currentSessions = latestAISessionsSnapshot ?? getPersistedAiSessionsSnapshot();
const currentSessions = latestAISessionsSnapshot
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
@@ -218,53 +181,160 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Maximum number of sessions kept in the persisted store. IndexedDB has no
* shared byte quota, so retention is a plain count cap; the cap only exists
* to keep history from growing without bound.
* Byte budget for the serialized sessions JSON. The localStorage quota is
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
* of KB per turn, so keep the sessions blob well under the quota with
* headroom for the rest of the app's storage keys.
*/
const MAX_STORED_SESSIONS = 1000;
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
/** Retry budgets used when the primary budget still fails to persist. */
const RETRY_SESSIONS_JSON_BYTES = [
1024 * 1024,
512 * 1024,
256 * 1024,
128 * 1024,
] as const;
/**
* Per-session message cap, read from user settings (clamped to 50–2000,
* default 200). Read on every write so a lowered cap trims the oldest
* messages on the next write; a raised cap stops further trimming.
* Best-effort: an unavailable storage layer keeps the default cap.
* Maximum persisted length of a single tool result payload. Terminal tool
* output can reach hundreds of KB per command, and AI-driven terminal work
* accumulates many of them. When storage pressure forces degradation, shedding
* this replay bulk keeps every visible conversation persisted instead of
* evicting whole chats that happen to carry it.
*/
export function readAiSessionMessageCap(): number {
try {
return normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
);
} catch {
return DEFAULT_AI_SESSION_MESSAGE_CAP;
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
function truncatePersistedToolResultContent(content: string): string {
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
// The marker consumes budget and must stay single-line: JSON-escaped
// newlines would cost 2 characters each, letting a slightly-over-cap
// result serialize at the same (or larger) size than the original — the
// ladder assumes the degraded form is the smallest. Shrink the retained
// head+tail until the whole persisted copy fits the cap.
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
let omitted = content.length - 2 * half;
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
omitted = content.length - 2 * half;
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
}
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
}
/**
* Prune sessions before writing to the persisted store to bound retention.
* Only affects what is persisted — the in-memory state retains all messages
* until the session is reloaded.
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation and shed bulk tool output. The ciphertext exists so stateless
* Responses turns can replay prior reasoning items; tool output dominates
* persisted size in AI-driven terminal chats. When storage pressure forces
* it, shedding this bulk keeps the visible conversation intact at the cost of
* reasoning replay and full tool output for affected messages.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
): ProviderContinuationOptions | undefined {
const hasCiphertext = Object.values(options).some(
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
);
if (!hasCiphertext) return options;
const stripped: ProviderContinuationOptions = {};
for (const [provider, providerOptions] of Object.entries(options)) {
const rest = { ...providerOptions };
delete rest.reasoningEncryptedContent;
if (Object.keys(rest).length) stripped[provider] = rest;
}
return Object.keys(stripped).length ? stripped : undefined;
}
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
const parts = continuation.reasoningParts.map(part => {
if (!part.providerOptions) return part;
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
if (providerOptions === part.providerOptions) return part;
changed = true;
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
});
if (!changed) return message;
return {
...message,
providerContinuation: {
...continuation,
reasoningParts: parts,
},
};
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
const toolResults = strippedContinuationMessage.toolResults;
if (!toolResults?.length) return strippedContinuationMessage;
let toolResultsChanged = false;
const nextToolResults = toolResults.map((result) => {
const content = truncatePersistedToolResultContent(result.content);
if (content === result.content) return result;
toolResultsChanged = true;
return { ...result, content };
});
return toolResultsChanged
? { ...strippedContinuationMessage, toolResults: nextToolResults }
: strippedContinuationMessage;
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
const compactedMessageCount = Math.min(
session.messages.length,
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
);
if (compactedMessageCount === 0) return session;
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripReasoningCiphertextFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
*
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last `messageCap` messages.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
*/
export function pruneSessionsForStorage(
sessions: AISession[],
messageCap: number = readAiSessionMessageCap(),
): AISession[] {
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
// Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > messageCap) {
if (s.messages.length > MAX_SESSION_MESSAGES) {
// Do not start the retained tail with a tool result whose assistant call
// was just trimmed. Reuse the same tool-safe boundary logic as context
// compaction, even if that retains a few more than the nominal cap.
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
s.messages,
messageCap,
MAX_SESSION_MESSAGES,
);
const contextCompaction = s.contextCompaction
? {
@@ -289,119 +359,149 @@ export function prepareRestoredSessions(sessions: AISession[], syncEnabled: bool
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
}
// ── IndexedDB persistence (issue #4123) ──
//
// Sessions are persisted one record per session. There is no global byte
// budget and no re-serialization ladder: the 2 MB localStorage budget existed
// to defend a shared quota against the cost of serializing the whole store on
// every write, and both are gone with per-record writes. Only a count cap
// (MAX_STORED_SESSIONS) and the per-session message cap remain.
/**
* Serialize sessions for localStorage under a byte budget, escalating pruning
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
* sessions that JSON represents.
*
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
* tool output (kept at a bounded head+tail per result), and only then drop
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
* emptied history for users under storage pressure.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
): { json: string; sessions: AISession[] } {
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
// These messages are replaced by the durable summary before every later
// request, so their replay ciphertext can never be used again. Remove it
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
return { session, json: JSON.stringify(session) };
});
/** Last set of sessions known to be persisted, for per-record diffing. */
let lastPersistedSessionsById: Map<string, AISession> = new Map();
/** Serialized write queue: IDB operations never interleave. */
let idbWriteQueue: Promise<void> = Promise.resolve();
// Check whether retained history fits before preparing degradation. Use
// the existing JSON string-length budget including array delimiters, and
// avoid preparing unused stripped messages and serializing payloads again.
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, prepared.length - 1);
if (fullLength <= budgetBytes) {
return {
json: `[${prepared.map(entry => entry.json).join(',')}]`,
sessions: prepared.map(entry => entry.session),
};
}
/** Test hook: reset diff state between tests. */
export function __resetIdbPersistenceForTests() {
lastPersistedSessionsById = new Map();
idbWriteQueue = Promise.resolve();
// Tests swap the fake indexedDB factory between cases; the cached open
// handle must not survive the swap.
resetAiSessionsIdbForTests();
// Tests simulate the post-boot state by default; the boot-gating cases
// call __resetAiSessionsHydrationForTests() to go back to pending.
markAiSessionsHydrationComplete();
}
// Reuse the full representation when the unchanged degradation ladder is
// needed, including on quota retries with a smaller budget.
const serialized = prepared.map(({ session, json }) => {
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
return [{
index,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
return {
session,
json,
strippedSession,
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
degradableMessages,
};
});
/** Test hook: resolve once every queued IndexedDB sync has settled. */
export function __flushIdbWritesForTests(): Promise<void> {
return idbWriteQueue.then(() => undefined, () => undefined);
}
// Preserve the newest session's full continuation whenever it can fit by
// itself. That session is the one the user is most likely continuing now;
// older visible history must not make its next tool turn unreplayable.
const protectNewestContinuation = serialized.length > 0
&& serialized[0].json.length + 2 <= budgetBytes;
// ── Boot hydration signal (issue #4125 review) ──
//
// Settles once useAIState's boot load has finished (IndexedDB read, legacy
// migration, disk-backup restore). Sync collection and the active-pointer
// validation must not run before it: a pre-hydration collect reads the
// stale legacy localStorage store and would overwrite newer IndexedDB
// records, and pointer validation against the initially-empty in-memory
// array would null every pointer before the real sessions arrive.
// Determine how many sessions can fit using the smallest representation for
// older sessions while reserving the newest session's full representation
// when possible. This also prevents an old, oversized visible chat that must
// be dropped anyway from causing newer replay ciphertext to be stripped.
let minimalLength = 2
+ serialized.reduce((total, entry, index) => (
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
), 0)
+ Math.max(0, serialized.length - 1);
while (minimalLength > budgetBytes && serialized.length > 1) {
const removed = serialized.pop();
if (!removed) break;
minimalLength -= removed.strippedJson.length + 1;
}
let aiSessionsHydrationComplete = false;
// Then keep full continuation data for the retained sessions and shed
// replay-only bulk (ciphertext, oversized tool output) from the oldest
// retained sessions only as needed, never touching the protected newest
// session. Within one session, shed it one message at a time from oldest to
// newest so a long active chat can retain its most recent replayable tool
// exchange when that still fits.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
for (
let index = serialized.length - 1;
index >= firstStrippableIndex && jsonLength > budgetBytes;
index -= 1
) {
const current = serialized[index];
let degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
}
if (degradedMessageCount > 0) {
const degradedMessagesByIndex = new Map(
current.degradableMessages
.slice(0, degradedMessageCount)
.map(entry => [entry.index, entry.degradedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
degradedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.degradableMessages
.slice(0, degradedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
...current,
session: nextSession,
json: nextJson,
};
}
}
export function isAiSessionsHydrationComplete(): boolean {
return aiSessionsHydrationComplete;
}
/** Called by useAIState's boot effect when the boot load settles. */
export function markAiSessionsHydrationComplete(): void {
aiSessionsHydrationComplete = true;
}
/** Test hook: return the hydration signal to the pre-boot (pending) state. */
export function __resetAiSessionsHydrationForTests(): void {
aiSessionsHydrationComplete = false;
}
/** Seed the diff baseline after a boot load or migration. */
export function seedLastPersistedSessions(sessions: AISession[]) {
lastPersistedSessionsById = new Map(sessions.map(session => [session.id, session]));
return {
json: `[${serialized.map(entry => entry.json).join(',')}]`,
sessions: serialized.map(entry => entry.session),
};
}
/**
* Read the persisted session set synchronously. Source of truth is the
* in-memory snapshot held by useAIState; this accessor returns the last set
* handed to `writeSessionsForStorage`, falling back to the read-only legacy
* localStorage store (kept untouched for one release).
* Persist sessions to localStorage with byte-budgeted pruning and retries.
* Returns true when the write succeeded; a false result means the payload
* could not be persisted even after escalation (it stays memory-only).
*/
export function getPersistedAiSessionsSnapshot(): AISession[] {
if (lastPersistedSessionsById.size > 0) {
return Array.from(lastPersistedSessionsById.values())
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
return readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
}
function queueIdbSessionSync(
changed: AISession[],
removedIds: string[],
prevById: Map<string, AISession>,
persisted: AISession[],
) {
if (changed.length === 0 && removedIds.length === 0) return;
idbWriteQueue = idbWriteQueue
.then(async () => {
if (!isIndexedDbAvailable()) return;
const db = await openAiSessionsIdb();
// Per-record write: only the changed sessions are put, only removed
// ids are deleted.
if (changed.length > 0) await idbPutSessions(db, changed);
if (removedIds.length > 0) await idbDeleteSessionIds(db, removedIds);
await idbPutMeta(db, 'schemaVersion', 1);
await idbPutMeta(db, 'lastWriteAt', Date.now());
publishAiSessionsIdbChange();
})
.catch((error) => {
// IndexedDB failures must never break the in-memory session state.
// Reset the diff baseline so the next write retries every record, and
// fall back to the legacy localStorage key so a corrupt/unavailable
// IndexedDB still leaves a restorable trace (via the disk mirror).
console.warn('[AIState] IndexedDB session sync failed:', error);
lastPersistedSessionsById = new Map(prevById);
try {
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, JSON.stringify(persisted));
} catch {
// Legacy fallback is best-effort too.
}
});
}
// Durability mirror (issue #4015 follow-up): every write also schedules a
// debounced disk backup so a wiped/recreated profile does not take the chat
// history with it. The persisted JSON is mirrored; when the count cap evicts
// sessions, a PRE-eviction generation is written first so evicted chats stay
// recoverable from disk (the blind spot found in issue #4123).
// Durability mirror (issue #4015 follow-up): every successful store write
// also schedules a debounced disk backup so a wiped/recreated profile does
// not take the chat history with it. The exact JSON that landed in
// localStorage is mirrored (already byte-budgeted), throttled to at most one
// write per mutation batch.
const backupMirror = new DebouncedBackupMirror((json) => {
try {
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
@@ -410,14 +510,6 @@ const backupMirror = new DebouncedBackupMirror((json) => {
}
});
let aiSessionsBackupRecoveryPending = false;
/** Partial migration must not rotate away generations holding disk-only chats. */
export function setAiSessionsBackupRecoveryPending(pending: boolean) {
aiSessionsBackupRecoveryPending = pending;
if (pending) backupMirror.reset();
}
export function flushBackupMirror() {
try {
backupMirror.flushNow();
@@ -428,11 +520,9 @@ export function flushBackupMirror() {
export function __resetBackupMirrorForTests() {
backupMirror.reset();
aiSessionsBackupRecoveryPending = false;
}
export function scheduleBackupMirror(json: string) {
if (aiSessionsBackupRecoveryPending) return;
try {
backupMirror.schedule(json);
} catch {
@@ -440,79 +530,48 @@ export function scheduleBackupMirror(json: string) {
}
}
/** Write one immediate backup generation (bypasses the debounce window). */
function writeImmediateBackupGeneration(json: string) {
if (aiSessionsBackupRecoveryPending) return;
try {
backupMirror.schedule(json);
backupMirror.flushNow();
} catch {
// Backup failures must never break chat persistence.
export function writeSessionsForStorage(sessions: AISession[]): boolean {
// Synced records are authoritative after restart: silently evicting a
// session or turn here would turn local retention into cloud deletion.
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
return syncedOk;
}
}
/**
* Perf (#4121): the exact same immutable sessions array is often written
* again by the debounced save path and by cloud-sync collection; skipping
* the re-stringify for a reference-identical set keeps that cheap. The
* per-record IDB diff below already makes the store write itself cheap.
*/
let lastMirroredSessions: AISession[] | null = null;
/**
* Persist sessions. Writes go to IndexedDB, one record per session, through a
* serialized queue; errors are logged, never thrown. Returns true when the
* persisted set was computed (in-memory state remains authoritative either
* way).
*/
export function writeSessionsForStorage(
sessions: AISession[],
options?: { deletedSessionIds?: readonly string[] },
): boolean {
try {
// Explicitly deleted ids never count as cap eviction (they also must not
// trigger a pre-eviction backup generation).
for (const id of options?.deletedSessionIds ?? []) intentionallyDeletedSessionIds.add(id);
// Synced records are authoritative after restart: never prune them, or
// local retention would turn into cloud deletion on the next sync.
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
)?.enabled;
const persisted = syncEnabled ? sessions : pruneSessionsForStorage(sessions);
const prevById = lastPersistedSessionsById;
const persistedIds = new Set(persisted.map(session => session.id));
// Count-cap eviction: sessions that drop out of the persisted set without
// being explicitly deleted get a pre-eviction disk generation containing
// the full pre-eviction store, so they remain recoverable.
const evicted = Array.from(prevById.entries())
.filter(([id]) => !persistedIds.has(id) && !intentionallyDeletedSessionIds.has(id))
.map(([, session]) => session);
intentionallyDeletedSessionIds.clear();
if (evicted.length > 0) {
writeImmediateBackupGeneration(JSON.stringify(sessions));
console.warn(
`[AIState] Retention cap evicted ${evicted.length} session(s); a pre-eviction backup generation was written to disk.`,
);
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
// A real quota failure means the next attempt must be smaller even when
// the failed payload was already below the nominal retry budget. Reduce
// materially on each bounded retry so several small sessions can be
// removed before the retry sequence is exhausted.
const reducedBudget = Number.isFinite(previousLength)
? Math.floor(previousLength * 0.75)
: configuredBudget;
const budget = Math.min(configuredBudget, reducedBudget);
if (budget < 2) break;
const candidate = serializeSessionsForStorage(sessions, budget);
if (candidate.json.length >= previousLength) continue;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) {
scheduleBackupMirror(candidate.json);
return true;
}
// Per-record diff (synchronous: the baseline must advance even when
// IndexedDB is unavailable, and callers read the snapshot right after).
const changed = persisted.filter(session => prevById.get(session.id) !== session);
const removedIds = Array.from(prevById.keys()).filter(id => !persistedIds.has(id));
lastPersistedSessionsById = new Map(persisted.map(session => [session.id, session]));
if (changed.length > 0 || removedIds.length > 0 || lastMirroredSessions !== persisted) {
scheduleBackupMirror(JSON.stringify(persisted));
lastMirroredSessions = persisted;
}
queueIdbSessionSync(changed, removedIds, prevById, persisted);
previousLength = candidate.json.length;
}
// Last resort: attempt the smallest representation this serializer can
// produce (the newest session with replay-only ciphertext removed). A very
// small amount of shared quota may still be enough to preserve that chat.
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
if (
minimalCandidate.json.length < previousLength
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
) {
scheduleBackupMirror(minimalCandidate.json);
return true;
} catch (error) {
console.warn('[AIState] Failed to persist AI sessions:', error);
return false;
}
console.warn(
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
);
return false;
}
export let latestAISessionsSnapshot: AISession[] | null = null;
@@ -554,10 +613,10 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
export function prewarmAIStateStorageSnapshots() {
try {
// Sessions are no longer prewarmed from localStorage: the primary store is
// IndexedDB and loads asynchronously in useAIState. Prewarming the stale
// read-only legacy key would make the boot loader skip the IndexedDB
// contents ("never restore over non-empty live state").
if (latestAISessionsSnapshot === null) {
latestAISessionsSnapshot =
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
}
if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
@@ -1,6 +1,5 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { HistoryEntry } from "./commandHistoryStore";
import {
STORAGE_KEY_COMMAND_HISTORY,
@@ -74,9 +73,7 @@ const {
getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared,
markCommandHistoryCleared,
fuzzyQueryHistory,
queryHistory,
queryRecentHistoryByCommand,
recordCommand,
subscribeCommandHistoryCleared,
} = await import("./commandHistoryStore.ts");
@@ -329,172 +326,3 @@ test("an external clear also drops in-memory compose-bar recall", async () => {
unsubscribe();
});
test("saving after a command and peer writes read the clear generation without parsing the history", async (t) => {
clearHistory();
recordCommand("echo fast-path", "host-fast", "linux");
assert.equal(flushCommandHistoryStore(), true);
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
assert.match(raw, /^\{"clearGeneration":\d+,/, "generation is serialized first");
const parse = t.mock.method(JSON, "parse");
recordCommand("echo fast-path-2", "host-fast", "linux");
assert.equal(flushCommandHistoryStore(), true);
// A peer window's save only needs the generation as well.
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, storage.get(COMMAND_HISTORY_STORAGE_KEY)!, raw);
assert.equal(parse.mock.callCount(), 0);
parse.mock.restore();
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
});
const QUERY_NOW = 1_700_000_000_000;
const DAY_MS = 24 * 60 * 60 * 1000;
function historyEntry(command: string, overrides: Partial<HistoryEntry> = {}): HistoryEntry {
return {
command, hostId: "host-1", os: "linux", frequency: 1,
createdAt: QUERY_NOW, lastUsedAt: QUERY_NOW, ...overrides,
};
}
function installHistoryEntries(entries: HistoryEntry[]): void {
clearHistory();
const raw = JSON.stringify({
clearGeneration: getCommandHistoryClearGeneration(), version: 1, entries,
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, raw);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, raw);
}
test("prefix history preserves relevance, stable ties, host isolation and best duplicate", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vim duplicate"),
historyEntry("vim frequent", { frequency: 8, lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("Vim case"),
historyEntry("vim tied"),
historyEntry("vim duplicate", { hostId: "host-2", frequency: 5 }),
historyEntry("unrelated", { frequency: 100 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 4 }), [entries[5], entries[2], entries[3], entries[4]]);
assert.deepEqual(queryHistory("vi", { hostId: "host-1", limit: 3 }), [entries[2], entries[1], entries[3]]);
assert.deepEqual(queryHistory("VI", { limit: 1 }), [entries[0]]);
assert.deepEqual(queryHistory("missing"), []);
assert.deepEqual(queryHistory("vi", { limit: 0 }), []);
});
test("top-k history replaces and evicts duplicates without losing distinct results", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vim a", { frequency: 2 }),
historyEntry("vim b", { frequency: 3 }),
historyEntry("vim c", { frequency: 4 }),
historyEntry("vim b", { hostId: "host-2", frequency: 5 }),
historyEntry("vim a", { hostId: "host-2", frequency: 6 }),
historyEntry("vim a", { hostId: "host-3", frequency: 1 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
});
test("fuzzy history preserves subsequence quality, recency, exclusions and stable ties", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vi alpha"),
historyEntry("vi beta"),
historyEntry("very indirect", { frequency: 2 }),
historyEntry("vi stale", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("vi alpha", { hostId: "host-2", frequency: 3 }),
historyEntry("iv does not match"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 4 }), [entries[5], entries[3], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory("vi", { hostId: "host-1", limit: 4 }), [entries[3], entries[1], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory(""), []);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: -1 }), []);
});
test("recent history preserves timestamps, command boundaries and quoted argument prefixes", () => {
const entries = [
historyEntry("cd /work/older", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("cd '/work/new folder'"),
historyEntry("CD /work/tied"),
historyEntry("cd /work/older", { hostId: "host-2", lastUsedAt: QUERY_NOW + 1 }),
historyEntry("cd /other", { lastUsedAt: QUERY_NOW + 2 }),
historyEntry("cdb /work/wrong", { lastUsedAt: QUERY_NOW + 3 }),
historyEntry("cd /work/excluded", { lastUsedAt: QUERY_NOW + 4 }),
];
installHistoryEntries(entries);
const options = { commandName: " CD ", argumentPrefix: "'/work/", excludeCommand: "cd /work/excluded", limit: 3 };
assert.deepEqual(queryRecentHistoryByCommand(options), [entries[3], entries[1], entries[2]]);
assert.deepEqual(queryRecentHistoryByCommand({ ...options, hostId: "host-1" }), [entries[1], entries[2], entries[0]]);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "" }), []);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "cd", limit: 0 }), []);
});
test("fuzzy scoring preserves consecutive and word-boundary bonuses across long gaps", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("av-x"),
historyEntry("avx"),
historyEntry(`v${"a".repeat(1900)}x`),
historyEntry("v-x"),
historyEntry("v/x"),
historyEntry("v_x"),
historyEntry("v x"),
historyEntry("vx adjacent"),
historyEntry("v missing second character"),
historyEntry("xv wrong order"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vx"), [
entries[7], entries[3], entries[4], entries[5], entries[6], entries[2], entries[1], entries[0],
]);
assert.deepEqual(fuzzyQueryHistory("vv"), []);
});
for (const size of [4_000, 40_000]) {
test(`history queries keep five best results with linear scoring for ${size} entries`, (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = Array.from({ length: size }, (_, i) => historyEntry(`vi file-${i}`, {
// Unsorted scores/timestamps, with the best match at the end of the store.
frequency: i === size - 1 ? size + 1 : (i * 7919) % size + 1,
lastUsedAt: QUERY_NOW + (i === size - 1 ? size + 1 : (i * 7919) % size),
}));
installHistoryEntries(entries);
queryHistory("missing"); // Warm the cache before measuring query operations.
const expected = entries.slice().sort((a, b) => b.frequency - a.frequency).slice(0, 5);
const pow = t.mock.method(Math, "pow");
const lower = t.mock.method(String.prototype, "toLowerCase");
// node:test cannot mock methods directly on arrays (including Array.prototype).
const originalSort = Array.prototype.sort;
const sortedSizes: number[] = [];
Array.prototype.sort = function<T>(this: T[], compare?: (a: T, b: T) => number): T[] {
sortedSizes.push(this.length);
return originalSort.call(this, compare);
};
t.after(() => { Array.prototype.sort = originalSort; });
const queries = [
() => queryHistory("vi", { limit: 5 }),
() => fuzzyQueryHistory("vi", { limit: 5 }),
() => queryRecentHistoryByCommand({ commandName: "vi", argumentPrefix: "file-", limit: 5 }),
];
for (const query of queries) {
const beforePow = pow.mock.callCount();
const beforeLower = lower.mock.callCount();
const result = query();
assert.deepEqual(result, expected);
assert.equal(new Set(result.map((entry) => entry.command)).size, 5);
assert.ok(pow.mock.callCount() - beforePow <= size, "score each entry at most once");
assert.ok(lower.mock.callCount() - beforeLower <= 2 * size + 2, "normalize in one linear pass");
}
assert.deepEqual(sortedSizes, [], "never sort all matches before limiting");
});
}
+103 -126
View File
@@ -68,75 +68,33 @@ function loadStore(): HistoryStore {
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let saveIdleHandle: number | null = null;
const CLEAR_GENERATION_PREFIX = /^\{"clearGeneration":(\d+)[,}]/;
/**
* The store's clear generation, read from its raw JSON without parsing the
* whole history (up to 10k entries, ~1.5 MB). Stores are serialized with
* clearGeneration first (serializeStore); other layouts fall back to a parse.
*/
function readRawClearGeneration(raw: string | null): number | null {
if (!raw) return null;
const match = CLEAR_GENERATION_PREFIX.exec(raw.slice(0, 48));
if (match) return Number(match[1]);
try {
return readClearGeneration(JSON.parse(raw) as HistoryStore);
} catch {
return null;
}
}
function serializeStore(store: HistoryStore): HistoryStore {
// Keep clearGeneration first so readRawClearGeneration can find it.
return { clearGeneration: readClearGeneration(store), version: store.version, entries: store.entries };
}
function persistStoreNow(store: HistoryStore): boolean {
// A peer clear is visible in storage before its queued event is handled.
// Never flush a cached pre-clear snapshot over that newer empty store.
// Only the generation is needed on this per-command path; parse the stored
// history just in the rare case where it must be adopted.
const persistedGeneration = readRawClearGeneration(localStorageAdapter.readString(STORAGE_KEY));
if (persistedGeneration !== null && persistedGeneration > readClearGeneration(store)) {
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
}
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
}
const ok = localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
const ok = localStorageAdapter.write(STORAGE_KEY, store);
if (ok) return true;
// Storage full — evict lowest scored entries (not just oldest by insertion)
const now = Date.now();
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
return localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
}
function hasPendingSave(): boolean {
return saveTimer !== null || saveIdleHandle !== null;
return localStorageAdapter.write(STORAGE_KEY, store);
}
function saveStore(store: HistoryStore): void {
cachedStore = store;
// Debounce saves, then write when the renderer is idle: serializing the
// history right after Enter used to land in the middle of command output.
cancelPendingSave();
// Debounce saves to avoid excessive writes
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
persistStoreNow(store);
saveTimer = null;
const persist = () => {
saveIdleHandle = null;
persistStoreNow(store);
};
if (typeof requestIdleCallback === "function") {
saveIdleHandle = requestIdleCallback(persist, { timeout: 2000 });
} else {
persist();
}
}, 500);
}
@@ -145,10 +103,6 @@ function cancelPendingSave(): void {
clearTimeout(saveTimer);
saveTimer = null;
}
if (saveIdleHandle !== null) {
if (typeof cancelIdleCallback === "function") cancelIdleCallback(saveIdleHandle);
saveIdleHandle = null;
}
}
/**
@@ -175,14 +129,26 @@ function applyExternalCommandHistoryWrite(
rawNewValue: string | null,
rawOldValue: string | null,
): void {
// Fires in every other window for each save; read just the generation.
const incomingGeneration = readRawClearGeneration(rawNewValue);
let incomingGeneration: number | null = null;
if (rawNewValue) {
try {
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
} catch {
incomingGeneration = null;
}
}
let previousGeneration = knownClearGeneration;
if (previousGeneration === null) {
// A fresh popup can have compose recall before its first autocomplete query.
// The event's old value is its baseline, even after an earlier global clear.
previousGeneration = readRawClearGeneration(rawOldValue) ?? 0;
try {
previousGeneration = rawOldValue
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
: 0;
} catch {
previousGeneration = 0;
}
}
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
cancelPendingSave();
@@ -190,7 +156,7 @@ function applyExternalCommandHistoryWrite(
knownClearGeneration = incomingGeneration;
notifyCommandHistoryCleared();
} else {
if (!hasPendingSave()) cachedStore = null;
if (!saveTimer) cachedStore = null;
if (incomingGeneration !== null && knownClearGeneration === null) {
knownClearGeneration = incomingGeneration;
}
@@ -352,36 +318,6 @@ export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
argumentPrefix?: string;
}
interface RankedHistoryEntry {
entry: HistoryEntry;
score: number;
}
/**
* Keep only the best k distinct commands. Queries score each match once and
* retain O(k) candidates instead of sorting/allocating all matches (k is 5
* for autocomplete). Entries arrive in store order for stable score ties.
*/
function retainHistoryMatch(
best: RankedHistoryEntry[],
entry: HistoryEntry,
score: number,
limit: number,
): void {
if (best.length >= limit && score <= best[best.length - 1].score) return;
const duplicate = best.findIndex((candidate) => candidate.entry.command === entry.command);
if (duplicate >= 0) {
if (score <= best[duplicate].score) return;
best.splice(duplicate, 1);
}
// Insert after equal scores to preserve the original store order on ties.
const index = best.findIndex((candidate) => score > candidate.score);
if (best.length >= limit) best.pop();
best.splice(index < 0 ? best.length : index, 0, { entry, score });
}
/**
* Query history entries matching a prefix.
* Returns entries sorted by relevance (frequency * recency).
@@ -396,17 +332,33 @@ export function queryHistory(
const lowerPrefix = prefix.toLowerCase();
const now = Date.now(); // Cache once per query
const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
const filtered = store.entries.filter((entry) => {
// Must match prefix
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) continue;
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
// Must not be identical to prefix
if (entry.command === prefix) continue;
retainHistoryMatch(best, entry, scoreEntryAt(entry, now), limit);
if (entry.command === prefix) return false;
// Host filtering: strict per-host isolation
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return best.map(({ entry }) => entry);
return results;
}
/**
@@ -424,7 +376,7 @@ export function fuzzyQueryHistory(
const lowerQuery = query.toLowerCase();
const now = Date.now(); // Cache once per query
const best: RankedHistoryEntry[] = [];
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
for (const entry of store.entries) {
// Host filtering
@@ -434,11 +386,24 @@ export function fuzzyQueryHistory(
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
if (matchScore > 0 && entry.command !== query) {
retainHistoryMatch(best, entry, matchScore * scoreEntryAt(entry, now), limit);
scored.push({ entry, matchScore });
}
}
return best.map(({ entry }) => entry);
scored.sort((a, b) =>
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const { entry } of scored) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
@@ -463,26 +428,38 @@ export function queryRecentHistoryByCommand(
const commandPrefix = `${trimmedCommandName} `;
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
const filtered = store.entries.filter((entry) => {
const lowerCommand = entry.command.toLowerCase();
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
continue;
return false;
}
if (excludeCommand && entry.command === excludeCommand) continue;
if (excludeCommand && entry.command === excludeCommand) return false;
if (normalizedArgumentPrefix) {
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
continue;
return false;
}
}
retainHistoryMatch(best, entry, entry.lastUsedAt, limit);
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return best.map(({ entry }) => entry);
return results;
}
function getCurrentCommandToken(command: string): string {
@@ -558,28 +535,28 @@ function fuzzyScore(query: string, target: string): number {
if (query.length > target.length) return 0;
let score = 0;
let searchFrom = 0;
let queryIdx = 0;
let prevMatchIdx = -2;
for (let queryIdx = 0; queryIdx < query.length; queryIdx++) {
// Skip unmatched runs in native string search, especially for long commands.
const i = target.indexOf(query[queryIdx], searchFrom);
if (i < 0) return 0;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
if (target[i] === query[queryIdx]) {
queryIdx++;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
}
score += 1;
prevMatchIdx = i;
}
score += 1;
prevMatchIdx = i;
searchFrom = i + 1;
}
return score;
// All query characters must be matched
return queryIdx === query.length ? score : 0;
}
/**
@@ -47,45 +47,3 @@ test("renderer cursor acknowledges GPU publication separately from CPU sample an
historyId: "history",
});
});
test("idle runtime polls do not notify subscribers until something changes", async () => {
const React = await import("react");
const { act, create } = await import("react-test-renderer");
const { useMonitorSnapshot } = await import("./monitorRuntimeStore");
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const idle = (takenAt: number): MonitorSnapshot => ({
historyMode: "replace",
runtimeId: "idle-poll-test",
epoch: 7,
takenAt,
running: [],
count: 0,
hostCount: 0,
activeSessions: [],
startedAt: {},
hosts: {},
});
let renders = 0;
function Probe() {
useMonitorSnapshot();
renders += 1;
return null;
}
// Later than anything earlier tests published, so this baseline wins.
const base = 1_000_000_000;
publishMonitorSnapshot(idle(base));
let renderer!: ReturnType<typeof create>;
act(() => {
renderer = create(React.createElement(Probe));
});
const baseline = renders;
act(() => {
for (let at = base + 1000; at <= base + 5000; at += 1000) publishMonitorSnapshot(idle(at));
});
assert.equal(renders, baseline, "unchanged idle replies must not re-render subscribers");
act(() => {
publishMonitorSnapshot({ ...idle(base + 6000), running: ["panel"], count: 1 });
});
assert.equal(renders, baseline + 1, "a real change still notifies");
act(() => renderer.unmount());
});
+1 -23
View File
@@ -14,30 +14,8 @@ const empty: MonitorSnapshot = {
};
let snapshot = empty;
const listeners = new Set<() => void>();
export function isIdleMonitorSnapshot(value: MonitorSnapshot): boolean {
return value.running.length === 0
&& value.count === 0
&& value.hostCount === 0
&& value.activeSessions.length === 0
&& Object.keys(value.hosts).length === 0
&& Object.keys(value.startedAt).length === 0;
}
/** An idle runtime polled again: only `takenAt` moved, nothing to render. */
function isUnchangedIdleSnapshot(previous: MonitorSnapshot, next: MonitorSnapshot): boolean {
return isIdleMonitorSnapshot(previous)
&& isIdleMonitorSnapshot(next)
&& previous.epoch === next.epoch
&& previous.runtimeId === next.runtimeId
&& Boolean(previous.unavailable) === Boolean(next.unavailable);
}
export function publishMonitorSnapshot(next: MonitorSnapshot) {
const merged = mergeMonitorSnapshotHistory(snapshot, next);
// The runtime is polled even when nothing is monitored; do not wake every
// subscriber (host-key dialog, monitor panels) for an unchanged idle reply.
if (merged === snapshot || isUnchangedIdleSnapshot(snapshot, merged)) return;
snapshot = merged;
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
for (const l of listeners) l();
}
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
@@ -393,10 +393,12 @@ export function useSftpFollowTerminalCwd({
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
}
}, [
activeSessionId,
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd,
effectiveFollowTerminalCwd,
focusedSessionId,
hasActiveWork,
isVisible,
onGetTerminalCwd,
@@ -30,20 +30,3 @@ test('terminalCwdStore bumps version only when cwd or provenance changes', () =>
unsubscribe();
assert.ok(versions.length >= 2);
});
test('session revisions isolate terminals and retain identical OSC 7 reports without rerendering', () => {
const a = 'revision-a'; const b = 'revision-b';
terminalCwdStore.setCwd(a, '/same', 'osc7');
const revisionA = terminalCwdStore.getSessionRevision(a);
terminalCwdStore.setCwd(b, '/elsewhere', 'osc7');
assert.equal(terminalCwdStore.getSessionRevision(a), revisionA);
const globalVersion = terminalCwdStore.getVersion();
assert.equal(terminalCwdStore.setCwd(a, '/same', 'osc7'), false);
assert.ok(terminalCwdStore.getSessionRevision(a) > revisionA);
assert.equal(terminalCwdStore.getVersion(), globalVersion);
const revisionB = terminalCwdStore.getSessionRevision(b);
terminalCwdStore.prune(new Set([b]));
assert.equal(terminalCwdStore.getSessionRevision(a), 0);
assert.equal(terminalCwdStore.getSessionRevision(b), revisionB);
terminalCwdStore.prune(new Set());
});
+1 -14
View File
@@ -10,16 +10,11 @@ export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backen
class TerminalCwdStore {
private cwdBySession = new Map<string, string>();
private sourceBySession = new Map<string, TerminalCwdSource>();
private revisionBySession = new Map<string, number>();
private revision = 0;
private version = 0;
private listeners = new Set<Listener>();
getVersion = (): number => this.version;
/** Tracks reports for one session, including unchanged authoritative OSC 7. */
getSessionRevision = (sessionId: string): number => this.revisionBySession.get(sessionId) ?? 0;
getCwd = (sessionId: string | null | undefined): string | null => {
if (!sessionId) return null;
return this.cwdBySession.get(sessionId) ?? null;
@@ -44,10 +39,7 @@ class TerminalCwdStore {
const nextSource = next
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
: undefined;
if (current === next && currentSource === nextSource) {
if (nextSource === 'osc7') this.revisionBySession.set(sessionId, ++this.revision);
return false;
}
if (current === next && currentSource === nextSource) return false;
if (next) {
this.cwdBySession.set(sessionId, next);
@@ -56,7 +48,6 @@ class TerminalCwdStore {
this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId);
}
this.revisionBySession.set(sessionId, ++this.revision);
this.version += 1;
for (const listener of this.listeners) {
listener();
@@ -70,13 +61,9 @@ class TerminalCwdStore {
if (!validSessionIds.has(sessionId)) {
this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId);
this.revisionBySession.delete(sessionId);
changed = true;
}
}
for (const id of this.revisionBySession.keys()) {
if (!validSessionIds.has(id)) this.revisionBySession.delete(id);
}
if (!changed) return;
this.version += 1;
for (const listener of this.listeners) {
@@ -1,230 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { terminalCwdStore } from './terminalCwdStore';
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
function setup(overrides: Partial<TerminalDirectoryIO> = {}) {
let cwd = '/old'; let ready = true; let current = true;
const writes: string[] = [];
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: 'test-directory', confirmationMs: 10,
io: { cwd: async () => cwd, list: async () => ({ entries: [], home: null }), realpath: async path => path,
write: data => { writes.push(data); cwd = '/next'; }, ...overrides },
isCurrent: () => current, isReady: () => ready,
publish: value => { published.push(value); terminalCwdStore.setCwd('test-directory', value, 'backend-strict'); },
});
return { controller, writes, published, busy: () => { ready = false; }, stale: () => { current = false; } };
}
test('navigation writes one quoted cd and waits for actual cwd before confirming', async () => {
const ctx = setup(); const accepted: string[] = [];
await ctx.controller.change('/next', new AbortController().signal, () => accepted.push('accepted'));
assert.deepEqual(ctx.writes, ["cd -- '/next'\r"]);
assert.deepEqual(accepted, ['accepted']);
assert.deepEqual(ctx.published, ['/next']);
ctx.controller.dispose();
});
test('busy, control-character and stale sessions never receive bytes', async () => {
const ctx = setup(); ctx.busy();
await assert.rejects(ctx.controller.change('/next', new AbortController().signal, () => {}), { code: 'busy' });
const invalid = setup();
await assert.rejects(invalid.controller.change('/next\n', new AbortController().signal, () => {}), { code: 'unsupportedPath' });
const stale = setup(); stale.stale();
await assert.rejects(stale.controller.change('/next', new AbortController().signal, () => {}), { code: 'unavailable' });
assert.deepEqual([...ctx.writes, ...invalid.writes, ...stale.writes], []);
});
test('rechecks readiness after async path resolution; double submission is rejected', async () => {
let finish!: (path: string) => void;
const ctx = setup({ realpath: () => new Promise(resolve => { finish = resolve; }) });
const first = ctx.controller.change('/next', new AbortController().signal, () => {});
await assert.rejects(ctx.controller.change('/other', new AbortController().signal, () => {}), { code: 'busy' });
ctx.busy(); finish('/next');
await assert.rejects(first, { code: 'busy' });
assert.deepEqual(ctx.writes, []);
});
test('a disposed connection rejects delayed reads and cannot publish into a new generation', async () => {
let finish!: (cwd: string) => void;
const ctx = setup({ cwd: () => new Promise(resolve => { finish = resolve; }) });
const read = ctx.controller.refresh();
ctx.controller.dispose(); finish('/stale');
await assert.rejects(read, { code: 'unavailable' });
assert.deepEqual(ctx.published, []);
});
test('a failed cd keeps the observed cwd and never retries the write', async () => {
const ctx = setup({ write: () => {}, cwd: async () => '/old' });
await assert.rejects(ctx.controller.change('/missing', new AbortController().signal, () => {}), { code: 'changeFailed' });
assert.ok(ctx.published.every(value => value === '/old'));
});
test('directory symlinks confirm against physical cwd', async () => {
const ctx = setup({ realpath: async path => path === '/link' ? '/real' : path, cwd: async () => '/real' });
await ctx.controller.change('/link', new AbortController().signal, () => {});
assert.equal(ctx.published.at(-1), '/real');
});
test('output arriving during an old cwd query retains one fresh follow-up query', async () => {
let finish!: (cwd: string) => void;
let reads = 0;
const ctx = setup({ cwd: () => {
reads++;
return reads === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve('/after-cd');
} });
const oldRead = ctx.controller.refresh();
const queued = ctx.controller.refresh();
finish('/before-cd');
await Promise.all([oldRead, queued]);
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 2);
assert.equal(ctx.published.at(-1), '/after-cd');
ctx.controller.dispose();
});
test('another terminal updating cannot override the current terminal strict cwd result', async t => {
const id = 'strict-after-other-session';
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/stale-osc7', 'stale');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); terminalCwdStore.setCwd(id, value, 'backend-strict'); },
});
t.after(() => controller.dispose());
const pending = controller.refresh();
terminalCwdStore.setCwd('unrelated-session', '/other', 'osc7');
finish('/actual-after-cd');
assert.equal(await pending, '/actual-after-cd');
assert.deepEqual(published, ['/actual-after-cd']);
assert.equal(terminalCwdStore.getCwd(id), '/actual-after-cd');
});
test('another terminal cannot turn this submission pre-write OSC 7 into a fresh report', async t => {
const id = 'pre-write-osc7-after-other-session';
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/before', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
t.after(() => controller.dispose());
const changing = controller.change('/after', new AbortController().signal, () => {});
await new Promise(resolve => setImmediate(resolve));
terminalCwdStore.setCwd(id + '-other', '/other', 'osc7');
finish('/after');
await changing;
assert.deepEqual(published, ['/after']);
assert.equal(terminalCwdStore.getCwd(id), '/after');
});
for (const nextCwd of ['/new-osc7', '/original']) {
test(`a later OSC 7 report from this terminal wins (${nextCwd})`, async t => {
const id = 'same-session-osc7-' + nextCwd;
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/original', 'stale');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); },
});
t.after(() => controller.dispose());
const pending = controller.refresh();
terminalCwdStore.setCwd(id, nextCwd, 'osc7');
finish('/older-backend-result');
assert.equal(await pending, nextCwd);
assert.deepEqual(published, []);
});
}
for (const probe of ['fails', 'stalls'] as const) {
test(`a fresh post-write OSC 7 confirms navigation when the strict cwd probe ${probe}`, { timeout: 2000 }, async t => {
const id = 'osc7-confirm-' + probe;
const writes: string[] = [];
terminalCwdStore.setCwd(id, '/old', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 1000, isCurrent: () => true, isReady: () => true,
io: {
cwd: () => probe === 'fails' ? Promise.reject(new Error('no process cwd support')) : new Promise(() => {}),
list: async () => ({ entries: [], home: null }), realpath: async value => value,
write: data => {
writes.push(data);
setImmediate(() => terminalCwdStore.setCwd(id, '/next', 'osc7'));
},
},
publish() { assert.fail('an unavailable probe must not publish a guessed cwd'); },
});
t.after(() => controller.dispose());
await controller.change('/next', new AbortController().signal, () => {});
assert.deepEqual(writes, ["cd -- '/next'\r"]);
assert.equal(controller.hasConfirmedCwd(), true);
});
}
test('navigation can probe past its pre-write OSC 7 when the shell stops reporting', async t => {
const id = 'probe-after-pre-write-osc7';
terminalCwdStore.setCwd(id, '/before', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/after', list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} },
publish: cwd => terminalCwdStore.setCwd(id, cwd, 'backend-strict'),
});
t.after(() => controller.dispose());
await controller.change('/after', new AbortController().signal, () => {});
assert.equal(terminalCwdStore.getCwd(id), '/after');
assert.equal(terminalCwdStore.getSource(id), 'backend-strict');
});
for (const report of ['stale', 'other-session', 'other-source', 'fresh-unchanged'] as const) {
test(`OSC 7 confirmation uses this submission's session revision (${report})`, { timeout: 2000 }, async t => {
const id = 'osc7-revision-confirm-' + report;
terminalCwdStore.setCwd(id, '/next', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
io: {
cwd: async () => { throw new Error('no process cwd support'); },
list: async () => ({ entries: [], home: null }), realpath: async value => value,
write() {
if (report !== 'stale') setImmediate(() => terminalCwdStore.setCwd(
report === 'other-session' ? id + '-other' : id, '/next', report === 'other-source' ? 'inferred' : 'osc7',
));
},
},
publish() {},
});
t.after(() => controller.dispose());
const change = controller.change('/next', new AbortController().signal, () => {});
if (report === 'fresh-unchanged') await change;
else await assert.rejects(change, { code: 'changeFailed' });
});
}
for (const cancellation of ['abort', 'dispose'] as const) {
test(`a fresh OSC 7 cannot confirm a cancelled directory change (${cancellation})`, { timeout: 2000 }, async t => {
const id = 'osc7-cancel-' + cancellation;
const abort = new AbortController();
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(() => {}), list: async () => ({ entries: [], home: null }),
realpath: async value => value,
write() { setImmediate(() => {
if (cancellation === 'abort') abort.abort(); else controller.dispose();
terminalCwdStore.setCwd(id, '/next', 'osc7');
}); } },
publish() {},
});
t.after(() => controller.dispose());
await assert.rejects(controller.change('/next', abort.signal, () => {}), { code: 'unavailable' });
});
}
@@ -1,162 +0,0 @@
import { DirectoryPickerError, isDirectoryPath } from '../../domain/directoryPicker';
import { resolveInteractiveTerminalCdIntent } from '../../domain/sessionRestore';
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
import { terminalCwdStore } from './terminalCwdStore';
export type TerminalDirectoryController = ReturnType<typeof createTerminalDirectoryController>;
/** One registration represents one connected interactive shell generation. */
export function createTerminalDirectoryController(options: {
sessionId: string;
io: TerminalDirectoryIO;
isCurrent: () => boolean;
isReady: () => boolean;
publish: (cwd: string) => void;
confirmationMs?: number;
}) {
let disposed = false;
let changing = false;
let confirmed = false;
let refreshing: Promise<string> | null = null;
let refreshAgain = false;
const lifetime = new AbortController();
const current = () => !disposed && options.isCurrent();
function check(signal?: AbortSignal) {
if (!current() || signal?.aborted) throw new DirectoryPickerError('unavailable');
}
function ready() {
check();
if (!options.isReady()) throw new DirectoryPickerError('busy');
}
function bounded<T>(task: Promise<T>, signal?: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const finish = (error?: unknown, value?: T) => {
clearTimeout(timer);
lifetime.signal.removeEventListener('abort', cancel);
signal?.removeEventListener('abort', cancel);
if (error) reject(error); else resolve(value!);
};
const cancel = () => finish(new DirectoryPickerError('unavailable'));
const timer = setTimeout(() => finish(new DirectoryPickerError('readFailed')), 5000);
lifetime.signal.addEventListener('abort', cancel, { once: true });
signal?.addEventListener('abort', cancel, { once: true });
if (signal?.aborted || lifetime.signal.aborted) cancel();
task.then(value => finish(undefined, value), error => finish(error));
});
}
async function refresh(submittedRevision?: number): Promise<string> {
check();
const activeShellCwd = () => {
const cwd = terminalCwdStore.getCwd(options.sessionId);
return cwd && terminalCwdStore.getSource(options.sessionId) === 'osc7'
&& (submittedRevision === undefined || terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision)
? cwd : null;
};
// A process probe may read the outer PTY shell while a nested shell is
// active. OSC 7 remains authoritative across debounce delays, completion
// events and picker remounts. Only a cd submission may probe past its own
// pre-write report while waiting for fresh confirmation.
const reported = activeShellCwd();
if (reported) { confirmed = true; return reported; }
if (refreshing) {
// Output may settle while a pre-cd query is still in flight. Keep one
// follow-up query so that its older result cannot be the last update.
refreshAgain = true;
return refreshing;
}
const pending = (async () => {
const cwd = await bounded(options.io.cwd());
check();
const reported = activeShellCwd();
const live = terminalCwdStore.getCwd(options.sessionId);
confirmed = true;
if (reported) return reported;
if (live !== cwd || terminalCwdStore.getSource(options.sessionId) === 'stale') options.publish(cwd);
return cwd;
})();
refreshing = pending;
try { return await pending; } finally {
if (refreshing === pending) refreshing = null;
if (refreshAgain && current()) {
refreshAgain = false;
void refresh().catch(() => {});
}
}
}
async function change(path: string, signal: AbortSignal, accepted: () => void, expectedCwd?: string) {
ready();
if (changing) throw new DirectoryPickerError('busy');
const intent = isDirectoryPath(path) ? resolveInteractiveTerminalCdIntent(path) : null;
if (!intent) throw new DirectoryPickerError('unsupportedPath');
changing = true;
let confirmationFinished = false;
try {
const canonical = await bounded(options.io.realpath(path, signal), signal);
check(signal);
ready(); // The shell can become busy while resolving a remote symlink.
if (expectedCwd && terminalCwdStore.getCwd(options.sessionId) !== expectedCwd) throw new DirectoryPickerError('unavailable');
const submittedRevision = terminalCwdStore.getSessionRevision(options.sessionId);
options.io.write(intent.command + '\r');
accepted();
const deadline = Date.now() + (options.confirmationMs ?? 8000);
const hasFreshOsc7 = () => {
const cwd = terminalCwdStore.getCwd(options.sessionId);
return terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision
&& terminalCwdStore.getSource(options.sessionId) === 'osc7'
&& (cwd === path || cwd === canonical);
};
const pause = () => new Promise<void>(resolve => {
const done = () => { clearTimeout(timer); lifetime.signal.removeEventListener('abort', done); resolve(); };
const timer = setTimeout(done, 200);
lifetime.signal.addEventListener('abort', done, { once: true });
});
const confirmFromProbe = async () => {
while (!confirmationFinished && Date.now() < deadline) {
check(signal);
if (hasFreshOsc7()) return;
if (options.isReady()) {
try {
const cwd = await refresh(submittedRevision);
check(signal);
if (cwd === path || cwd === canonical || await bounded(options.io.realpath(cwd, signal), signal) === canonical) return;
} catch (error) {
if (!current() || signal.aborted) throw error;
}
}
await pause();
}
if (!confirmationFinished) throw new DirectoryPickerError('changeFailed');
};
// OSC 7 is authoritative even when process cwd lookup is unavailable or
// stalled. Poll revisions too: an unchanged OSC 7 path emits no store event.
const confirmFromOsc7 = async () => {
while (!confirmationFinished) {
check(signal);
if (hasFreshOsc7()) return;
if (Date.now() >= deadline) throw new DirectoryPickerError('changeFailed');
await pause();
}
};
await Promise.race([confirmFromProbe(), confirmFromOsc7()]);
check(signal);
confirmed = true;
} finally { confirmationFinished = true; changing = false; }
}
return {
refresh: () => refresh(),
hasConfirmedCwd: () => confirmed,
async home() {
check();
const result = await bounded(options.io.home?.() ?? Promise.resolve(null));
check(); return result;
},
async list(path: string, signal: AbortSignal) {
check(signal);
const result = await bounded(options.io.list(path, signal), signal);
check(signal);
return result;
},
change,
dispose() { disposed = true; lifetime.abort(); },
};
}
@@ -1,87 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { terminalCwdStore } from './terminalCwdStore';
import {
observeTerminalDirectory, registerTerminalDirectory,
refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh,
} from './terminalDirectoryRegistry';
test('manual terminal output refreshes cwd without a trusted command-completion event', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let cwd = '/before'; let reads = 0;
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: 'unrecognized-prompt', isCurrent: () => true, isReady: () => false,
io: { cwd: async () => { reads++; return cwd; },
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); },
});
const unregister = registerTerminalDirectory('unrecognized-prompt', controller);
const stopObserving = observeTerminalDirectory('unrecognized-prompt');
t.after(() => { stopObserving(); unregister(); });
cwd = '/after-manual-cd';
// Multiple prompt/output chunks produce just one read. No trusted prompt
// detection, command-completed callback, or parsed `cd` is involved.
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
t.mock.timers.tick(80);
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
t.mock.timers.tick(149);
assert.equal(reads, 0);
t.mock.timers.tick(1);
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(published, ['/after-manual-cd']);
assert.equal(reads, 1);
t.mock.timers.tick(60_000);
assert.equal(reads, 1, 'no polling after output has settled');
});
test('settled output and prompt completion preserve the active nested shell OSC 7 cwd', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const id = 'nested-shell-output';
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
// The PTY process stays in its original directory while a child shell
// runs in /. Its cwd is only a fallback for shells without OSC 7.
io: { cwd: async () => '/outer-shell', list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} },
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const unregister = registerTerminalDirectory(id, controller);
const stopObserving = observeTerminalDirectory(id);
t.after(() => { stopObserving(); unregister(); });
scheduleObservedTerminalDirectoryRefresh(id);
terminalCwdStore.setCwd(id, '/', 'osc7');
t.mock.timers.tick(150);
await new Promise(resolve => setImmediate(resolve));
assert.equal(terminalCwdStore.getCwd(id), '/');
// Later output chunks, prompt completion and reopening/manual refresh must
// not treat that same active-shell report as stale merely due to timing.
scheduleObservedTerminalDirectoryRefresh(id);
t.mock.timers.tick(150);
refreshObservedTerminalDirectory(id);
assert.equal(await controller.refresh(), '/');
assert.deepEqual(published, []);
assert.equal(terminalCwdStore.getSource(id), 'osc7');
});
test('disabling chips cancels a queued output refresh and ignores future terminal output', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let reads = 0;
const controller = createTerminalDirectoryController({
sessionId: 'hidden-chips', isCurrent: () => true, isReady: () => true,
io: { cwd: async () => { reads++; return '/unused'; }, list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} }, publish() {},
});
const unregister = registerTerminalDirectory('hidden-chips', controller);
t.after(unregister);
const stopObserving = observeTerminalDirectory('hidden-chips');
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
stopObserving();
t.mock.timers.tick(1000);
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
t.mock.timers.tick(1000);
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 0);
});
@@ -1,58 +0,0 @@
import type { TerminalDirectoryController } from './terminalDirectoryController';
const sessions = new Map<string, TerminalDirectoryController>();
const consumers = new Map<string, number>();
const scheduledRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
const OUTPUT_SETTLE_MS = 150;
function cancelScheduledRefresh(id: string) {
const timer = scheduledRefreshes.get(id);
if (timer !== undefined) clearTimeout(timer);
scheduledRefreshes.delete(id);
}
const listeners = new Set<() => void>();
export const subscribeTerminalDirectories = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
export const getTerminalDirectory = (id: string) => sessions.get(id) ?? null;
export function registerTerminalDirectory(id: string, controller: TerminalDirectoryController) {
cancelScheduledRefresh(id);
sessions.set(id, controller);
listeners.forEach(listener => listener());
return () => {
controller.dispose();
if (sessions.get(id) !== controller) return;
cancelScheduledRefresh(id);
sessions.delete(id);
listeners.forEach(listener => listener());
};
}
export function observeTerminalDirectory(id: string) {
consumers.set(id, (consumers.get(id) ?? 0) + 1);
return () => {
const next = (consumers.get(id) ?? 1) - 1;
if (next) consumers.set(id, next);
else { consumers.delete(id); cancelScheduledRefresh(id); }
};
}
/** Called by the existing command completion path, only while chips are visible. */
export function refreshObservedTerminalDirectory(id: string) {
cancelScheduledRefresh(id);
if (consumers.has(id)) void sessions.get(id)?.refresh().catch(() => {});
}
/**
* Shells with custom prompts may emit neither OSC 7/133 nor a trusted command
* completion. Debounce actual output/submission activity instead of polling or
* interpreting `cd` text. The controller prefers active-shell OSC 7 and uses
* the bound shell's process cwd only as a fallback.
*/
export function scheduleObservedTerminalDirectoryRefresh(id: string) {
if (!consumers.has(id) || !sessions.has(id)) return;
cancelScheduledRefresh(id);
scheduledRefreshes.set(id, setTimeout(() => {
scheduledRefreshes.delete(id);
refreshObservedTerminalDirectory(id);
}, OUTPUT_SETTLE_MS));
}
@@ -3,37 +3,6 @@ import test from 'node:test';
import { createTerminalReconnectRegistry } from './terminalReconnectRegistry';
test('recovery snapshots use the current handler and disappear on unmount', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.inspect('s'), null);
const unregister = registry.register('s', () => {}, () => false,
() => ({ status: 'disconnected', waitingForInput: true, reconnecting: false }));
assert.equal(registry.inspect('s')?.waitingForInput, true);
assert.equal(registry.inspect('s')?.reconnectAllowed, false);
unregister();
assert.equal(registry.inspect('s'), null);
});
test('agent reconnect never queues an unmounted terminal', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.requestIfAvailable('missing'), false);
let calls = 0;
registry.register('missing', () => { calls++; });
assert.equal(calls, 0);
});
test('agent reconnect respects live eligibility and active reconnects', () => {
const registry = createTerminalReconnectRegistry();
let eligible = false;
let calls = 0;
registry.register('s', () => { calls++; registry.setActive('s', true); }, () => eligible);
assert.equal(registry.requestIfAvailable('s'), false);
eligible = true;
assert.equal(registry.requestIfAvailable('s'), true);
assert.equal(registry.requestIfAvailable('s'), false);
assert.equal(calls, 1);
});
test('a registered terminal session can be reconnected on request', () => {
const registry = createTerminalReconnectRegistry();
const requestedSessionIds: string[] = [];
+1 -27
View File
@@ -1,10 +1,7 @@
type TerminalReconnectHandler = () => void;
export type TerminalRecoverySnapshot = { status: string; waitingForInput: boolean; reconnecting: boolean; stopped?: boolean };
export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>();
const eligibility = new Map<string, () => boolean>();
const recoveryReaders = new Map<string, () => TerminalRecoverySnapshot>();
const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>();
@@ -31,40 +28,19 @@ export const createTerminalReconnectRegistry = () => {
return () => listeners.delete(listener);
};
const register = (sessionId: string, handler: TerminalReconnectHandler,
canReconnect: () => boolean = () => false, readRecovery?: () => TerminalRecoverySnapshot): (() => void) => {
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
handlers.set(sessionId, handler);
eligibility.set(sessionId, canReconnect);
if (readRecovery) recoveryReaders.set(sessionId, readRecovery);
else recoveryReaders.delete(sessionId);
if (pendingRequests.delete(sessionId)) {
handler();
}
return () => {
if (handlers.get(sessionId) === handler) {
handlers.delete(sessionId);
eligibility.delete(sessionId);
recoveryReaders.delete(sessionId);
setActive(sessionId, false);
}
};
};
const inspect = (sessionId: string) => {
const state = recoveryReaders.get(sessionId)?.();
if (!state) return null;
return { ...state, reconnecting: state.reconnecting || isActive(sessionId),
reconnectAllowed: !isActive(sessionId) && Boolean(eligibility.get(sessionId)?.()) };
};
// Agent requests must never survive unmount or bypass authentication/auto-retry.
const requestIfAvailable = (sessionId: string): boolean => {
const handler = handlers.get(sessionId);
if (!handler || isActive(sessionId) || !eligibility.get(sessionId)?.()) return false;
handler();
return true;
};
const request = (sessionId: string): boolean => {
if (isActive(sessionId)) return false;
const handler = handlers.get(sessionId);
@@ -90,9 +66,7 @@ export const createTerminalReconnectRegistry = () => {
return {
register,
inspect,
request,
requestIfAvailable,
requestEnterReconnect,
setEnterReconnectEnabled,
setActive,
@@ -1,215 +0,0 @@
/**
* Minimal in-memory IndexedDB stub covering the exact subset used by
* `aiSessionIdbStorage.ts`: open + upgrade (createObjectStore), readonly
* getAll/get, readwrite put/delete/clear, and meta-store records. Not a
* general-purpose IDB implementation — extend deliberately if the wrapper
* grows new usage.
*/
type Resolve = () => void;
const microtask2 = (fn: Resolve) => {
queueMicrotask(() => queueMicrotask(fn));
};
export class FakeRequest<T> {
onsuccess: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
onerror: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
onupgradeneeded: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
result: T;
error: Error | null = null;
constructor(result: T, mode: 'immediate' | 'upgrade' | 'failure' = 'immediate', error?: Error) {
this.result = result;
if (mode === 'failure') {
this.error = error ?? new Error('fake idb failure');
queueMicrotask(() => this.onerror?.(this));
return;
}
queueMicrotask(() => {
if (mode === 'upgrade') this.onupgradeneeded?.(this);
this.onsuccess?.(this);
});
}
}
interface StoreSpec {
name: string;
keyPath: string;
}
export class FakeObjectStore {
/** Instrumentation for per-record write assertions. */
putCount = 0;
deleteCount = 0;
constructor(
public readonly spec: StoreSpec,
public readonly records: Map<string, unknown>,
) {}
put(value: unknown): FakeRequest<IDBValidKey> {
this.putCount += 1;
const key = (value as Record<string, unknown>)[this.spec.keyPath] as string;
this.records.set(key, value);
return new FakeRequest(key);
}
delete(key: IDBValidKey): FakeRequest<undefined> {
this.deleteCount += 1;
this.records.delete(String(key));
return new FakeRequest(undefined);
}
clear(): FakeRequest<undefined> {
this.records.clear();
return new FakeRequest(undefined);
}
get(key: IDBValidKey): FakeRequest<unknown> {
return new FakeRequest(this.records.get(String(key)));
}
getAll(): FakeRequest<unknown[]> {
return new FakeRequest(Array.from(this.records.values()));
}
}
export class FakeTransaction {
oncomplete: Resolve | null = null;
onabort: ((ev?: unknown) => void) | null = null;
onerror: ((ev?: unknown) => void) | null = null;
error: Error | null = null;
constructor(
private readonly database: FakeIndexedDb,
private readonly storeName: string,
private readonly fail: boolean,
) {
microtask2(() => {
if (this.fail) {
this.error = new Error('fake transaction failure');
this.onerror?.(this);
this.onabort?.(this);
return;
}
this.oncomplete?.();
});
}
objectStore(name: string): FakeObjectStore {
if (name !== this.storeName) {
throw new Error(`fake idb: transaction opened for "${this.storeName}", not "${name}"`);
}
return this.database.getOrCreateStore(name);
}
}
export class FakeIndexedDb {
/** DOMStringList-like: the wrapper calls `.contains()` (browser IDB API). */
readonly objectStoreNames = new Set<string>() as Set<string> & { contains(name: string): boolean };
constructor() {
this.objectStoreNames.contains = (name: string) => this.objectStoreNames.has(name);
}
private readonly storeSpecs = new Map<string, string>();
private readonly records = new Map<string, Map<string, unknown>>();
/** Instrumentation per store: { put, delete } counters. */
readonly opCounts = new Map<string, { put: number; delete: number }>();
onclose: (() => void) | null = null;
onversionchange: (() => void) | null = null;
createObjectStore(name: string, options?: { keyPath?: string }) {
this.objectStoreNames.add(name);
this.storeSpecs.set(name, options?.keyPath ?? 'key');
return { name };
}
getOrCreateStore(name: string): FakeObjectStore {
if (!this.storeSpecs.has(name)) {
this.storeSpecs.set(name, 'key');
this.objectStoreNames.add(name);
}
if (!this.records.has(name)) this.records.set(name, new Map());
const spec = { name, keyPath: this.storeSpecs.get(name)! };
const store = new FakeObjectStore(spec, this.records.get(name)!);
const counts = this.opCounts.get(name) ?? { put: 0, delete: 0 };
// Aggregate counts lazily via proxy-free bookkeeping on each op.
const originalPut = store.put.bind(store);
store.put = (value: unknown) => {
counts.put += 1;
this.opCounts.set(name, counts);
return originalPut(value);
};
const originalDelete = store.delete.bind(store);
store.delete = (key: IDBValidKey) => {
counts.delete += 1;
this.opCounts.set(name, counts);
return originalDelete(key);
};
return store;
}
transaction(storeName: string, _mode?: string): FakeTransaction {
return new FakeTransaction(this, storeName, false);
}
close() {
this.onclose?.();
}
/** Direct read for assertions (bypasses the async wrapper). */
dumpStore(name: string): unknown[] {
return Array.from(this.records.get(name)?.values() ?? []);
}
}
export interface FakeIndexedDbHandle {
db: FakeIndexedDb;
openCount: number;
restore: () => void;
}
export function installFakeIndexedDb(options: { failOpen?: boolean; failOpenTimes?: number; onOperation?: (storeName: string, op: 'put' | 'delete') => void } = {}): FakeIndexedDbHandle {
const previous = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
const handle: FakeIndexedDbHandle = {
db: new FakeIndexedDb(),
openCount: 0,
restore: () => {
(globalThis as { indexedDB?: IDBFactory }).indexedDB = previous;
},
};
if (options.onOperation) {
const onOperation = options.onOperation;
const originalGet = handle.db.getOrCreateStore.bind(handle.db);
handle.db.getOrCreateStore = (name: string) => {
const store = originalGet(name);
const originalPut = store.put.bind(store);
store.put = (value: unknown) => {
onOperation(name, 'put');
return originalPut(value);
};
const originalDelete = store.delete.bind(store);
store.delete = (key: IDBValidKey) => {
onOperation(name, 'delete');
return originalDelete(key);
};
return store;
};
}
(globalThis as { indexedDB?: unknown }).indexedDB = {
open(_name: string, _version: number) {
handle.openCount += 1;
if (options.failOpen) {
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
}
if (options.failOpenTimes != null && handle.openCount <= options.failOpenTimes) {
// Fail only the first N opens, then recover (boot-retry scenarios).
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
}
return new FakeRequest<IDBDatabase>(handle.db as unknown as IDBDatabase, 'upgrade');
},
};
return handle;
}
-29
View File
@@ -12,7 +12,6 @@ import {
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
@@ -35,10 +34,6 @@ import {
normalizeResponseIdleTimeoutSeconds,
} from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import {
DEFAULT_AI_SESSION_MESSAGE_CAP,
normalizeAiSessionMessageCap,
} from '../../domain/aiSessionMessageCap';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean';
@@ -92,12 +87,6 @@ export function useAISettingsState() {
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
const [aiSessionMessageCap, setAiSessionMessageCapRaw] = useState<number>(() =>
normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
)
);
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
@@ -208,12 +197,6 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetMaxIterations?.(value);
}, []);
const setAiSessionMessageCap = useCallback((value: number) => {
const normalizedValue = normalizeAiSessionMessageCap(value);
setAiSessionMessageCapRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP, normalizedValue);
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
@@ -294,14 +277,6 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
break;
}
case STORAGE_KEY_AI_SESSION_MESSAGE_CAP:
setAiSessionMessageCapRaw(
normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
),
);
break;
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
@@ -365,8 +340,6 @@ export function useAISettingsState() {
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
aiSessionMessageCap,
setAiSessionMessageCap,
webSearchConfig,
setWebSearchConfig,
quickMessages,
@@ -400,8 +373,6 @@ export function useAISettingsState() {
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
aiSessionMessageCap,
setAiSessionMessageCap,
webSearchConfig,
setWebSearchConfig,
quickMessages,
@@ -1,489 +0,0 @@
/**
* Boot-scenario regression tests for the IndexedDB migration review fixes
* (issue #4125 review): P1 maintenance gating and complete legacy migration,
* P2① baseline seeding after an IndexedDB failure,
* P2② pointer validation gated on hydration, P2③ disk-backup restore when
* IndexedDB stays unopenable, and P2④ adopting an intentional cross-window
* deletion.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
} from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { getAISessionsSnapshot } from './aiSessionsStore';
import {
__flushIdbWritesForTests,
__resetAiSessionsHydrationForTests,
__resetBackupMirrorForTests,
__resetIdbPersistenceForTests,
getPersistedAiSessionsSnapshot,
flushBackupMirror,
latestAISessionsSnapshot,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
setLatestAIPanelViewByScopeSnapshot,
setLatestAISessionsSnapshot,
} from './aiStateSnapshots';
import {
idbDeleteSessionIds,
AI_SESSIONS_META_MIGRATION_KEY,
idbPutSessions,
loadOrMigrateAiSessions,
openAiSessionsIdb,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
function makeSession(id: string, updatedAt: number): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'global' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as AISession;
}
function installDom() {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
return dom;
}
interface Mounted {
ai: ReturnType<typeof useAIState>;
root: ReturnType<typeof createRoot>;
container: HTMLDivElement;
}
async function mountAiState(onMaintenance?: (ai: ReturnType<typeof useAIState>) => void): Promise<Mounted> {
const holder: { ai?: ReturnType<typeof useAIState> } = {};
function Maintenance({ ai }: { ai: ReturnType<typeof useAIState> }) {
const mountAi = React.useRef(ai);
React.useEffect(() => { onMaintenance?.(mountAi.current); }, []);
return null;
}
function Harness() {
holder.ai = useAIState();
return onMaintenance ? React.createElement(Maintenance, { ai: holder.ai }) : null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => { root.render(React.createElement(Harness)); });
return { ai: holder.ai!, root, container };
}
async function unmount(mounted: Mounted, dom: JSDOM) {
await act(async () => { mounted.root.unmount(); });
mounted.container.remove();
dom.window.close();
}
function resetBootState() {
// Post-boot defaults first, then back to pending so every test starts
// from the pre-hydration state its own hook instance controls.
__resetIdbPersistenceForTests();
__resetAiSessionsHydrationForTests();
// Module-level snapshots leak across tests (the hook's initial state and
// the boot merge path read them) — reset to the cold-start state. The
// map snapshot must be null so the hook falls back to localStorage.
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot(null as unknown as Record<string, string | null>);
setLatestAIDraftsByScopeSnapshot({});
setLatestAIPanelViewByScopeSnapshot({});
}
test.beforeEach(() => {
__resetBackupMirrorForTests();
resetBootState();
});
test.afterEach(() => { __resetBackupMirrorForTests(); });
test('P1: deleting the last migrated chat with no legacy key never restores a stale disk backup', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const deleted = makeSession('deleted-chat', 1);
await loadOrMigrateAiSessions(() => JSON.stringify([deleted]));
await idbDeleteSessionIds(await openAiSessionsIdb(), [deleted.id]);
let backupReads = 0;
const backupWrites: string[] = [];
Object.assign(dom.window, {
netcatty: {
aiSessionsBackupReadLatest: async () => {
backupReads += 1;
return { ok: true, json: JSON.stringify([deleted]) };
},
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
},
},
});
const mounted = await mountAiState();
try {
flushBackupMirror();
await __flushIdbWritesForTests();
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), null);
assert.equal(backupReads, 0);
assert.deepEqual(backupWrites, []);
assert.deepEqual(getAISessionsSnapshot().sessions, []);
assert.deepEqual(idb.db.dumpStore('sessions'), []);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: an uninitialized IndexedDB still restores the disk backup', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const backup = [makeSession('backup-chat', 1)];
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
});
const mounted = await mountAiState();
try {
await __flushIdbWritesForTests();
assert.deepEqual(getAISessionsSnapshot().sessions, backup);
assert.deepEqual(idb.db.dumpStore('sessions'), backup);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
for (const failure of ['not-ok', 'rejection', 'missing-id'] as const) {
test(`P1: disk overflow ${failure} keeps migration pending and backups intact, then retries beside primary IDB records`, async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const chunk = makeSession('chunk-chat', 2);
const diskOnly = makeSession('disk-only-chat', 1);
const unmarked = makeSession('unmarked-deleted-chat', 0);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([chunk]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
let recoveryAvailable = false;
let backupReads = 0;
const backupWrites: string[] = [];
Object.assign(dom.window, {
netcatty: {
aiSessionsBackupReadLatest: async () => {
backupReads += 1;
if (recoveryAvailable) return { ok: true, json: JSON.stringify([primary, chunk, diskOnly, unmarked]) };
if (failure === 'rejection') throw new Error('disk temporarily unavailable');
if (failure === 'missing-id') return { ok: true, json: JSON.stringify([unmarked]) };
return { ok: false };
},
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
},
},
});
let mounted = await mountAiState();
const hasMarker = () => idb.db.dumpStore('meta').some(entry => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY);
try {
assert.equal(backupReads, 1);
assert.equal(hasMarker(), false);
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, chunk]);
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, chunk]);
flushBackupMirror();
assert.deepEqual(backupWrites, [], 'boot must not mirror a partial migration');
// Ordinary edits and unload must also preserve the recovery generation.
await act(async () => {
mounted.ai.addMessageToSession(primary.id, { id: 'new-message', role: 'user', content: 'New edit', timestamp: 4 });
});
await new Promise(resolve => setTimeout(resolve, 600));
await __flushIdbWritesForTests();
dom.window.dispatchEvent(new dom.window.Event('beforeunload'));
assert.deepEqual(backupWrites, []);
const updated = getAISessionsSnapshot().sessions.find(session => session.id === primary.id)!;
assert.equal(updated.messages.length, 1);
assert.equal(hasMarker(), false);
await act(async () => { mounted.root.unmount(); });
mounted.container.remove();
resetBootState();
recoveryAvailable = true;
mounted = await mountAiState();
await __flushIdbWritesForTests();
assert.equal(backupReads, 2, 'populated IDB must still retry unresolved disk IDs');
assert.equal(hasMarker(), true);
const expected = [updated, chunk, diskOnly];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
flushBackupMirror();
assert.equal(backupWrites.length, 1, 'mirroring resumes only after all marked IDs are recovered');
assert.deepEqual(JSON.parse(backupWrites[0]), expected);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
}
test('P1: child mount maintenance waits for hydration and marks the IndexedDB transcript dangling', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const stale: AISession = {
...makeSession('closed-chat', 1),
scope: { type: 'terminal', targetId: 'closed-terminal' },
messages: [{ id: 'm-1', role: 'user', content: 'Old history', timestamp: 1 }],
};
const current: AISession = {
...stale,
updatedAt: 2,
messages: [...stale.messages, { id: 'm-2', role: 'assistant', content: 'New history', timestamp: 2 }],
};
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([stale]));
await idbPutSessions(await openAiSessionsIdb(), [current]);
setLatestAISessionsSnapshot(null as unknown as AISession[]);
let snapshotAfterMaintenance: AISession[] | null | undefined;
let putsAfterMaintenance = 0;
const mounted = await mountAiState((ai) => {
ai.cleanupOrphanedSessions(new Set(['live-terminal']));
snapshotAfterMaintenance = latestAISessionsSnapshot;
putsAfterMaintenance = idb.db.opCounts.get('sessions')!.put;
});
try {
assert.equal(snapshotAfterMaintenance, null, 'pending maintenance must not adopt the legacy transcript');
assert.equal(putsAfterMaintenance, 1, 'pending maintenance must not persist stale history');
await __flushIdbWritesForTests();
const expected = [{ ...current, scopeDangling: true }];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), JSON.stringify([stale]));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: migration includes chunked overflow alongside a populated primary', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const overflow = [makeSession('overflow-a', 2), makeSession('overflow-b', 1), { ...primary, updatedAt: 0 }];
const overflowJson = JSON.stringify(overflow);
const split = Math.floor(overflowJson.length / 2);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, overflowJson.slice(0, split));
dom.window.localStorage.setItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`, overflowJson.slice(split));
const mounted = await mountAiState();
try {
const expected = [primary, ...overflow.slice(0, 2)];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW), overflowJson.slice(0, split));
assert.equal(dom.window.localStorage.getItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), overflowJson.slice(split));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: migration awaits disk-only recovery before recording completion beside a populated primary', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const diskOnly = makeSession('disk-only-chat', 2);
const deleted = makeSession('deleted-chat', 1);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
let resolveBackup!: (result: { ok: boolean; json: string }) => void;
const backup = new Promise<{ ok: boolean; json: string }>((resolve) => { resolveBackup = resolve; });
let backupReads = 0;
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: () => { backupReads += 1; return backup; } },
});
const mounted = await mountAiState();
try {
assert.equal(backupReads, 1);
assert.equal(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY), false);
await act(async () => {
resolveBackup({ ok: true, json: JSON.stringify([primary, diskOnly, deleted]) });
});
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, diskOnly]);
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, diskOnly]);
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), JSON.stringify([diskOnly.id]));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
// ── P2①: idbFailed boot must persist the legacy set before seeding the baseline ──
test('P2①a: after a transient IndexedDB open failure the legacy sessions are persisted and the baseline seeded', async () => {
const dom = installDom();
const idb = installFakeIndexedDb({ failOpenTimes: 1 });
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
const mounted = await mountAiState();
try {
// One open retry happened inside the boot effect: the legacy set now
// lives in IndexedDB (old code never retried and left the store empty).
const ids = idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
assert.deepEqual(ids, ['chat-a', 'chat-b']);
assert.deepEqual(
getPersistedAiSessionsSnapshot().map((s) => s.id).sort(),
['chat-a', 'chat-b'],
);
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P2①b: with IndexedDB unreachable the baseline stays unset, so a later edit rewrites every record', async () => {
const dom = installDom();
const failing = installFakeIndexedDb({ failOpen: true });
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
const mounted = await mountAiState();
let recovered: ReturnType<typeof installFakeIndexedDb> | null = null;
try {
// The retry open also failed: nothing was persisted, and the diff
// baseline must remain unset.
assert.deepEqual(failing.db.dumpStore('sessions'), []);
// IndexedDB recovers, then the user edits ONE session. The full set
// must reach the store, not only the edited record.
failing.restore();
resetAiSessionsIdbForTests();
recovered = installFakeIndexedDb();
await act(async () => {
mounted.ai.addMessageToSession('chat-a', { id: 'm-1', role: 'user', content: 'Edited', timestamp: 3 });
});
await new Promise((resolve) => setTimeout(resolve, 600)); // debounced persist
await act(async () => {});
await __flushIdbWritesForTests();
const ids = recovered.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
assert.deepEqual(ids, ['chat-a', 'chat-b']);
} finally {
await unmount(mounted, dom);
if (recovered) recovered.restore();
resetAiSessionsIdbForTests();
}
});
// ── P2②: pointer validation waits for hydration ──
test('P2②: pointers survive cold mount before hydration and stay valid after the boot load', async () => {
const dom = installDom();
const source = makeSession('chat-1', 1);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
dom.window.localStorage.setItem(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
JSON.stringify({ 'terminal:t1': 'chat-1' }),
);
const mounted = await mountAiState();
try {
// Old code nulled the pointer here: the validation effect ran against
// the initially-empty in-memory session array.
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
assert.equal(
JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)!)['terminal:t1'],
'chat-1',
);
// After hydration the session is restored and the pointer is intact.
await act(async () => {});
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
} finally {
await unmount(mounted, dom);
}
});
// ── P2③: unopenable IndexedDB + corrupt legacy → disk backup is applied ──
test('P2③: with IndexedDB unopenable and the legacy store corrupt the newest disk backup generation is applied', async () => {
const dom = installDom();
installFakeIndexedDb({ failOpen: true });
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, 'not-json'); // corrupt legacy
const backup = [makeSession('backup-chat', 5)];
const warns: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => { warns.push(args); };
try {
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
});
const mounted = await mountAiState();
try {
await act(async () => {});
// The backup generation became the recovery source (old code: the
// failing IndexedDB recheck rejection was swallowed and the restore
// never ran).
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['backup-chat']);
assert.deepEqual(getPersistedAiSessionsSnapshot().map((s) => s.id), ['backup-chat']);
// The unopenable-store error was surfaced, not swallowed.
assert.ok(warns.some((args) => String(args[0]).includes('IndexedDB recheck failed')));
} finally {
await unmount(mounted, dom);
}
} finally {
console.warn = originalWarn;
delete (dom.window as unknown as { netcatty?: unknown }).netcatty;
resetAiSessionsIdbForTests();
}
});
// ── P2④: an empty getAll after hydration is an intentional deletion ──
test('P2④: adopting an empty IndexedDB store after another window deleted the last chat', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const source = makeSession('chat-1', 1);
await idbPutSessions(await openAiSessionsIdb(), [source]);
const mounted = await mountAiState();
try {
await act(async () => {});
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
// Another window deletes the last chat and announces the change. The
// announcement must come from a second BroadcastChannel: a channel never
// delivers to itself (the module-level channel is the receiving window).
await idbDeleteSessionIds(await openAiSessionsIdb(), ['chat-1']);
const otherWindow = new BroadcastChannel('netcatty_ai_sessions_changed');
await act(async () => {
otherWindow.postMessage(Date.now());
await new Promise((resolve) => setTimeout(resolve, 25));
});
otherWindow.close();
// Old code early-returned on the empty getAll and kept the deleted
// session alive locally.
assert.deepEqual(getAISessionsSnapshot().sessions, []);
assert.deepEqual(getPersistedAiSessionsSnapshot(), []);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
+4 -11
View File
@@ -8,7 +8,7 @@ import type { AISession } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
@@ -121,18 +121,11 @@ for (const newerCount of [49, 50]) {
let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
assert.ok(branch);
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
// nothing is evicted at these sizes) — not in a 50-capped localStorage
// blob.
const saved: AISession[] = getPersistedAiSessionsSnapshot();
assert.equal(saved.length, newerCount + 2);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => root.unmount()); container.remove();
// Reload from the read-only legacy store (the stand-in for what the
// migration persists on a profile without IndexedDB).
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
dom.window.close();
await act(async () => root.unmount()); container.remove(); dom.window.close();
const reloaded = await setupAiState(saved);
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
@@ -53,4 +53,3 @@ for (const enabled of [true, false]) {
}
});
}
+61 -256
View File
@@ -14,8 +14,6 @@ import {
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
@@ -66,23 +64,17 @@ import {
bumpDraftUploadGeneration,
cleanupDeletedAIChatSessions,
cleanupOrphanedAISessions,
flushBackupMirror,
getAIBridge,
getDraftUploadGeneration,
getPersistedAiSessionsSnapshot,
isAiSessionsHydrationComplete,
latestAIActiveSessionMapSnapshot,
latestAIActiveTargetIdsSnapshot,
getAIBridge,
flushBackupMirror,
getDraftUploadGeneration,
latestAIActiveSessionMapSnapshot,
latestAIDraftsByScopeSnapshot,
latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot,
markAiSessionsHydrationComplete,
prepareRestoredSessions,
readAiSessionMessageCap,
rebindActiveSessionMap,
scheduleBackupMirror,
seedLastPersistedSessions,
setAiSessionsBackupRecoveryPending,
rebindActiveSessionMap,
writeSessionsForStorage,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
@@ -91,20 +83,6 @@ import {
type DraftsByScope,
type PanelViewByScope,
} from './aiStateSnapshots';
import {
AI_SESSIONS_IDB_VERSION,
AI_SESSIONS_META_LAST_WRITE_KEY,
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
idbGetAllSessions,
idbPutMeta,
idbPutSessions,
loadOrMigrateAiSessions,
mirrorActiveSessionMapToIdb,
openAiSessionsIdb,
publishAiSessionsIdbChange,
readActiveSessionMapFromIdb,
subscribeAiSessionsIdbChange,
} from './aiSessionIdbStorage';
import {
isMissingOrCorruptSessionsStore,
mergeImportedSessions,
@@ -200,14 +178,10 @@ export function useAIState() {
);
// ── Sessions ──
// Memory-only initial state: the primary store is IndexedDB and loads
// asynchronously in the boot effect below (issue #4123). The legacy
// localStorage key is read-only fallback and must not pre-seed stale data.
const [sessions, setSessionsRaw] = useState<AISession[]>(() => latestAISessionsSnapshot ?? []);
// Issue #4125 review P2②: flips true when the boot load settles. The
// active-pointer validation effect waits for it — the initially-empty
// in-memory session array must never null the persisted pointers.
const [sessionsHydrated, setSessionsHydrated] = useState(() => isAiSessionsHydrationComplete());
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
latestAISessionsSnapshot
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
);
// An explicit empty array is authoritative, even if the backup is stale.
// Only a missing or corrupt store can be restored from disk.
useEffect(() => {
@@ -215,158 +189,48 @@ export function useAIState() {
return () => window.removeEventListener('beforeunload', flushBackupMirror);
}, []);
useEffect(() => {
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) {
if (readLiveSessionsArray(liveRaw).length > 0 && liveRaw != null) scheduleBackupMirror(liveRaw);
return;
}
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
let cancelled = false;
let cleanupAfterHydration = false;
void (async () => {
try {
// 1. IndexedDB (primary) → one-time legacy localStorage migration.
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
setAiSessionsBackupRecoveryPending(diskOnlyIds.size > 0);
const boot = await loadOrMigrateAiSessions(
async () => {
// Legacy retention split history between the primary key, JSON
// chunks, and explicitly marked disk-only records. Resolve all
// slices before the one-shot migration records completion.
const chunks: string[] = [];
for (let index = 0; ; index += 1) {
const key = index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
const chunk = localStorageAdapter.readString(key);
if (chunk == null) break;
chunks.push(chunk);
}
let legacy = mergeImportedSessions(
readLiveSessionsArray(chunks.join('')),
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
);
if (diskOnlyIds.size > 0) {
try {
const backup = await getAIBridge()?.aiSessionsBackupReadLatest?.();
if (backup?.ok) {
legacy = mergeImportedSessions(
(parseBackupSessions(backup.json) ?? []).filter(session => diskOnlyIds.has(session.id)),
legacy,
);
}
} catch (error) {
console.warn('[AIState] Disk-only session recovery failed; migration will retry on the next boot:', error);
}
}
return JSON.stringify(legacy);
},
[...diskOnlyIds],
);
setAiSessionsBackupRecoveryPending(boot.migrationPending);
if (cancelled) return;
if (boot.idbFailed && boot.sessions.length > 0) {
// Issue #4125 review P2①: the legacy set is not in IndexedDB yet.
// Persist it best-effort (one re-open retry) BEFORE seeding the
// diff baseline; if the store is still unreachable, leave the
// baseline unset so the next edit performs a full write instead of
// a diff that would drop the un-persisted sessions after IndexedDB
// recovers.
try {
const db = await openAiSessionsIdb();
if (!cancelled) {
await idbPutSessions(db, boot.sessions);
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
publishAiSessionsIdbChange();
seedLastPersistedSessions(boot.sessions);
}
} catch (error) {
console.warn(
'[AIState] Could not persist the legacy sessions after an IndexedDB failure;'
+ ' the diff baseline stays unset so the next edit rewrites every record:',
error,
);
}
} else {
seedLastPersistedSessions(boot.sessions);
}
// Restore the mirrored active-pointer map when the localStorage key
// is missing (fresh profile restored from IndexedDB).
if (localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) == null) {
const mirrored = await readActiveSessionMapFromIdb();
if (cancelled) return;
if (mirrored && Object.keys(mirrored).length > 0) {
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, mirrored);
setLatestAIActiveSessionMapSnapshot(mirrored);
setActiveSessionIdMapRaw(mirrored);
}
}
const bootSessions = boot.sessions;
if (bootSessions.length > 0) {
const live = latestAISessionsSnapshot ?? sessionsRef.current;
if (live.length > 0) {
// Sessions were created before the async load finished: merge
// (live records win by id) instead of clobbering either side.
const byId = new Map(bootSessions.map(session => [session.id, session]));
for (const session of live) byId.set(session.id, session);
const merged = Array.from(byId.values())
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
setLatestAISessionsSnapshot(merged);
sessionsRef.current = merged;
setSessionsRaw(merged);
writeSessionsForStorage(merged);
return;
}
setLatestAISessionsSnapshot(bootSessions);
sessionsRef.current = bootSessions;
setSessionsRaw(bootSessions);
scheduleBackupMirror(JSON.stringify(bootSessions));
cleanupAfterHydration = true;
return;
}
// A previously initialized empty IDB is a committed deletion. An
// incomplete migration must retry only its marked disk-only records.
if (boot.authoritativeEmpty || boot.migrationPending) return;
// 2. Uninitialized store (and no legacy data to migrate): the #4018 disk
// backup remains the last restore step. Only when the legacy store
// is also missing or corrupt — a valid empty legacy array is an
// intentional wipe and stays authoritative.
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
const backup = await bridge.aiSessionsBackupReadLatest?.();
if (cancelled || !backup?.ok) return;
// Re-check after the async gap: a session may have been created
// while the backup was being read; never restore over live state.
// Cover persisted storage and in-memory snapshot/React state —
// Cover both persisted storage and in-memory snapshot/React state —
// createSession updates the snapshot before the debounced disk write.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
// Issue #4125 review P2③: one open retry already happened inside
// the boot load. If the store is still unopenable, do not let the
// recheck rejection reach the outer catch — the disk backup below
// is the recovery source.
try {
if ((await idbGetAllSessions()).length > 0) return;
} catch (error) {
console.warn(
'[AIState] IndexedDB recheck failed during the disk-backup restore;'
+ ' applying the newest backup generation as the recovery source:',
error,
);
}
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
if (restored.length === 0) return;
// Apply restore synchronously (not inside a setState updater). A
// deferred updater would leave didRestore false and skip dangling
// marking even when the records later land in React state.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
setLatestAISessionsSnapshot(restored);
writeSessionsForStorage(restored);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
cleanupAfterHydration = true;
// Restore does not change live terminals/workspaces, so the orphan-
// cleanup effect will not run. Reuse the last known live target set
// (empty is valid: nothing live) so chats bound to missing scopes
// become dangling and /chats can offer /rebind.
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions = latestAISessionsSnapshot ?? restored;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
@@ -376,25 +240,10 @@ export function useAIState() {
?? {},
);
console.info(
`[AIState] Restored ${nextSessions.length} AI session(s) from the disk backup.`,
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
);
} catch (error) {
// Boot load must never break startup — but it must not vanish
// silently either (issue #4125 review P2③).
console.warn('[AIState] AI session boot load failed:', error);
} finally {
markAiSessionsHydrationComplete();
if (!cancelled) {
// Maintenance may have mounted before hydration with unchanged live
// targets. Replay it now against the authoritative boot history.
if (cleanupAfterHydration) {
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions = latestAISessionsSnapshot ?? sessionsRef.current;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
}
setSessionsHydrated(true);
}
} catch {
// Backup restore must never break startup.
}
})();
return () => {
@@ -463,9 +312,6 @@ export function useAIState() {
useEffect(() => {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
// Issue #4123: mirror the active-pointer map into the IndexedDB meta
// store (localStorage stays written at the mutation sites as before).
void mirrorActiveSessionMapToIdb(activeSessionIdMap);
}, [activeSessionIdMap]);
useEffect(() => {
@@ -477,11 +323,6 @@ export function useAIState() {
}, [panelViewByScope]);
useEffect(() => {
// Issue #4125 review P2②: before boot hydration settles, `sessions` is
// the initially-empty in-memory array; validating pointers against it
// would null every pointer and break workspace restore. No pointer
// writes before hydration completes.
if (!sessionsHydrated) return;
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
@@ -510,7 +351,7 @@ export function useAIState() {
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
return next;
});
}, [sessions, activeSessionIdMap, sessionsHydrated]);
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
@@ -702,8 +543,7 @@ export function useAIState() {
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
try {
const key = e.key;
switch (key) {
switch (e.key) {
case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
if (parsed != null && !Array.isArray(parsed)) {
@@ -795,10 +635,7 @@ export function useAIState() {
break;
}
case STORAGE_KEY_AI_SESSIONS: {
// Session writes no longer land in localStorage (issue #4123);
// the cross-window signal is the IndexedDB change channel and the
// storage event only fires as a legacy fallback.
const nextSessions = getPersistedAiSessionsSnapshot();
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
setLatestAISessionsSnapshot(nextSessions);
setSessionsRaw(nextSessions);
break;
@@ -840,7 +677,8 @@ export function useAIState() {
case STORAGE_KEY_AI_SESSIONS:
setSessionsRaw(
latestAISessionsSnapshot
?? getPersistedAiSessionsSnapshot(),
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [],
);
return;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
@@ -867,37 +705,6 @@ export function useAIState() {
};
}, []);
// Cross-window AI session sync: session writes land in IndexedDB now, so
// the localStorage `storage` event no longer fires for them. Other windows
// announce their writes on a BroadcastChannel; the receiver reloads the
// store from IndexedDB.
useEffect(() => subscribeAiSessionsIdbChange(() => {
void (async () => {
try {
const next = await idbGetAllSessions();
if (next.length === 0) {
if (!isAiSessionsHydrationComplete()) return;
// Issue #4125 review P2④: after hydration an empty store is an
// intentional cross-window deletion — adopt it instead of keeping
// the deleted session alive locally (its next edit would
// resurrect it). The pointer-validation effect clears the
// now-stale pointers. Only the pre-hydration window keeps the
// early-return: the boot load owns the store view until it
// settles.
setLatestAISessionsSnapshot([]);
seedLastPersistedSessions([]);
setSessionsRaw([]);
return;
}
setLatestAISessionsSnapshot(next);
seedLastPersistedSessions(next);
setSessionsRaw(next);
} catch {
// Cross-window refresh is best-effort.
}
})();
}), []);
// ── Sync initial safety settings to MCP Server on mount ──
useEffect(() => {
const bridge = getAIBridge();
@@ -923,8 +730,8 @@ export function useAIState() {
}, []);
// ── Session CRUD ──
const persistSessions = useCallback((next: AISession[], deletedSessionIds?: readonly string[]) => {
writeSessionsForStorage(next, { deletedSessionIds });
const persistSessions = useCallback((next: AISession[]) => {
writeSessionsForStorage(next);
}, []);
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
@@ -1069,7 +876,7 @@ export function useAIState() {
setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId);
setLatestAISessionsSnapshot(next);
persistSessions(next, [sessionId]);
persistSessions(next);
return next;
});
if (scopeKey) {
@@ -1113,7 +920,7 @@ export function useAIState() {
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
});
setLatestAISessionsSnapshot(next);
persistSessions(next, removedSessionIds);
persistSessions(next);
return next;
});
const scopeKey = `${scopeType}:${targetId}`;
@@ -1209,10 +1016,11 @@ export function useAIState() {
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
if (!writeSessionsForStorage(next)) return 0;
const persisted = readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
const importedIds = new Set(imported.map(session => session.id));
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
setSessionsRaw(next);
sessionsRef.current = persisted;
setLatestAISessionsSnapshot(persisted);
setSessionsRaw(persisted);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Import does not change live terminals/workspaces, so the orphan-cleanup
// effect will not run. Reuse the last known live target set (empty is
@@ -1221,7 +1029,7 @@ export function useAIState() {
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions =
latestAISessionsSnapshot
?? next;
?? persisted;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
@@ -1229,7 +1037,7 @@ export function useAIState() {
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return nextSessions.filter(session => importedIds.has(session.id)).length;
return persisted.filter(session => importedIds.has(session.id)).length;
}, []);
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
@@ -1245,27 +1053,22 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
// Local-only retention. Synced history must match the full-history storage
// policy. The in-memory bound follows the user-configurable per-session cap
// (see aiStateSnapshots.readAiSessionMessageCap) but never below this floor,
// so the default behavior is unchanged and RAM stays bounded when the user
// lowers the persisted cap.
const MIN_IN_MEMORY_MESSAGES_PER_SESSION = 500;
// Local-only retention. Synced history must match the full-history storage policy.
const MAX_MESSAGES_PER_SESSION = 500;
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
const inMemoryCap = Math.max(readAiSessionMessageCap(), MIN_IN_MEMORY_MESSAGES_PER_SESSION);
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
let msgs = [...s.messages, message];
// Dropping synced prefixes makes the next cloud merge add them back.
if (!syncEnabled && msgs.length > inMemoryCap) {
if (!syncEnabled && msgs.length > MAX_MESSAGES_PER_SESSION) {
const systemMsgs = msgs.filter(m => m.role === 'system');
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
const dropped = nonSystemMsgs.length - (inMemoryCap - systemMsgs.length);
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${inMemoryCap} limit`);
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-inMemoryCap + systemMsgs.length)];
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
@@ -1503,11 +1306,11 @@ export function useAIState() {
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
cleanupOrphanedAISessions(activeTargetIds);
if (!isAiSessionsHydrationComplete()) return;
const nextSessions =
latestAISessionsSnapshot
?? getPersistedAiSessionsSnapshot();
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
@@ -1559,7 +1362,8 @@ export function useAIState() {
?? {};
const currentSessions =
latestAISessionsSnapshot
?? getPersistedAiSessionsSnapshot();
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: currentMap,
@@ -1604,7 +1408,8 @@ export function useAIState() {
?? {};
const currentSessions =
latestAISessionsSnapshot
?? getPersistedAiSessionsSnapshot();
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const result = retargetWorkspaceActiveChatAfterMemberLoss({
activeSessionIdMap: currentMap,
sessions: currentSessions,
@@ -9,7 +9,7 @@ import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRunt
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
@@ -175,21 +175,17 @@ for (const newerCount of [49, 50]) {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
});
assert.ok(branchId);
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
// nothing is evicted at these sizes) — not in a 50-capped localStorage blob.
const saved: AISession[] = getPersistedAiSessionsSnapshot();
assert.equal(saved.length, newerCount + 2);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount());
container.remove();
// Seed the read-only legacy store with the persisted set (the stand-in
// for what the migration persists on a profile without IndexedDB).
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
dom.window.close();
container.remove(); dom.window.close();
// Remount the actual hook from the persisted records, as on app reload.
const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
@@ -227,7 +223,7 @@ for (const viewingScope of [
), 3);
const original = capture.sessions.find(item => item.id === source.id)!;
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
const saved = getPersistedAiSessionsSnapshot();
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
await act(async () => root.unmount());
container.remove(); dom.window.close();
@@ -11,10 +11,6 @@ import {
} from "./useActiveChromeTheme.ts";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
import { readFileSync } from "node:fs";
import {
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
} from "./settingsChromeStore.ts";
function createInlineStyle() {
const values = new Map<string, string>();
@@ -102,193 +98,6 @@ test("syncActiveChromeTheme skips surface refresh when the active theme fingerpr
}
});
for (const preference of ["system", "light", "dark"] as const) {
for (const chromeType of ["light", "dark"] as const) {
test(`syncActiveChromeTheme uses ${preference === "system" ? "system" : chromeType} native theme for ${preference} preference and ${chromeType} chrome`, () => {
const globals = globalThis as typeof globalThis & { document?: Document; window?: Window };
const originalDocument = globals.document;
const originalWindow = globals.window;
const originalSnapshot = getSettingsChromeSnapshot();
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === chromeType);
assert.ok(theme);
const themeCalls: string[] = [];
const backgroundCalls: string[] = [];
globals.document = {
documentElement: {
dataset: {},
classList: { remove: () => {}, add: () => {} },
removeAttribute: () => {},
},
getElementById: () => null,
createElement: () => ({}),
head: { appendChild: () => {} },
querySelector: () => null,
} as unknown as Document;
globals.window = {
netcatty: {
setTheme: (value: string) => themeCalls.push(value),
setBackgroundColor: (value: string) => backgroundCalls.push(value),
},
} as unknown as Window & typeof globalThis;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: preference });
syncActiveChromeTheme(theme, () => {
throw new Error("app theme should not be restored for an active chrome theme");
});
assert.deepEqual(themeCalls, [preference === "system" ? "system" : chromeType]);
assert.deepEqual(backgroundCalls, [theme.colors.background]);
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
if (originalDocument) {
globals.document = originalDocument;
} else {
delete globals.document;
}
if (originalWindow) {
globals.window = originalWindow;
} else {
delete globals.window;
}
}
});
}
}
// A hanging jsdom/React render must fail this test quickly instead of
// consuming the whole CI job budget.
test("terminal chrome never replaces the application native theme policy", { timeout: 15000 }, async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const themeSources: string[] = [];
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
testWindow.netcatty = {
setTheme: async (source: string) => { themeSources.push(source); return true; },
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
} as unknown as NetcattyBridge;
globalThis.window = testWindow;
globalThis.document = testWindow.document;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
for (const type of ['light', 'dark'] as const) {
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
assert.ok(theme);
syncActiveChromeTheme(theme, () => {});
assert.equal(document.documentElement.classList.contains(type), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
}
assert.deepEqual(themeSources, ['system', 'system'], 'terminal colors must not pin nativeTheme.themeSource to light/dark');
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("system preference refresh reapplies an unchanged independent terminal chrome", { timeout: 15000 }, async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
testWindow.netcatty = {
setTheme: (source: string) => {
assert.equal(source, 'system', 'chrome refresh must preserve native system theme policy');
return Promise.resolve(true);
},
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
} as unknown as NetcattyBridge;
globalThis.window = testWindow;
globalThis.document = testWindow.document;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
for (const type of ['light', 'dark'] as const) {
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
assert.ok(theme);
syncActiveChromeTheme(theme, () => {});
// The application settings effect runs first on an OS preference change.
document.documentElement.className = type === 'dark' ? 'light' : 'dark';
backgrounds.push('application-background');
syncActiveChromeTheme(theme, () => {}, { refreshSurfaces: true });
assert.equal(document.documentElement.classList.contains(type), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
}
syncActiveChromeTheme(null, () => {
document.documentElement.className = 'light';
backgrounds.push('application-background');
});
assert.equal(document.documentElement.className, 'light');
assert.equal(document.documentElement.dataset.activeChromeTheme, undefined);
assert.equal(document.getElementById('netcatty-active-chrome-theme'), null);
assert.equal(backgrounds.at(-1), 'application-background');
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("chrome hook reacts to OS preference changes with stable terminal props", { timeout: 15000 }, async () => {
const { JSDOM } = await import('jsdom');
const { act, createElement } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useActiveChromeTheme } = await import('./useActiveChromeTheme');
const { getSettingsChromeSnapshot, publishSettingsChromeSnapshot } = await import('./settingsChromeStore');
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const originalActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
globalThis.window = testWindow;
globalThis.document = dom.window.document;
testWindow.netcatty = {
setBackgroundColor: async (color: string) => { backgrounds.push(color); return true; },
} as unknown as NetcattyBridge;
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === 'dark');
assert.ok(theme);
const applyAppTheme = () => {};
function Harness() {
useActiveChromeTheme({ activeTheme: theme!, applyAppTheme });
return null;
}
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'dark' });
let renderer: ReturnType<typeof createRoot> | undefined;
try {
await act(async () => {
renderer = createRoot(document.createElement('div'));
renderer.render(createElement(Harness));
});
assert.equal(document.documentElement.classList.contains('dark'), true);
document.documentElement.className = 'light';
backgrounds.push('application-background');
await act(async () => {
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'light' });
});
assert.equal(document.documentElement.classList.contains('dark'), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
} finally {
await act(async () => { renderer?.unmount(); });
environment.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("active chrome theme foreground uses contrast calculation", () => {
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
+6 -12
View File
@@ -1,6 +1,6 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import { usePluginThemeRevision } from './usePluginThemeRuntime';
import { getSettingsChromeSnapshot, useSettingsChromeStore } from './settingsChromeStore';
import { useSettingsChromeStore } from './settingsChromeStore';
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
@@ -237,11 +237,9 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
}
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
const targetClass = theme.type === "dark" ? "dark" : "light";
if (typeof window !== "undefined") {
// Preserve OS preference events instead of locking nativeTheme to the chrome type.
const targetClass = theme.type === "dark" ? "dark" : "light";
const themeSource = getSettingsChromeSnapshot().theme === "system" ? "system" : targetClass;
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setTheme?.(targetClass);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
}
applyTopTabsChromeThemeVars(theme);
@@ -250,11 +248,10 @@ function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
export function syncActiveChromeTheme(
activeTheme: TerminalTheme | null,
applyAppTheme: () => void,
{ refreshSurfaces = false }: { refreshSurfaces?: boolean } = {},
): void {
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint && !(activeTheme && refreshSurfaces)) {
if (nextFingerprint === appliedFingerprint) {
return;
}
@@ -284,11 +281,8 @@ export function useActiveChromeTheme({
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
// Settings may have just applied the OS theme over a fixed terminal theme.
// Reassert its visual surfaces even when the terminal fingerprint is stable;
// tab-switch callers retain the default fingerprint fast path.
syncActiveChromeTheme(effectiveTheme, applyAppTheme, { refreshSurfaces: true });
}, [effectiveTheme, applyAppTheme, pluginThemeRevision, resolvedTheme]);
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
useLayoutEffect(() => {
return () => {
@@ -92,21 +92,3 @@ test("selectPreferredRuntimeAppLockState prefers initialized state when versions
current,
);
});
test("selectPreferredRuntimeAppLockState keeps the current object when a resync changes nothing", () => {
const current = normalizeRuntimeAppLockState({
initialized: true,
locked: false,
reason: null,
version: 4,
lastUnlockedAt: 1000,
lastActivityAt: 2000,
});
const resynced = normalizeRuntimeAppLockState({ ...current });
assert.notEqual(resynced, current);
// Window focus resyncs must not hand React a new state object.
assert.equal(selectPreferredRuntimeAppLockState(current, resynced), current);
const changed = normalizeRuntimeAppLockState({ ...current, locked: true, reason: "idle" });
assert.equal(selectPreferredRuntimeAppLockState(current, changed), changed);
});
+1 -14
View File
@@ -43,20 +43,7 @@ export function selectPreferredRuntimeAppLockState(
if (incoming.version < current.version) return current;
if (incoming.initialized && !current.initialized) return incoming;
if (!incoming.initialized && current.initialized) return current;
// Every window focus/visibility change resyncs; keep the current object when
// nothing changed so the gate (and the provider tree under it) does not
// re-render.
return isSameRuntimeAppLockState(current, incoming) ? current : incoming;
}
function isSameRuntimeAppLockState(left: RuntimeAppLockState, right: RuntimeAppLockState): boolean {
return left.initialized === right.initialized
&& left.locked === right.locked
&& left.reason === right.reason
&& left.version === right.version
&& left.lastLockedAt === right.lastLockedAt
&& left.lastUnlockedAt === right.lastUnlockedAt
&& left.lastActivityAt === right.lastActivityAt;
return incoming;
}
export function useAppLockRuntime(
+3 -25
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
normalizeAppLockSettings,
@@ -131,8 +131,6 @@ export function createOptimisticUnlockedRuntimeState(
};
}
export const APP_LOCK_ACTIVITY_REPORT_INTERVAL_MS = 1000;
export function useAppLockState(settings: AppLockSettings) {
const normalizedSettings = useMemo(() => normalizeAppLockSettings(settings), [settings]);
const systemUnlockRefreshKey = `${normalizedSettings.enabled}:${normalizedSettings.systemUnlockEnabled}:${Boolean(normalizedSettings.passwordVerifier)}`;
@@ -162,14 +160,8 @@ export function useAppLockState(settings: AppLockSettings) {
void bridge?.setAppLockRuntimeLocked?.(reason);
}, [bridge, normalizedSettings]);
// Activity only resets a minutes-long idle timer; one report per second is
// enough instead of an IPC per keystroke, wheel tick and pointer event.
const lastActivityReportAtRef = useRef(0);
const recordActivity = useCallback(() => {
if (effectiveRuntimeState.locked) return;
const now = Date.now();
if (now - lastActivityReportAtRef.current < APP_LOCK_ACTIVITY_REPORT_INTERVAL_MS) return;
lastActivityReportAtRef.current = now;
void bridge?.reportAppLockActivity?.();
}, [bridge, effectiveRuntimeState.locked]);
@@ -257,9 +249,7 @@ export function useAppLockState(settings: AppLockSettings) {
void refreshSystemUnlockStatus();
}, [refreshSystemUnlockStatus, systemUnlockRefreshKey]);
// Stable identity: AppLockGate passes this to <App>, whose provider tree
// re-renders whenever it changes.
return useMemo(() => ({
return {
initialized: effectiveRuntimeState.initialized,
locked: effectiveRuntimeState.locked,
lockReason: effectiveRuntimeState.reason,
@@ -271,17 +261,5 @@ export function useAppLockState(settings: AppLockSettings) {
reset,
recordActivity,
resync: refreshRuntimeState,
}), [
effectiveRuntimeState.initialized,
effectiveRuntimeState.locked,
effectiveRuntimeState.reason,
lockNow,
unlock,
unlockWithSystemAuth,
systemUnlockStatus,
refreshSystemUnlockStatus,
reset,
recordActivity,
refreshRuntimeState,
]);
};
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { useCallback } from 'react';
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
import { useStoredNumber } from './useStoredNumber';
export const COMPOSE_BAR_HEIGHT_DEFAULT = 144;
export const COMPOSE_BAR_HEIGHT_MIN = 104;
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
export const COMPOSE_BAR_HEIGHT_MIN = 72;
export const COMPOSE_BAR_HEIGHT_MAX = 360;
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
@@ -1,106 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useComposeDirectoryPicker } from './useComposeDirectoryPicker';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { refreshObservedTerminalDirectory, registerTerminalDirectory } from './terminalDirectoryRegistry';
import { terminalCwdStore } from './terminalCwdStore';
import { DirectoryPickerError } from '../../domain/directoryPicker';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
test('opening loads children once; search is local; unmount stops chip refresh demand', async () => {
let reads = 0; let probes = 0;
const id = 'hook-directory';
const controller = createTerminalDirectoryController({ sessionId: id,
io: { cwd: async () => { probes++; return '/base'; }, realpath: async path => path, write() {},
list: async () => { reads++; return { home: '/base', entries: [{ name: 'child', path: '/base/child', hidden: false }] }; } },
isCurrent: () => true, isReady: () => true,
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const cleanup = registerTerminalDirectory(id, controller);
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness() { model = useComposeDirectoryPicker(id); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
assert.equal(model.cwd, '/base');
await act(async () => { model.setOpen(true); });
assert.equal(reads, 1);
await act(async () => { model.setQuery('CH'); });
assert.equal(model.entries.length, 1);
assert.equal(reads, 1);
await act(async () => { root.unmount(); });
const before = probes;
refreshObservedTerminalDirectory(id);
await Promise.resolve();
assert.equal(probes, before);
cleanup();
});
test('switching terminal cancels an old directory read and closes its popover', async () => {
let finish!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
const cleanups = ['hook-a', 'hook-b'].map(id => registerTerminalDirectory(id, createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/' + id, realpath: async path => path, write() {},
list: () => new Promise(resolve => { finish = resolve; }) },
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
})));
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness({ id }: { id: string }) { model = useComposeDirectoryPicker(id); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness, { id: 'hook-a' })); });
await act(async () => { model.setOpen(true); });
await act(async () => { root.update(React.createElement(Harness, { id: 'hook-b' })); });
await act(async () => { finish({ entries: [{ name: 'old', path: '/hook-a/old', hidden: false }], home: null }); });
assert.equal(model.open, false);
assert.equal(model.cwd, '/hook-b');
assert.deepEqual(model.entries, []);
await act(async () => { root.unmount(); });
cleanups.forEach(cleanup => cleanup());
});
test('reconnect after an aborted listing allows manual recovery from a failed cwd probe', async t => {
const id = 'hook-directory-reconnect';
let finishOld!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
const oldController = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/old', realpath: async value => value, write() {},
list: () => new Promise(resolve => { finishOld = resolve; }) },
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const unregisterOld = registerTerminalDirectory(id, oldController);
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness() { model = useComposeDirectoryPicker(id); return null; }
let root!: ReactTestRenderer;
let unregisterNew = () => {};
t.after(async () => {
await act(async () => { root.unmount(); });
unregisterOld(); unregisterNew();
});
await act(async () => { root = create(React.createElement(Harness)); });
await act(async () => { model.setOpen(true); });
assert.equal(model.loading, true);
// Close while the request is pending, so the read is already aborted when
// the disconnect disposes its controller and settles the old promise.
await act(async () => { model.setOpen(false); });
await act(async () => { unregisterOld(); terminalCwdStore.setCwd(id, null); });
let failProbe = true;
const nextController = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => { if (failProbe) throw new DirectoryPickerError('unknown'); return '/recovered'; },
realpath: async value => value, write() {},
list: async () => ({ entries: [{ name: 'new', path: '/recovered/new', hidden: false }], home: null }) },
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
await act(async () => { unregisterNew = registerTerminalDirectory(id, nextController); });
assert.equal(model.cwd, null);
assert.equal(model.error, 'unknown');
assert.equal(model.loading, false, 'cancelled reads must not leave the manual Refresh action disabled');
await act(async () => { failProbe = false; await model.refresh(); model.setOpen(true); });
assert.equal(model.cwd, '/recovered');
assert.equal(model.loading, false);
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
await act(async () => { finishOld({ entries: [{ name: 'stale', path: '/old/stale', hidden: false }], home: null }); });
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
});
@@ -1,94 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { DirectoryPickerError, directoryParent, filterDirectories, type DirectoryEntry, type DirectoryPickerErrorCode } from '../../domain/directoryPicker';
import { terminalCwdStore } from './terminalCwdStore';
import { getTerminalDirectory, observeTerminalDirectory, subscribeTerminalDirectories } from './terminalDirectoryRegistry';
export function useComposeDirectoryPicker(sessionId: string) {
const controller = useSyncExternalStore(subscribeTerminalDirectories, () => getTerminalDirectory(sessionId), () => null);
useSyncExternalStore(terminalCwdStore.subscribe, terminalCwdStore.getVersion, terminalCwdStore.getVersion);
const [confirmed, setConfirmed] = useState(false);
const cwd = controller && (confirmed || controller.hasConfirmedCwd() || terminalCwdStore.getSource(sessionId) === 'osc7')
? terminalCwdStore.getCwd(sessionId) : null;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [showHidden, setShowHidden] = useState(false);
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [home, setHome] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState(false);
const listingBase = useRef<string | null>(null);
const [error, setError] = useState<DirectoryPickerErrorCode | null>(null);
const generation = useRef(0);
const lifetime = useRef(new AbortController());
const read = useRef<AbortController | null>(null);
const sending = useRef(false);
const report = useCallback((failure: unknown, fallback: DirectoryPickerErrorCode) => {
setError(failure instanceof DirectoryPickerError ? failure.code : fallback);
}, []);
useEffect(() => {
const id = ++generation.current;
const stopObserving = observeTerminalDirectory(sessionId);
lifetime.current = new AbortController();
sending.current = false;
setConfirmed(false); setOpen(false); setPending(false); setLoading(false); setHome(null); setError(null); setEntries([]);
listingBase.current = null;
if (controller) void Promise.all([controller.refresh(), controller.home()]).then(([, nextHome]) => {
if (generation.current === id) { setConfirmed(true); setHome(nextHome); }
}).catch(failure => { if (generation.current === id) report(failure, 'unknown'); });
return () => {
generation.current = id + 1;
lifetime.current.abort(); read.current?.abort(); stopObserving();
};
}, [sessionId, controller, report]);
const refresh = useCallback(async () => {
if (!controller) { setError('unavailable'); return; }
const id = generation.current;
setError(null);
try { await controller.refresh(); if (generation.current === id) setConfirmed(true); }
catch (failure) { if (generation.current === id) report(failure, 'unknown'); }
}, [controller, report]);
const load = useCallback(async () => {
if (!controller || !cwd) return;
read.current?.abort();
const task = new AbortController(); read.current = task;
const id = generation.current;
setLoading(true); setEntries([]); setError(null);
try {
const result = await controller.list(cwd, task.signal);
if (!task.signal.aborted && id === generation.current) {
listingBase.current = cwd; setEntries(result.entries); setHome(result.home);
}
} catch (failure) {
if (!task.signal.aborted && id === generation.current) report(failure, 'readFailed');
} finally { if (!task.signal.aborted && id === generation.current) setLoading(false); }
}, [controller, cwd, report]);
useEffect(() => {
if (open) { setQuery(''); void load(); }
return () => { read.current?.abort(); };
}, [open, load]);
const change = useCallback(async (path: string) => {
if (!controller) { setError('unavailable'); return; }
if (sending.current) return;
if (!cwd || terminalCwdStore.getCwd(sessionId) !== cwd
|| (path !== directoryParent(cwd) && listingBase.current !== cwd)) { setError('unavailable'); return; }
sending.current = true; setPending(true); setError(null);
const id = generation.current;
try {
await controller.change(path, lifetime.current.signal, () => {
if (id === generation.current) setOpen(false);
}, cwd ?? undefined);
} catch (failure) { if (id === generation.current) report(failure, 'changeFailed'); }
finally {
if (id === generation.current) { sending.current = false; setPending(false); }
}
}, [controller, report, cwd, sessionId]);
const filtered = useMemo(() => filterDirectories(entries, query, showHidden), [entries, query, showHidden]);
return { cwd, home, open, setOpen, query, setQuery, showHidden, setShowHidden, entries: filtered,
parent: cwd ? directoryParent(cwd) : null, loading, pending, error, available: !!controller, refresh, reload: load, change };
}
+6 -18
View File
@@ -13,7 +13,6 @@ import { useMonitorPanels } from "./monitorPanelsStorage";
import {
monitorActions,
getMonitorHistoryCursor,
isIdleMonitorSnapshot,
publishMonitorSnapshot,
publishMonitorUnavailable,
} from "./monitorRuntimeStore";
@@ -158,24 +157,16 @@ export function useMonitorRuntime() {
isInitialized,
retryVersion,
]);
// Poll every second while anything is monitored (here or in another window);
// otherwise only every 10 s to notice monitors started elsewhere. Starting a
// panel here publishes demand and reads a snapshot immediately, and flipping
// `hasRunningPanels` restarts this loop at the fast cadence.
const hasRunningPanels = running.size > 0;
useEffect(() => {
const bridge = netcattyBridge.get();
let disposed = false;
let pending = false;
let remoteRunning = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
if (pending || !bridge?.monitorSnapshot) return;
pending = true;
try {
const snapshot = await bridge.monitorSnapshot(getMonitorHistoryCursor());
if (disposed) return;
remoteRunning = !isIdleMonitorSnapshot(snapshot);
if (
(epoch.current !== null && epoch.current !== snapshot.epoch) ||
(runtimeId.current !== null &&
@@ -195,16 +186,13 @@ export function useMonitorRuntime() {
pending = false;
}
};
const schedule = () => {
if (disposed) return;
timer = setTimeout(() => {
void poll().finally(schedule);
}, hasRunningPanels || remoteRunning ? 1000 : 10_000);
};
void poll().finally(schedule);
void poll();
const timer = setInterval(() => {
void poll();
}, 1000);
return () => {
disposed = true;
if (timer) clearTimeout(timer);
clearInterval(timer);
};
}, [hasRunningPanels]);
}, []);
}
+30 -50
View File
@@ -3,7 +3,8 @@ import type { TerminalSession, Workspace } from '../../domain/models';
import { getNotesSnapshot } from './notesStore';
import { useActiveTabId } from './activeTabStore';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import type { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -26,47 +27,30 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
// The runtime pulls in the AI SDK providers (~1 MB with the Catty
// harness). This host is mounted for every main window, so create it on
// the first plugin AI request instead of at startup.
type HostRuntime = ReturnType<typeof createTrustedPluginAIHostRuntime>;
let runtime: HostRuntime | null = null;
let runtimeLoading: Promise<HostRuntime> | null = null;
const withLoadedRuntime = (apply: (loaded: HostRuntime) => void) => {
if (runtime) apply(runtime);
else void runtimeLoading?.then(apply, () => {});
};
const ensureRuntime = () => runtimeLoading ??= Promise.all([
import('../../infrastructure/ai/plugin/trustedHostRuntime'),
import('../../infrastructure/ai/plugin/configuredModel'),
]).then(([{ createTrustedPluginAIHostRuntime: createRuntime }, { getConfiguredPluginModel, prepareConfiguredPluginModel }]) => {
runtime = createRuntime({
getModel: getConfiguredPluginModel,
prepareModel: prepareConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
if (!mounted) runtime.shutdown();
return runtime;
const runtime = createTrustedPluginAIHostRuntime({
getModel: getConfiguredPluginModel,
prepareModel: prepareConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
if (request.kind === 'request.cancel') {
@@ -76,28 +60,24 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
if (request.kind === 'runtime.release') {
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
withLoadedRuntime((loaded) => loaded.releaseRuntime(request.runtimeId));
runtime.releaseRuntime(request.runtimeId);
return;
}
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController();
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void ensureRuntime().then((loaded) => {
// Loading is async: re-check that the host and request are still live.
if (!mounted || controller.signal.aborted) throw new Error('Plugin AI request is no longer active');
return loaded.handle(request.kind, request.payload, request.runtimeId, controller.signal);
}).then(
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { withLoadedRuntime((loaded) => loaded.shutdown()); for (const pending of requests.values()) pending.controller.abort(); };
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
window.addEventListener('pagehide', pageHide);
void bridge.setPluginAIHostReady(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
for (const pending of requests.values()) pending.controller.abort();
requests.clear(); withLoadedRuntime((loaded) => loaded.shutdown());
requests.clear(); runtime.shutdown();
void bridge.setPluginAIHostReady(false).catch(() => {});
};
}, []);
+33 -53
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession } from '../../domain/models';
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
import type { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
@@ -22,78 +22,58 @@ export function usePluginAgentHost(sessions: TerminalSession[]) {
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
// The agent runtime pulls in the whole Catty harness and AI SDK. This host
// is mounted for every main window, so create it on the first plugin agent
// request instead of at startup; until then there is nothing to cancel,
// release or invalidate.
type AgentRuntime = ReturnType<typeof createTrustedPluginAgentRuntime>;
let runtime: AgentRuntime | null = null;
let runtimeLoading: Promise<AgentRuntime> | null = null;
const withLoadedRuntime = (apply: (loaded: AgentRuntime) => void) => {
if (runtime) apply(runtime);
else void runtimeLoading?.then(apply, () => {});
};
const ensureRuntime = () => runtimeLoading ??= import('../../infrastructure/ai/pluginAgent/trustedAgentRuntime')
.then(({ createTrustedPluginAgentRuntime: createRuntime }) => {
runtime = createRuntime({
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A target selection is already pending')); return; }
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
}));
const finish = (ids: string[] | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
reject(Error('Selected terminal is no longer connected')); return;
}
resolve(selected as PluginAgentHostTerminal[]);
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
setChoice({ items, confirm: finish, cancel });
if (signal.aborted) cancel();
}),
});
if (!mounted) runtime.shutdown();
return runtime;
});
const runtime = createTrustedPluginAgentRuntime({
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A target selection is already pending')); return; }
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
}));
const finish = (ids: string[] | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
reject(Error('Selected terminal is no longer connected')); return;
}
resolve(selected as PluginAgentHostTerminal[]);
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
setChoice({ items, confirm: finish, cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.subscribe((request) => {
if (request.kind === 'request.cancel') {
const pending = requests.get(request.payload.requestId);
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
withLoadedRuntime((loaded) => loaded.cancelRequest(request.payload.requestId, request.runtimeId)); return;
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
}
if (request.kind === 'runtime.release') {
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
withLoadedRuntime((loaded) => loaded.releaseRuntime(request.runtimeId)); return;
runtime.releaseRuntime(request.runtimeId); return;
}
if (request.kind === 'agent.close') { withLoadedRuntime((loaded) => loaded.releaseChat(request.payload.chatSessionId, request.runtimeId)); return; }
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController(); requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void ensureRuntime().then((loaded) => {
// Loading is async: re-check that the host and request are still live.
if (!mounted || controller.signal.aborted) throw new Error('Plugin agent request is no longer active');
return loaded.handle(request, controller.signal);
}).then(
void runtime.handle(request, controller.signal).then(
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { withLoadedRuntime((loaded) => loaded.shutdown()); for (const item of requests.values()) item.controller.abort(); };
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
const configurationChanged = (event: Event) => {
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
if (key === null || (key && configurationKeys.has(key))) {
withLoadedRuntime((loaded) => loaded.invalidateConfiguration());
runtime.invalidateConfiguration();
// Invalidate main-owned backend leases too, even with no event read in
// flight. Re-advertising availability does not renew existing handles.
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
@@ -105,7 +85,7 @@ export function usePluginAgentHost(sessions: TerminalSession[]) {
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
for (const item of requests.values()) item.controller.abort(); requests.clear(); withLoadedRuntime((loaded) => loaded.shutdown());
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
void bridge.ready(false).catch(() => {});
};
}, []);
@@ -1,59 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { parsePluginGitHubRepository } from '@/domain/pluginGitHubRepository.cjs';
export function usePluginGitHubInstall(onInstalled: () => Promise<unknown>) {
const [preview, setPreview] = useState<NetcattyGitHubPluginPreview | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const token = useRef<string | null>(null);
const active = useRef(false);
const mounted = useRef(true);
const reset = useCallback(() => {
const previous = token.current;
token.current = null;
setPreview(null);
setError(null);
if (previous) void netcattyBridge.get()?.discardGitHubPlugin?.(previous).catch(() => {});
}, []);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (token.current) void netcattyBridge.get()?.discardGitHubPlugin?.(token.current).catch(() => {});
};
}, []);
const run = async (operation: () => Promise<void>) => {
if (active.current) return;
active.current = true;
setBusy(true);
setError(null);
try { await operation(); }
catch (cause) { if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); }
finally { active.current = false; if (mounted.current) setBusy(false); }
};
return {
preview, busy, error, reset,
prepare: (input: string) => run(async () => {
const { origin } = parsePluginGitHubRepository(input);
const bridge = netcattyBridge.require();
if (!bridge.prepareGitHubPlugin) throw new Error('GitHub plugin installation unavailable');
reset();
const result = await bridge.prepareGitHubPlugin(origin);
if (!mounted.current) { await bridge.discardGitHubPlugin?.(result.token); return; }
token.current = result.token;
setPreview(result);
}),
install: () => run(async () => {
const bridge = netcattyBridge.require();
if (!bridge.installGitHubPlugin || !token.current) throw new Error('Download and review a GitHub plugin first');
const selected = token.current;
// The host consumes the selection even on failure; retry starts with a new review.
token.current = null;
setPreview(null);
await bridge.installGitHubPlugin(selected);
await onInstalled();
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
}),
};
}
+23 -33
View File
@@ -21,7 +21,6 @@ getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
setWorkspaceFocusedSessionId,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
@@ -250,8 +249,6 @@ export const useSessionState = ({
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const logViewsRef = useRef(logViews);
logViewsRef.current = logViews;
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
const sessionsRef = useRef(sessions);
const tabOrderRef = useRef(tabOrder);
@@ -1100,10 +1097,10 @@ export const useSessionState = ({
// Set the focused session in a workspace (for focus mode)
const setWorkspaceFocusedSession = useCallback((workspaceId: string, sessionId: string) => {
// Pane click and focus both land here; an unchanged focus must keep the
// same array or every click re-renders the whole shell (and re-saves the
// session restore snapshot).
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, sessionId));
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: sessionId };
}));
}, []);
const reorderWorkspaceSessions = useCallback((
@@ -1129,7 +1126,7 @@ export const useSessionState = ({
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
const workspace = workspacesRef.current.find(w => w.id === workspaceId);
const workspace = workspaces.find(w => w.id === workspaceId);
if (!workspace) {
return false;
}
@@ -1150,10 +1147,13 @@ export const useSessionState = ({
}
// Update focused session
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, nextSessionId));
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: nextSessionId };
}));
return true;
}, []);
}, [workspaces]);
// Run a snippet on multiple target hosts - creates a focus mode workspace
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[], commandOverride?: string) => {
@@ -1196,10 +1196,6 @@ export const useSessionState = ({
}, [setActiveTabId]);
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
// Read by action callbacks at call time so they keep a stable identity:
// orphanSessions changes with every session status update.
const orphanSessionsRef = useRef(orphanSessions);
orphanSessionsRef.current = orphanSessions;
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
@@ -1234,13 +1230,6 @@ export const useSessionState = ({
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
// Same snapshot the old closure captured (values at click time), read from
// refs so this action keeps a stable identity across session updates.
const tabIdsAtCall = [
...orphanSessionsRef.current.map(s => s.id),
...workspacesRef.current.map(w => w.id),
...logViewsRef.current.map(lv => lv.id),
];
const newSessionId = crypto.randomUUID();
setSessions(prevSessions => {
@@ -1261,13 +1250,17 @@ export const useSessionState = ({
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
const allTabIds = tabIdsAtCall;
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
return insertCopiedTabOrderIdOnce(prevTabOrder, sessionId, newSessionId, allTabIds);
});
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
// Copy a whole workspace (split/focus tab): clone every pane's session and
// reproduce the layout tree in a new tab.
@@ -1275,14 +1268,7 @@ export const useSessionState = ({
localShellType?: TerminalSession['shellType'];
perPaneCwd?: Record<string, string | undefined>;
}) => {
// Same snapshot the old closure captured (values at click time), read from
// refs so this action keeps a stable identity across session updates.
const tabIdsAtCall = [
...orphanSessionsRef.current.map(s => s.id),
...workspacesRef.current.map(w => w.id),
...logViewsRef.current.map(lv => lv.id),
];
const sourceWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
const sourceWorkspace = workspaces.find(w => w.id === workspaceId);
if (!sourceWorkspace) return;
// Pre-mint the new workspace id and the old->new session-id map OUTSIDE the
@@ -1320,13 +1306,17 @@ export const useSessionState = ({
setWorkspaces(prev => addWorkspaceIfMissing(prev, built.newWorkspace));
setActiveTabId(newWorkspaceId);
setTabOrder(prevTabOrder => {
const allTabIds = tabIdsAtCall;
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
return insertCopiedTabOrderIdOnce(prevTabOrder, workspaceId, newWorkspaceId, allTabIds);
});
return [...prevSessions, ...built.newSessions];
});
}, [setActiveTabId]);
}, [workspaces, orphanSessions, logViews, setActiveTabId]);
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
localShellType?: TerminalSession['shellType'];
@@ -95,6 +95,7 @@ test("merging a workspace tab with a standalone tab reuses the workspace id (iss
// standalone on the opposite side of the drop hint (hint describes where
// the dragged member should appear relative to the standalone).
let orientationStandaloneId = "";
let orientationMemberId = "";
await act(async () => {
orientationStandaloneId = current().createLocalTerminal({ shellName: "orient-standalone" });
});
-9
View File
@@ -1231,15 +1231,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
return () => mql.removeEventListener('change', handler);
}, []);
// Listen for system theme changes from main process (fallback when matchMedia doesn't fire)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSystemThemeChanged) return;
return bridge.onSystemThemeChanged((payload) => {
setSystemPreference(payload.isDark ? 'dark' : 'light');
});
}, []);
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
document.documentElement.lang = uiLanguage;
+9 -37
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
@@ -70,55 +70,26 @@ export function shouldReleaseShortcutModifier(
return !isShortcutModifierHeld(event, requirements);
}
// The held flag lives in a tiny store so only the tab icon slots re-render when
// it flips. It used to be TopTabs state, which re-rendered the whole tab bar
// twice for every Ctrl/⌘ shortcut typed in a terminal (Ctrl+C, ⌘V, ...).
let modifierHeld = false;
const modifierHeldListeners = new Set<() => void>();
function publishModifierHeld(next: boolean): void {
if (modifierHeld === next) return;
modifierHeld = next;
for (const listener of modifierHeldListeners) listener();
}
function subscribeModifierHeld(listener: () => void): () => void {
modifierHeldListeners.add(listener);
return () => {
modifierHeldListeners.delete(listener);
};
}
const getModifierHeld = () => modifierHeld;
/** Whether the tab-switch modifier combination is currently held. */
export function useShortcutModifierHeldValue(): boolean {
return useSyncExternalStore(subscribeModifierHeld, getModifierHeld, getModifierHeld);
}
/**
* Track the modifiers of the effective [1...9] tab-switch binding and publish
* whether they are held. Pass a null binding to stop tracking.
*/
export function useTrackShortcutModifierHeld(
export function useShortcutModifierHeld(
keyBinding: string | null,
scheme: HotkeyScheme,
): void {
): boolean {
const [isHeld, setIsHeld] = useState(false);
const requirements = useMemo(
() => getShortcutModifierRequirements(keyBinding, scheme),
[keyBinding, scheme],
);
useEffect(() => {
publishModifierHeld(false);
setIsHeld(false);
if (!requirements) return;
// Sync on every modifier transition so extras clear the preview and
// releasing an extra modifier can re-arm when the exact combo remains.
const syncHeldState = (event: KeyboardEvent) => {
publishModifierHeld(isShortcutModifierHeld(event, requirements));
setIsHeld(isShortcutModifierHeld(event, requirements));
};
const clearHeldState = () => publishModifierHeld(false);
const clearHeldState = () => setIsHeld(false);
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
clearHeldState();
@@ -135,7 +106,8 @@ export function useTrackShortcutModifierHeld(
window.removeEventListener('keyup', syncHeldState, true);
window.removeEventListener('blur', clearHeldState);
document.removeEventListener('visibilitychange', handleVisibilityChange);
publishModifierHeld(false);
};
}, [requirements]);
return isHeld;
}
@@ -1,95 +0,0 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host } from '../../domain/models';
import { getTerminalDirectory, observeTerminalDirectory } from './terminalDirectoryRegistry';
import { useTerminalDirectorySession } from './useTerminalDirectorySession';
import { useComposeDirectoryPicker } from './useComposeDirectoryPicker';
type SessionProps = Parameters<typeof useTerminalDirectorySession>[0];
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
function installBridge(t: TestContext, getAppInfo?: NetcattyBridge['getAppInfo']) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
let probes = 0; let lists = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
getAppInfo,
getSessionPwd: async () => { probes++; return { success: true, cwd: '/actual' }; },
listLocalDir: async () => { lists++; return []; },
} } });
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window');
});
return { counts: () => ({ probes, lists }) };
}
const options = (id: string, protocol = 'local', shellType = 'posix'): SessionProps => ({
sessionId: id, status: 'connected',
host: { id, label: 'Test', hostname: 'host', username: 'me', protocol } as Host,
shellType, getBackendId: () => id, isReady: () => true, publish() {},
});
for (const wrapper of ['wsl-Ubuntu', 'git-bash', 'cygwin']) {
test(`Windows ${wrapper} reports directory navigation unavailable and performs no reads`, async t => {
const bridge = installBridge(t, async () => ({ name: 'Netcatty', version: 'test', platform: 'win32' }));
const id = 'windows-' + wrapper;
let model!: ReturnType<typeof useComposeDirectoryPicker>;
function Harness() {
const props = options(id);
props.host = { ...props.host, localShell: wrapper };
useTerminalDirectorySession(props);
model = useComposeDirectoryPicker(id);
return null;
}
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
t.after(async () => { await act(async () => { root.unmount(); }); });
assert.equal(getTerminalDirectory(id), null);
assert.equal(model.available, false);
await act(async () => { model.setOpen(true); await model.refresh(); });
assert.equal(model.error, 'unavailable');
assert.deepEqual(bridge.counts(), { probes: 0, lists: 0 });
});
}
for (const platform of ['linux', 'darwin']) {
test(`local POSIX directory controller registers on ${platform}`, async t => {
installBridge(t, async () => ({ name: 'Netcatty', version: 'test', platform }));
const id = 'local-' + platform;
function Harness() { useTerminalDirectorySession(options(id)); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
t.after(async () => { await act(async () => { root.unmount(); }); });
assert.ok(getTerminalDirectory(id));
});
}
test('Windows SSH sessions do not inherit the local cwd platform limitation', async t => {
let platformQueries = 0;
installBridge(t, async () => { platformQueries++; return { name: 'Netcatty', version: 'test', platform: 'win32' }; });
const id = 'windows-ssh';
function Harness() { useTerminalDirectorySession(options(id, 'ssh')); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
t.after(async () => { await act(async () => { root.unmount(); }); });
assert.ok(getTerminalDirectory(id));
assert.equal(platformQueries, 0);
});
test('a late platform query cannot register a disconnected terminal', async t => {
let finish!: (info: { name: string; version: string; platform: string }) => void;
installBridge(t, () => new Promise(resolve => { finish = resolve; }));
const id = 'disconnected-during-platform-query';
const stopObserving = observeTerminalDirectory(id);
t.after(stopObserving);
function Harness({ status }: { status: string }) { useTerminalDirectorySession({ ...options(id), status }); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness, { status: 'connected' })); });
await act(async () => { root.update(React.createElement(Harness, { status: 'disconnected' })); });
await act(async () => { finish({ name: 'Netcatty', version: 'test', platform: 'linux' }); });
assert.equal(getTerminalDirectory(id), null);
await act(async () => { root.unmount(); });
});
@@ -1,56 +0,0 @@
import { useEffect, useRef } from 'react';
import type { Host } from '../../domain/models';
import { classifyDistroId, hostRestrictsExtraSshChannels } from '../../domain/host';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { createTerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { registerTerminalDirectory } from './terminalDirectoryRegistry';
export function useTerminalDirectorySession(options: {
sessionId: string;
status: string;
host: Host;
shellType?: string;
getBackendId: () => string | null;
isReady: () => boolean;
publish: (cwd: string) => void;
}) {
const live = useRef(options);
live.current = options;
const { sessionId, status, shellType, host } = options;
const local = host.protocol === 'local';
const supported = (local || ((host.protocol ?? 'ssh') === 'ssh' && !host.moshEnabled && !host.etEnabled))
&& shellType !== 'powershell' && shellType !== 'cmd'
&& host.deviceType !== 'network' && classifyDistroId(host.distro) !== 'network-device'
&& !hostRestrictsExtraSshChannels(host);
useEffect(() => {
const bridge = netcattyBridge.get();
const backendId = live.current.getBackendId();
if (!bridge || !backendId || status !== 'connected' || !supported) return;
let active = true;
const isCurrent = () => active && live.current.status === 'connected' && live.current.getBackendId() === backendId;
let unregister = () => {};
const register = () => {
if (!isCurrent()) return;
const controller = createTerminalDirectoryController({
sessionId,
io: createTerminalDirectoryIO(bridge, backendId, {
local, hostname: host.hostname, username: host.username || 'root', port: host.port,
encoding: host.sftpEncoding,
}, isCurrent),
isCurrent,
isReady: () => live.current.isReady(),
publish: cwd => live.current.publish(cwd),
});
unregister = registerTerminalDirectory(sessionId, controller);
};
if (local) {
// POSIX wrappers on Windows still use win32 PTYs. Neither their cwd nor
// their path namespace is supported by readLocalSessionCwd/local FS.
void bridge.getAppInfo?.().then(info => {
if (info.platform === 'darwin' || info.platform === 'linux') register();
}).catch(() => {});
} else register();
return () => { active = false; unregister(); };
}, [sessionId, status, supported, local, host.hostname, host.username, host.port, host.sftpEncoding, shellType]);
}
-4
View File
@@ -55,8 +55,6 @@ export interface UseVaultAgentBridgeInput {
openHost?: VaultAgentApiDeps['openHost'];
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
closeSession?: VaultAgentApiDeps['closeSession'];
reconnectSession?: VaultAgentApiDeps['reconnectSession'];
inspectSessionRecovery?: VaultAgentApiDeps['inspectSessionRecovery'];
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
}
@@ -275,8 +273,6 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
reconnectSession: current.reconnectSession,
inspectSessionRecovery: current.inspectSessionRecovery,
closeSession: current.closeSession
? (sessionId) => current.closeSession!(sessionId)
: undefined,
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import {
collectSidePanelPanes,
+12 -74
View File
@@ -8,8 +8,6 @@ import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
import { useAIChatStreaming, type UseAIChatStreamingReturn } from './state/useAIChatStreaming';
import { installFakeIndexedDb } from './state/testSupport/fakeIndexedDb';
import { idbPutSessions, openAiSessionsIdb } from './state/aiSessionIdbStorage';
installBrowserLockFixture();
@@ -45,47 +43,7 @@ function save(sessions: AISession[], enabled = true) {
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify(sessions));
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
}
test.beforeEach(() => {
storage.clear();
save([], false);
snapshots.__resetIdbPersistenceForTests();
});
test.afterEach(() => {
snapshots.__resetIdbPersistenceForTests();
});
// Issue #4125 review P1①: a sync round that runs before the boot hydration
// has settled must not collect (and persist) the stale legacy store — it
// would overwrite newer IndexedDB records with pre-migration data.
test('a sync round before boot hydration skips chat collection and never overwrites IndexedDB with stale legacy data', async () => {
const idb = installFakeIndexedDb();
try {
snapshots.__resetIdbPersistenceForTests();
snapshots.__resetAiSessionsHydrationForTests(); // boot load still pending
const newer = { ...session(), title: 'Newer IndexedDB copy', updatedAt: 9 };
await idbPutSessions(await openAiSessionsIdb(), [newer]);
// Stale pre-migration copy left in the read-only legacy key.
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify([{ ...session(), title: 'Stale legacy copy', updatedAt: 1 }]));
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
// Pre-hydration collection skips the round entirely.
assert.equal(collectAiSessionsForSync(), undefined);
await snapshots.__flushIdbWritesForTests();
assert.equal(
idb.db.dumpStore('sessions').find((s) => (s as AISession).id === 'chat')?.title,
'Newer IndexedDB copy',
);
// After hydration the collector returns the live snapshot again.
snapshots.markAiSessionsHydrationComplete();
const hydrated = [newer];
snapshots.seedLastPersistedSessions(hydrated);
snapshots.setLatestAISessionsSnapshot(hydrated);
assert.equal(collectAiSessionsForSync()?.[0].title, 'Newer IndexedDB copy');
} finally {
idb.restore();
snapshots.__resetIdbPersistenceForTests();
}
});
test.beforeEach(() => { storage.clear(); save([], false); });
test('actual manual upload and auto collector honor consent and explicit empty', async () => {
save([session()], false);
@@ -116,16 +74,12 @@ test('sync persistence survives restart beyond the local-only history limits', (
assert.equal(afterRestart[0].messages.length, 201);
save(many, false);
assert.equal(snapshots.writeSessionsForStorage(many), true);
// Issue #4123: the persisted set now lives in IndexedDB (per-record
// writes); the legacy localStorage key is a read-only fallback. The
// session-count cap rose to 1000, so all 51 chats survive retention —
// only the per-session message cap (200) still trims.
const localOnly = snapshots.getPersistedAiSessionsSnapshot();
assert.equal(localOnly.length, 51);
const localOnly = JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!);
assert.equal(localOnly.length, 50);
assert.equal(localOnly[0].messages.length, 200);
});
test('legacy localStorage quota failures no longer block sync upload', async () => {
test('failed history persistence refuses upload without pruning the cloud representation', async () => {
save([session()]);
const original = globalThis.localStorage.setItem;
globalThis.localStorage.setItem = (key, value) => {
@@ -134,12 +88,8 @@ test('legacy localStorage quota failures no longer block sync upload', async ()
};
snapshots.setLatestAISessionsSnapshot([{ ...session(), title: 'Unsaved change' }]);
try {
// Persistence moved to IndexedDB (issue #4123), so the legacy key's
// quota can no longer fail the collect step; the in-memory snapshot is
// the source of truth for the upload.
const cloud = await buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined });
assert.equal(cloud.aiSessions?.[0].title, 'Unsaved change');
assert.equal(JSON.stringify(cloud).includes('cHJpdmF0ZQ=='), false);
await assert.rejects(buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined }), /persist|save/i);
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].title, 'Chat');
const backup = buildLocalVaultPayload(vault);
assert.equal(backup.aiSessions?.[0].title, 'Unsaved change');
assert.equal(backup.aiSessions?.[0].messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
@@ -203,7 +153,7 @@ test('download publishes immediately and keeps local attachment bytes on a newer
assert.equal(current.messages[0].content, 'Updated');
assert.equal(current.messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
assert.equal(snapshots.latestAISessionsSnapshot?.[0], current);
assert.equal(snapshots.getPersistedAiSessionsSnapshot()[0].messages[0].content, 'Updated');
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].messages[0].content, 'Updated');
});
test('restoring an old backup without chat leaves current chats alone', async () => {
@@ -213,7 +163,7 @@ test('restoring an old backup without chat leaves current chats alone', async ()
prepareConvergentRestore: async () => async () => {},
});
assert.deepEqual(snapshots.latestAISessionsSnapshot, [local]);
assert.deepEqual(snapshots.getPersistedAiSessionsSnapshot(), [local]);
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), [local]);
});
test('authoritative cloud session removal stays removed in the next upload', async () => {
@@ -241,9 +191,6 @@ test('replacement stops active turns and waits for finalizers before persisting
const local = { ...session(), messages: [...session().messages,
{ id: 'reply', role: 'assistant' as const, content: 'Partial', timestamp: 2, executionStatus: 'running' as const }] };
save([local]);
// Seed the per-record diff baseline so the only 'persist' marker in
// this scenario is the final replacement write (issue #4123).
snapshots.seedLastPersistedSessions([local]);
hook!.abortControllersRef.current.set('chat', controller);
await act(async () => { hook!.setStreamingForScope('chat', true); });
Object.assign(window, { netcatty: {
@@ -258,24 +205,15 @@ test('replacement stops active turns and waits for finalizers before persisting
{ ...local.messages[1], content: 'Settled', executionStatus: 'cancelled' }] }]);
order.push('settled');
});
// Issue #4123: session persistence is an IndexedDB commit now; the
// 'persist' marker fires when the record lands in the store.
globalThis.localStorage.setItem = original;
const idb = installFakeIndexedDb({ onOperation: (store, _op) => {
// Both puts and deletes are persistence commits for the sessions store.
if (store === 'sessions') order.push('persist');
} });
globalThis.localStorage.setItem = (key, value) => { if (key === keys.STORAGE_KEY_AI_SESSIONS) order.push('persist'); original(key, value); };
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
{ id: 'remote', role: 'user' as const, content: 'Remote', timestamp: 3 }] };
await applyLocalVaultPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined }, {
prepareConvergentRestore: async () => async () => {},
});
await snapshots.__flushIdbWritesForTests();
idb.restore();
snapshots.__resetIdbPersistenceForTests();
assert.ok(order.indexOf('cancel') < order.indexOf('settled'));
assert.ok(order.indexOf('settled') < order.indexOf('persist'));
assert.ok(order.includes('cleanup'));
assert.ok(order.indexOf('persist') < order.indexOf('cleanup'));
assert.equal(hook!.abortControllersRef.current.has('chat'), false);
if (remove) assert.deepEqual(getAISessionsSnapshot().sessions, []);
else assert.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Remote');
@@ -319,7 +257,7 @@ test('cloud sync preserves an active trimmed conversation and its native thread'
assert.equal(controller.signal.aborted, false);
assert.deepEqual(calls, []);
assert.deepEqual(getAISessionsSnapshot().sessions, [local]);
assert.equal(snapshots.getPersistedAiSessionsSnapshot()[0].externalSessionId, 'native-thread');
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].externalSessionId, 'native-thread');
}
} finally {
hook!.abortControllersRef.current.delete('chat');
@@ -350,7 +288,7 @@ test('cloud downloads respect local chat opt-in while still applying vault data'
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
assert.equal(vaultImports, 1);
assert.deepEqual(snapshots.latestAISessionsSnapshot, []);
assert.deepEqual(snapshots.getPersistedAiSessionsSnapshot(), []);
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), []);
save([], true);
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
assert.equal(vaultImports, 2);
+2 -9
View File
@@ -50,9 +50,7 @@ import { decryptField, encryptField } from '../infrastructure/persistence/secure
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import {
isAiSessionsHydrationComplete,
latestAISessionsSnapshot,
getPersistedAiSessionsSnapshot,
setLatestAISessionsSnapshot,
writeSessionsForStorage,
cleanupSdkAgentSessions,
@@ -1066,12 +1064,6 @@ function pruneOrphanPerAgentBindings(): void {
export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
const enabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
if (enabled?.enabled !== true) return undefined;
// Issue #4125 review P1①: never collect before the boot hydration has
// settled. A pre-hydration collect falls back to the stale legacy
// localStorage store and the writeSessionsForStorage() call below would
// overwrite newer IndexedDB records with pre-migration data. Skip this
// sync round; the next auto-sync picks up the hydrated store.
if (!isAiSessionsHydrationComplete()) return undefined;
const sessions = collectLocalAiSessions();
if (!writeSessionsForStorage(sessions)) {
throw new Error('Unable to persist chat history for cloud sync');
@@ -1081,7 +1073,8 @@ export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
function collectLocalAiSessions(): NonNullable<SyncPayload['aiSessions']> {
return latestAISessionsSnapshot
?? getPersistedAiSessionsSnapshot();
?? localStorageAdapter.read<SyncPayload['aiSessions']>(STORAGE_KEY_AI_SESSIONS)
?? [];
}
/** Build the portable payload; device-local data is added by the backup builder. */
@@ -3,7 +3,7 @@ import test from "node:test";
import React from "react";
import type { AISession } from "../infrastructure/ai/types.ts";
import { getLoadedLocaleMessages, loadLocaleMessages } from "../application/i18n/messages.ts";
import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
@@ -107,11 +107,9 @@ test("history drawer filters, pages, clears and explains the bounded search in e
assert.equal(closed, 1);
for (const locale of ["en", "zh-CN", "zh-TW", "es", "ru"]) {
// Non-English dictionaries load on demand.
await loadLocaleMessages(locale);
await render(sessions, locale);
await enterQuery("no-such-content");
const messages = getLoadedLocaleMessages(locale)!;
const messages = MESSAGES_BY_LOCALE[locale];
const input = renderer.container.querySelector("input")!;
const hint = dom.document.getElementById(input.getAttribute("aria-describedby")!);
for (const key of ["ai.chat.searchSessions", "ai.chat.searchSessionsHint", "ai.chat.noMatchingSessions"]) {
-1
View File
@@ -112,7 +112,6 @@ export function createAppLockGate(deps: AppLockGateDeps): React.FC<AppLockGatePr
setTimeout(() => splash.remove(), 200);
}
if (shouldNotifyRendererReady) {
performance.mark('netcatty:renderer:ready');
notifyAppLockRendererReady();
}
} catch {
@@ -3,7 +3,6 @@ import test from "node:test";
import React from "react";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { loadLocaleMessages } from "../application/i18n/messages.ts";
import { APP_LOCK_AUTO_PROMPT_DELAY_MS, AppLockOverlay } from "./AppLockOverlay.tsx";
import {
createDomRenderer,
@@ -343,7 +342,6 @@ test("AppLockOverlay renders platform-specific system unlock button", async () =
});
test("AppLockOverlay localizes the system unlock button label", async () => {
await loadLocaleMessages("zh-CN");
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
@@ -2,11 +2,9 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { loadLocaleMessages } from '../application/i18n/messages';
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
test('backup cleanup confirms, preserves data on cancel, refreshes after retry and explains decrypt failures', async () => {
await loadLocaleMessages('zh-CN');
test('backup cleanup confirms, preserves data on cancel, reports errors and refreshes after retry', async () => {
const dom = installDomEnvironment();
const globals = {
localStorage: dom.window.localStorage,
@@ -39,9 +37,6 @@ test('backup cleanup confirms, preserves data on cancel, refreshes after retry a
id: 'fixture', createdAt: 1, reason: 'before_restore', fingerprint: 'fixture',
preview: { hostCount: 1, keyCount: 0, snippetCount: 0, noteCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
}] : [],
readVaultBackup: async () => {
throw new Error("Error invoking remote method 'netcatty:vaultBackups:read': Error: VAULT_BACKUP_DECRYPT_FAILED");
},
clearVaultBackups: async () => {
calls += 1;
if (fail) throw new Error('access denied');
@@ -62,8 +57,6 @@ test('backup cleanup confirms, preserves data on cancel, refreshes after retry a
try {
await renderer.render(<I18nProvider locale="en"><LocalBackupsPanel onApplyPayload={() => { throw new Error('must not restore'); }} /></I18nProvider>);
await flushEffects();
assert.match(dom.document.body.textContent ?? '', /This folder alone is not a complete, portable backup/);
assert.match(dom.document.body.textContent ?? '', /Deleting or wiping the app profile \(userData\)/);
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: false }); });
@@ -97,19 +90,6 @@ test('backup cleanup confirms, preserves data on cancel, refreshes after retry a
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
assert.match(dom.document.body.textContent ?? '', /No local backups yet/);
assert.match(notices[1], /^success:/);
hasBackup = true;
await renderer.render(<I18nProvider locale="zh-CN"><LocalBackupsPanel key="restore" onApplyPayload={() => { throw new Error('must not apply unreadable backup'); }} /></I18nProvider>);
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: true }); });
await flushEffects();
assert.match(dom.document.body.textContent ?? '', /仅复制此文件夹并不构成完整、可迁移的备份/);
await click(button('恢复'));
dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
await click(button('恢复', dialog));
assert.match(notices[2], /当前应用配置无法解密此备份/);
assert.match(notices[2], /可能是加密密钥丢失、应用配置已更改,或备份已损坏/);
assert.doesNotMatch(notices[2], /VAULT_BACKUP_DECRYPT_FAILED|decryptString/);
} finally {
toast.success = success;
toast.error = error;
+1 -11
View File
@@ -35,7 +35,6 @@ import {
import {
initializePreparedConvergentMigration,
prepareConvergentSyncMigration,
resolvePreparedConvergentMigrationConflict,
type PreparedConvergentMigration,
} from '../application/convergentSyncMigration';
import type { ShrinkFinding } from '../domain/syncGuards';
@@ -359,15 +358,6 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
setConvergentBusy(true);
setConvergentError(null);
try {
if (preparedConvergentMigration && !sync.convergentSyncConfig.initialized) {
const prepared = resolvePreparedConvergentMigrationConflict(
preparedConvergentMigration, addressKey, candidateDot,
);
setPreparedConvergentMigration(prepared);
setConvergentPreview(prepared.plan.preview);
toast.success(t('cloudSync.convergent.conflict.resolved'));
return;
}
const { results } = await sync.resolveConvergentConflict(
addressKey,
candidateDot,
@@ -381,7 +371,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
} finally {
setConvergentBusy(false);
}
}, [onApplyConvergentPayload, preparedConvergentMigration, sync, t]);
}, [onApplyConvergentPayload, sync, t]);
const handleDowngradeConvergent = useCallback(async () => {
if (!window.confirm(t('cloudSync.convergent.downgrade.confirm'))) return;
+1 -3
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import jsQR from "jsqr";
import { RE2JS } from "re2js";
import type { Host, HostTotpConfig } from "../domain/models";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
@@ -59,9 +60,6 @@ function errorKey(error: string | undefined): string {
}
async function decodeQrFile(file: File): Promise<string> {
// Loaded on demand: the decoder is only needed when importing a QR image,
// but the host editor (and with it this module) is part of the startup bundle.
const { default: jsQR } = await import("jsqr");
const bitmap = await createImageBitmap(file);
try {
const canvas = document.createElement("canvas");
@@ -1,10 +1,9 @@
import assert from "node:assert/strict";
import test, { before } from "node:test";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider";
import { loadLocaleMessages } from "../application/i18n/messages";
import type { Host, TerminalSession } from "../types";
import { QuickSwitcher } from "./QuickSwitcher";
@@ -41,12 +40,6 @@ const renderSwitcher = (
</I18nProvider>,
);
// Non-English dictionaries load on demand; static rendering runs no effects.
before(async () => {
await loadLocaleMessages("zh-TW");
await loadLocaleMessages("zh-CN");
});
test("quick switcher keeps a matching host ahead of an address row", () => {
const markup = renderSwitcher("10.1.1.1", [host()]);
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
+1 -1
View File
@@ -150,7 +150,7 @@ export function getQuickSwitcherVisualRowHeight(row: QuickSwitcherVisualRow): nu
return QS_ROW_HEIGHT;
}
export interface QuickSwitcherProps {
interface QuickSwitcherProps {
isOpen: boolean;
query: string;
results: Host[];
-2
View File
@@ -247,8 +247,6 @@ const SettingsAITabContainer: React.FC = () => {
setResponseIdleTimeout={aiState.setResponseIdleTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
aiSessionMessageCap={aiState.aiSessionMessageCap}
setAiSessionMessageCap={aiState.setAiSessionMessageCap}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
quickMessages={aiState.quickMessages}
+16 -108
View File
@@ -1,11 +1,7 @@
import { terminalCwdStore } from '../application/state/terminalCwdStore';
import { useTerminalDirectorySession } from '../application/state/useTerminalDirectorySession';
import { refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh } from '../application/state/terminalDirectoryRegistry';
import { publishTerminalSelectionTarget } from "../infrastructure/plugins/terminalSelectionRegistry";
import { usePluginTerminalSelection } from "../application/state/usePluginTerminalSelection";
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { shouldBroadcastDuringSensitivePrompt } from "../domain/terminalBroadcast";
import { probeAltScreenScheduling } from "./terminal/runtime/terminalWriteCoalescer";
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from "../infrastructure/config/storageKeys";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
@@ -57,7 +53,7 @@ import {
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from "../domain/host";
import { shouldCollectServerStats } from "../domain/systemManager/systemTarget";
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
import { CONNECTION_PROGRESS_START, capConnectionProgressLogs } from "./terminal/connectionProgress";
import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
@@ -67,7 +63,6 @@ import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminal
import {
foldTerminalPromptSecurityOutput,
isConfirmedTerminalShellPrompt,
isSensitiveTerminalChallenge,
isUntrustedTerminalInputPrompt,
} from "../domain/terminalPromptSecurity";
import { listPasswordPromptFillCandidates } from "../domain/passwordPromptAssist";
@@ -160,7 +155,6 @@ import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalO
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import {
createTerminalSessionStarters,
isAuthFailureMessage,
type PendingAuth,
type TerminalSessionDataMeta,
} from "./terminal/runtime/createTerminalSessionStarters";
@@ -469,11 +463,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sensitivePromptOutputTailRef = useRef("");
const heredocPromptOriginRef = useRef("");
const promptSecurityPendingCrRef = useRef(false);
const promptSecurityScreenRef = useRef<{
// Null until a raw transition or snapshot establishes authoritative screen state.
active: boolean | null;
incomplete: Parameters<typeof probeAltScreenScheduling>[1];
}>({ active: null, incomplete: null });
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdsRef = useRef(new Set<string>());
@@ -516,10 +505,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const availableFonts = useAvailableFonts();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const isPromptSecurityAlternateScreenActive = useCallback(() => (
promptSecurityScreenRef.current.active
?? (termRef.current ? isTerminalAlternateScreenActive(termRef.current) : false)
), []);
const fitAddonRef = useRef<FitAddon | null>(null);
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
@@ -617,7 +602,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
const agentRecoveryStoppedRef = useRef(false);
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
// connection: reusing a live/idle pooled transport multiplexes onto the old
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
@@ -927,18 +911,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
const [progressLogs, setProgressLogsState] = useState<string[]>([]);
// Every writer (starters, auto-reconnect, cancel) appends; an outage with
// auto-reconnect on (retry every 5 s, no attempt cap) used to grow this list
// and its unvirtualized dialog render for as long as the tab stayed open.
const setProgressLogs = useCallback((update: React.SetStateAction<string[]>) => {
setProgressLogsState((prev) => capConnectionProgressLogs(
typeof update === "function" ? update(prev) : update,
));
}, []);
// Deadline of the running connection timeout; the dialog counts down from
// it locally so per-second ticks do not re-render Terminal/TerminalView.
const [connectionDeadlineAt, setConnectionDeadlineAt] = useState<number | null>(null);
const [progressLogs, setProgressLogs] = useState<string[]>([]);
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [isSessionLogging, setIsSessionLogging] = useState(false);
@@ -1141,8 +1115,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
sensitivePromptSource: passwordPromptActiveRef.source,
alternateScreen: isPromptSecurityAlternateScreenActive(),
})
&& isBroadcastEnabledRef.current
&& onBroadcastInputRef.current
@@ -1249,7 +1221,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
terminalCwdTracker.clearRendererCwd();
terminalCwdStore.setCwd(sessionId, null);
knownCwdRef.current = undefined;
if (options?.persistRestoreMetadata === false) return;
onTerminalCwdChange?.(sessionId, null);
@@ -1534,9 +1505,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, []);
const updateStatus = useCallback((next: TerminalSession["status"]) => {
if (next === "disconnected") {
promptSecurityScreenRef.current = { active: null, incomplete: null };
}
statusRef.current = next;
setStatus(next);
hasConnectedRef.current = next === "connected";
@@ -1603,7 +1571,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, TERMINAL_AUTO_RECONNECT_DELAY_MS);
return true;
}, [attachExistingSession, host, setProgressLogs, t, terminalSettings, updateStatus]);
}, [attachExistingSession, host, t, terminalSettings, updateStatus]);
const prepareRestoredReconnect = useCallback(() => {
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect(
@@ -1758,10 +1726,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
throw new Error("Terminal output did not settle before applying the snapshot");
}
}
promptSecurityScreenRef.current = {
active: payload.alternateScreen,
incomplete: null,
};
if (typeof payload.perCharacterInput === "boolean") {
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
@@ -2095,14 +2059,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
chunk: string,
meta?: TerminalSessionDataMeta,
) => {
// PTY output reaches this observer before coalesced writes finish parsing.
const screenProbe = probeAltScreenScheduling(chunk, promptSecurityScreenRef.current.incomplete);
promptSecurityScreenRef.current = {
active: screenProbe.lastTransition === null
? promptSecurityScreenRef.current.active
: screenProbe.lastTransition === "enter",
incomplete: screenProbe.incomplete,
};
const folded = foldTerminalPromptSecurityOutput({
origin: heredocPromptOriginRef.current,
current: sensitivePromptOutputTailRef.current,
@@ -2113,9 +2069,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
promptSecurityPendingCrRef.current = folded.pendingCr;
const promptSecurityOptions = {
allowHostStyleGreaterThan: isNetworkDevice,
alternateScreen: promptSecurityScreenRef.current.active ?? (termRef.current
alternateScreen: termRef.current
? isTerminalAlternateScreenActive(termRef.current)
: false),
: false,
};
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
@@ -2130,24 +2086,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
promptSecurityPendingCrRef.current = false;
}
return;
} else if (passwordPromptActiveRef.current && passwordPromptActiveRef.source === "explicit") {
return;
} else if (isSensitiveTerminalChallenge(folded.promptText)
|| (!(promptSecurityOptions.alternateScreen && /^:/.test(folded.promptText.trimStart()))
&& isUntrustedTerminalInputPrompt(
folded.promptText,
promptSecurityOptions,
))) {
} else if (isUntrustedTerminalInputPrompt(
folded.promptText,
promptSecurityOptions,
)) {
passwordPromptActiveRef.current = true;
passwordPromptActiveRef.source = "heuristic";
autocompleteCloseRef.current?.();
} else if ((promptSecurityOptions.alternateScreen && /^:/.test(folded.promptText.trimStart())) || isConfirmedTerminalShellPrompt(
} else if (isConfirmedTerminalShellPrompt(
folded.promptText,
promptSecurityOptions,
)) {
// Vim ex can end a stale heuristic latch on the alternate screen (#4107).
// Other output must confirm a shell prompt: background job output can
// arrive while a password is still expected, including inside tmux.
passwordPromptActiveRef.current = false;
passwordPromptActiveRef.source = "heuristic";
}
@@ -2594,7 +2543,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const cwdAwareOnCommandSubmitted = useCallback((
...args: Parameters<NonNullable<typeof onCommandSubmitted>>
) => {
scheduleObservedTerminalDirectoryRefresh(sessionId);
if (!shouldPreserveTerminalCwdAcrossCommand(hostRestrictsExtraSshChannels(host))) {
invalidateTerminalCwdAfterCommand(
terminalCwdTracker,
@@ -2608,7 +2556,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [host, onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
const pluginAwareOnCommandCompleted = useCallback(() => {
pluginTerminalLifecycle.onCommandCompleted();
refreshObservedTerminalDirectory(sessionId);
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
// Hibernate wake builds its runtime from this callback. Same local
// prompt-return refresh as useTerminalEffects (#1952).
@@ -2621,7 +2568,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
cwd: string | null,
meta?: TerminalCwdChangeMeta,
) => {
terminalCwdStore.setCwd(changedSessionId, cwd, meta?.source);
pluginTerminalLifecycle.onCwdChanged(cwd);
onTerminalCwdChange?.(changedSessionId, cwd, meta);
}, [onTerminalCwdChange, pluginTerminalLifecycle]);
@@ -2637,13 +2583,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pluginAwareOnTerminalCwdChange(sessionId, normalizedCwd ?? null, meta);
void refreshProviderOutputs('cwd-changed');
}, [pluginAwareOnTerminalCwdChange, refreshProviderOutputs, sessionId, terminalCwdTracker]);
useTerminalDirectorySession({
sessionId, status, host, shellType,
getBackendId: () => sessionRef.current,
isReady: () => isTerminalReadyForCommandInjection(sessionId),
publish: cwd => pluginAwareOnRuntimeCwdChange(cwd, { source: 'backend-strict' }),
});
const pluginAwareOnTerminalTitleChange = useCallback((changedSessionId: string, title: string | null) => {
terminalTitleRef.current = title || undefined;
pluginTerminalLifecycle.onTitleChanged(title);
@@ -2785,7 +2724,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onTerminalOutput: (chunk: string, meta?: TerminalSessionDataMeta) => {
observeTerminalInputPrompt(chunk, meta);
scheduleObservedTerminalDirectoryRefresh(sessionId);
appendOutputTriggerOutputRef.current(chunk, meta);
if (onTerminalOutput) {
onTerminalOutput(sessionId, chunk);
@@ -3509,8 +3447,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: passwordPromptActiveRef.current,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
sensitivePromptSource: options?.sensitive === true ? "explicit" : passwordPromptActiveRef.source,
alternateScreen: isPromptSecurityAlternateScreenActive(),
})
&& sessionRef.current
&& isBroadcastEnabledRef.current
@@ -3528,7 +3464,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return true;
}
return false;
}, [isPromptSecurityAlternateScreenActive, sessionId]);
}, [sessionId]);
const executeSnippetCommand = useCallback(async (
command: string,
@@ -3536,8 +3472,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
options?: {
broadcast?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
/** Submit compose blocks through the shell's advertised paste support. */
preferBracketedPaste?: boolean;
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
focus?: boolean;
/** Force sensitive classification on the write (bypassed password fan-out, #3488). */
@@ -3586,11 +3520,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
// outside the paste markers — otherwise shells treat it as pasted text
// instead of a submit action.
// Compose submits a command block, so keep it in the shell's input buffer
// even when clipboard bracketing is disabled. Otherwise a running command
// can consume the later lines as stdin (#4062). Still require shell support.
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode
&& (options?.preferBracketedPaste || !disableBracketedPasteRef.current)) {
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
data = wrapBracketedPaste(data);
}
if (!noAutoRun) data = `${data}\r`;
@@ -3612,8 +3542,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
sensitivePromptSource: options?.sensitive === true ? "explicit" : passwordPromptActiveRef.source,
alternateScreen: isPromptSecurityAlternateScreenActive(),
})
&& broadcastThisSend
&& onBroadcastInputRef.current
@@ -3654,7 +3582,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
term.focus();
}
return true;
}, [isPromptSecurityAlternateScreenActive, prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
const executeSnippet = useCallback(async (snippet: Snippet) => {
if (isScriptSnippet(snippet)) {
@@ -3856,7 +3784,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
]);
const handleCancelConnect = () => {
agentRecoveryStoppedRef.current = true;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
@@ -3890,7 +3817,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleDisconnect = () => {
if (attachExistingSession) return;
if (statusRef.current === "disconnected") return;
agentRecoveryStoppedRef.current = true;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
@@ -4223,31 +4149,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
startReconnectRef.current = startReconnect;
const handleRetry = () => {
agentRecoveryStoppedRef.current = false;
startReconnect("manual");
};
manualReconnectRequestRef.current = handleRetry;
const canAgentReconnectRef = useRef<() => boolean>(() => false);
canAgentReconnectRef.current = () => statusRef.current === 'disconnected'
&& !agentRecoveryStoppedRef.current && !isAuthFailureMessage(error || '')
&& !auth.needsAuth && !needsHostKeyVerification && !isConnectionAwaitingUserInput
&& !autoReconnectLoopActiveRef.current && !reconnectWakeInFlightRef.current
&& reconnectPreparationTokenRef.current === null;
const readAgentRecoveryRef = useRef(() => ({ status: String(statusRef.current), waitingForInput: false, reconnecting: false, stopped: false }));
readAgentRecoveryRef.current = () => ({
status: statusRef.current,
waitingForInput: Boolean(auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput || isAuthFailureMessage(error || '')),
stopped: agentRecoveryStoppedRef.current,
reconnecting: autoReconnectLoopActiveRef.current || reconnectWakeInFlightRef.current
|| reconnectPreparationTokenRef.current !== null,
});
useEffect(() => {
if (attachExistingSession) return undefined;
return terminalReconnectRegistry.register(
sessionId,
() => manualReconnectRequestRef.current(),
() => canAgentReconnectRef.current(),
() => readAgentRecoveryRef.current(),
);
}, [attachExistingSession, sessionId]);
@@ -4653,7 +4562,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onAutocompleteReposition: () => autocompleteRepositionRef.current?.(),
terminalContextActionsRef,
isRestoringSelectionRef,
isPaneVisibleRef: isVisibleRef,
};
const safeFitRef = useRef(safeFit);
@@ -4911,7 +4819,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ isPromptSecurityAlternateScreenActive, CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setConnectionDeadlineAt, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
@@ -4924,7 +4832,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onDismiss={dismissScriptOverlay}
compactTopChrome={terminalSettings?.showHostInfoBar === false}
/>
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, connectionDeadlineAt, connectionTimeoutSeconds: CONNECTION_TIMEOUT / 1000, toast, zmodem }} isPaneMagnified={isPaneMagnified} />
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, timeLeft, toast, zmodem }} isPaneMagnified={isPaneMagnified} />
<ScriptSaveRecordingDialog
open={saveRecordingOpen}
code={recordedCode}
+1 -5
View File
@@ -2499,7 +2499,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (executor) {
pendingSends.push(Promise.resolve(executor(text, false, {
broadcast: false,
preferBracketedPaste: true,
// Preserve both prompt snapshots across an asynchronous wake: the
// peer may clear its prompt classification before the actual write.
...(focusedSensitive || recipientSensitive ? { sensitive: true } : {}),
@@ -2539,10 +2538,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sensitive = isTerminalSensitiveInputActive(targetId);
// Explicitly local: an omitted broadcast option falls back to the
// toolbar switch, which can still be on while compose is active-only.
pendingSends.push(Promise.resolve(executor(text, false, {
broadcast: false,
preferBracketedPaste: true,
})).then(
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
));
} else {
+1 -1
View File
@@ -218,7 +218,7 @@ test('an attached observe popup never owns automatic reconnect', () => {
const reconnectEnd = terminalSource.indexOf('const prepareRestoredReconnect', reconnectStart);
const reconnectSource = terminalSource.slice(reconnectStart, reconnectEnd);
assert.match(reconnectSource, /if \(attachExistingSession\) return false;/);
assert.match(reconnectSource, /\[attachExistingSession, host, setProgressLogs, t, terminalSettings, updateStatus\]/);
assert.match(reconnectSource, /\[attachExistingSession, host, t, terminalSettings, updateStatus\]/);
assert.match(terminalSource, /const startReconnect = async[\s\S]*?if \(attachExistingSession\) return;/);
assert.match(
terminalSource,
+17 -38
View File
@@ -4,7 +4,7 @@ import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '.
import { topTabsSessionsEqual } from '../domain/topTabsSessionsEqual';
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
import { buildTabShortcutNumberById } from '../application/app/tabShortcutTargets';
import { useTrackShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
import type { EditorTabChrome } from '../application/state/editorTabStore';
import { collectSessionIds } from '../domain/workspace';
import {
@@ -56,11 +56,6 @@ const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
const noDragRegionStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties;
const emptyTabStyle: React.CSSProperties = {};
// Hoisted so the memoized root tabs see a stable icon element every render.
const VAULT_TAB_ICON = <FolderLock size={14} />;
const SFTP_TAB_ICON = <Folder size={14} />;
type WorkspaceTabSessions = { ids: string[]; sessions: TerminalSession[] };
const EMPTY_WORKSPACE_TAB_SESSIONS: WorkspaceTabSessions = { ids: [], sessions: [] };
export function computeHostTreeTabGutter(hostTreeLayoutWidth: number, toggleRight: number): number {
return Math.max(0, hostTreeLayoutWidth - toggleRight);
@@ -247,19 +242,18 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
: hotkeyScheme === 'pc'
? switchTabKeyBinding?.pc ?? null
: null;
// Badges only appear while the switch modifier is held. The held flag is a
// small store read by each tab's TabIconSlot, so pressing Ctrl/⌘ does not
// re-render the tab bar; the numbers themselves only change with the tabs.
useTrackShortcutModifierHeld(showTabNumberBadges ? switchTabKey : null, hotkeyScheme);
const shortcutModifierHeld = useShortcutModifierHeld(switchTabKey, hotkeyScheme);
const tabShortcutNumbers = useMemo(() => {
if (!showTabNumberBadges || hotkeyScheme === 'disabled') return null;
// Keep the tab bar quiet until the modifier for the active shortcut scheme
// is held, while retaining the existing setting as the master toggle.
if (!showTabNumberBadges || hotkeyScheme === 'disabled' || !shortcutModifierHeld) return null;
return buildTabShortcutNumberById({
showSftpTab,
shellOnlyTabNumberShortcuts,
orderedTabs,
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
});
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, orderedTabs, editorTabs]);
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, shortcutModifierHeld, orderedTabs, editorTabs]);
const isHostTreeOpen = useTerminalHostTreeOpen();
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const toggleHostTree = useToggleTerminalHostTree();
@@ -526,26 +520,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
isHostTreeOpen,
]);
// Per-workspace session lists, rebuilt only when workspaces/sessions change
// (not on every tab switch, drag frame or modifier change) so the memoized
// WorkspaceTopTab keeps its props.
const workspaceTabSessions = useMemo(() => {
const sessionById = new Map(sessions.map((session) => [session.id, session]));
return new Map(workspaces.map((workspace): [string, WorkspaceTabSessions] => {
const ids = collectSessionIds(workspace.root);
// Detach-menu labels resolve presentation inside WorkspaceTopTab so
// dynamic title updates stay live without remapping the whole bar.
const workspaceSessions = ids
.map((sessionId) => sessionById.get(sessionId))
.filter((session): session is TerminalSession => Boolean(session));
return [workspace.id, { ids, sessions: workspaceSessions }];
}));
}, [sessions, workspaces]);
const handleDetachSessionFromWorkspace = useCallback(
(_workspaceId: string, sessionId: string) => onRemoveSessionFromWorkspace(sessionId),
[onRemoveSessionFromWorkspace],
);
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
// Payload must be written on the original dragstart. A slow reorder calls
// this again from `drag`, when DataTransfer is already read-only.
@@ -955,15 +929,20 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const shiftStyle = tabShiftStyles[workspace.id] || emptyTabStyle;
const showDropIndicatorBefore = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'before';
const showDropIndicatorAfter = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'after';
const workspaceSessionLists = workspaceTabSessions.get(workspace.id) ?? EMPTY_WORKSPACE_TAB_SESSIONS;
const workspaceSessionIds = collectSessionIds(workspace.root);
// Detach-menu labels resolve presentation inside WorkspaceTopTab so
// dynamic title updates stay live without remapping the whole bar.
const workspaceSessions = workspaceSessionIds
.map((sessionId) => sessions.find((s) => s.id === sessionId))
.filter((s): s is TerminalSession => Boolean(s));
return (
<WorkspaceTopTab
key={workspace.id}
workspace={workspace}
paneCount={paneCount}
workspaceSessionIds={workspaceSessionLists.ids}
workspaceSessions={workspaceSessionLists.sessions}
workspaceSessionIds={workspaceSessionIds}
workspaceSessions={workspaceSessions}
dynamicTabTitleMode={dynamicTabTitleMode}
isBeingDragged={isBeingDragged}
isDraggingForReorder={isDraggingForReorder}
@@ -979,7 +958,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onRenameWorkspace={onRenameWorkspace}
onCopyWorkspace={onCopyWorkspace}
onCloseWorkspace={onCloseWorkspace}
onDetachSessionFromWorkspace={handleDetachSessionFromWorkspace}
onDetachSessionFromWorkspace={(_workspaceId, sessionId) => onRemoveSessionFromWorkspace(sessionId)}
renderBulkCloseItems={renderBulkCloseItems}
t={t}
tabAnimationClass={getTabAnimationClass(workspace.id)}
@@ -1061,7 +1040,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<RootTopTab
tabId="vault"
label="Vaults"
icon={VAULT_TAB_ICON}
icon={<FolderLock size={14} />}
className="rounded"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('vault')}
@@ -1070,7 +1049,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<RootTopTab
tabId="sftp"
label="SFTP"
icon={SFTP_TAB_ICON}
icon={<Folder size={14} />}
className="rounded-t-md"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('sftp')}
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff. Show more