mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9abfa87f71 | ||
|
|
e74c0600ec | ||
|
|
f32c6ef8f5 | ||
|
|
80d49e5d9f | ||
|
|
4569e6aa95 | ||
|
|
4b79eb1332 | ||
|
|
1c1ab80918 | ||
|
|
a6c52ff1b4 | ||
|
|
63df81c1cc | ||
|
|
8b369d7d4d | ||
|
|
364e2ff686 | ||
|
|
e29ba085a6 | ||
|
|
74faa46744 | ||
|
|
c93bb77176 | ||
|
|
9fb61841f7 |
No files matched your search
@@ -11,6 +11,7 @@ import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHo
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { AppShell } from './application/app/AppShell';
|
||||
import { AppLocalStateProvider } from './application/app/AppLocalState';
|
||||
@@ -75,6 +76,10 @@ function AppWithProviders() {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return setupCodexAppServerInteractionBridge();
|
||||
}, []);
|
||||
|
||||
useExternalMcpGrantPersister();
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,6 +38,7 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
|
||||
assert.match(appSideEffectsSource, /connectionLogs: connectionLogsRef\.current/);
|
||||
assert.match(terminalHostSource, /useTerminalAppearanceInjection/);
|
||||
assert.match(terminalHostSource, /includeChromeSurfaces: followAppTerminalTheme/);
|
||||
assert.match(terminalHostSource, /useTerminalAppearanceInjection\(accentedGlobalAppearance/);
|
||||
assert.match(terminalHostSource, /clearThemeIntent\(\)/);
|
||||
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
|
||||
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
|
||||
|
||||
@@ -193,6 +193,31 @@ test('Hosts assemble bags field-by-field without spreading prepared domains', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('published Host bags omit notes, accent, and connectionLogs churn fields', () => {
|
||||
const vaultHost = hostSources['VaultHost.tsx'];
|
||||
const terminalHost = hostSources['TerminalHost.tsx'];
|
||||
assert.ok(vaultHost && terminalHost);
|
||||
|
||||
// Notes / connection logs live in dedicated stores — VaultHost must not
|
||||
// publish them into the vault domain bag that AppView consumes.
|
||||
assert.doesNotMatch(vaultHost, /\bnotes\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /\bconnectionLogs\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /notesStore|connectionLogsStore|useNotesStore|useConnectionLogs/);
|
||||
|
||||
// Accent feeds useThemeRuntime for local injection only; the published
|
||||
// terminal domain bag must not list accentMode/customAccent fields.
|
||||
const domainStart = terminalHost.indexOf('const terminalDomain = useMemo');
|
||||
assert.notEqual(domainStart, -1);
|
||||
const domain = terminalHost.slice(domainStart, terminalHost.indexOf('useLayoutEffect(() => {\n if (terminalDomain)', domainStart));
|
||||
assert.doesNotMatch(domain, /accentMode/);
|
||||
assert.doesNotMatch(domain, /customAccent/);
|
||||
assert.match(domain, /currentTerminalTheme,/);
|
||||
assert.match(
|
||||
terminalHost,
|
||||
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
|
||||
);
|
||||
});
|
||||
|
||||
test('AppShell uses default memo (store-driven), not always-rerender comparator', () => {
|
||||
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
|
||||
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
|
||||
|
||||
@@ -646,8 +646,17 @@ export function AppSideEffects() {
|
||||
};
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const pendingTrayConnectFlushKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || pendingTrayPanelConnectHostIds.length === 0) return;
|
||||
if (!isVaultInitialized) return;
|
||||
if (pendingTrayPanelConnectHostIds.length === 0) {
|
||||
pendingTrayConnectFlushKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
const flushKey = pendingTrayPanelConnectHostIds.join('\0');
|
||||
if (pendingTrayConnectFlushKeyRef.current === flushKey) return;
|
||||
pendingTrayConnectFlushKeyRef.current = flushKey;
|
||||
flushQueuedTrayPanelConnectHostsImpl(() => ({
|
||||
connectNow: _handleTrayPanelConnect,
|
||||
pendingHostIds: pendingTrayPanelConnectHostIds,
|
||||
|
||||
@@ -78,6 +78,30 @@ test("editor tabs use the followed terminal theme when follow-app terminal theme
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("follow-app chrome applies custom accent onto the published base theme", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
accentMode: "custom",
|
||||
customAccent: "0 100% 50%",
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
assert.notEqual(resolved?.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
|
||||
@@ -68,7 +68,9 @@ export function resolveActiveChromeTheme({
|
||||
};
|
||||
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
if (resolveSessionAppearance) {
|
||||
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
|
||||
}
|
||||
@@ -90,12 +92,15 @@ export function resolveActiveChromeTheme({
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const explicitThemeId = logView.log.themeId;
|
||||
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
const base = explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(base, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
if (workspace.viewMode === "focus") {
|
||||
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
useSessionSnapshotActions,
|
||||
} from '../../state/sessionSnapshotStore';
|
||||
import { useSettingsChromeStore } from '../../state/settingsChromeStore';
|
||||
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
|
||||
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
||||
import {
|
||||
getTerminalSettingsActions,
|
||||
@@ -40,8 +39,6 @@ const IS_MAC_CLIENT =
|
||||
export function ChromeHost() {
|
||||
const { t } = useI18n();
|
||||
const settingsChrome = useSettingsChromeStore();
|
||||
const appearance = useAppearanceChromeStore();
|
||||
void appearance; // accent leaves subscribe directly; keep import for island contract
|
||||
const session = useSessionSnapshot();
|
||||
const sessionActions = useSessionSnapshotActions();
|
||||
const vault = useVaultSnapshot();
|
||||
|
||||
@@ -85,6 +85,7 @@ export function TerminalHost() {
|
||||
|
||||
const {
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
clearIntent: clearThemeIntent,
|
||||
settleManualIntent: settleManualThemeIntent,
|
||||
pickTheme: pickTerminalTheme,
|
||||
@@ -92,7 +93,9 @@ export function TerminalHost() {
|
||||
currentTerminalTheme,
|
||||
} = themeRuntime;
|
||||
|
||||
useTerminalAppearanceInjection(globalAppearance, {
|
||||
// Inject live accent into CSS vars without publishing accented theme identity
|
||||
// into the terminal domain bag (accent drag must not rebuild AppShell).
|
||||
useTerminalAppearanceInjection(accentedGlobalAppearance, {
|
||||
includeChromeSurfaces: followAppTerminalTheme,
|
||||
});
|
||||
|
||||
@@ -103,6 +106,8 @@ export function TerminalHost() {
|
||||
clearThemeIntent();
|
||||
}, [followAppTerminalTheme, clearThemeIntent]);
|
||||
|
||||
// Bridge exposes the stable base theme only — ChromeHost must not republish
|
||||
// when accentedGlobalAppearance identity churns during color-picker drag.
|
||||
const themeBridgeActions = useMemo(() => ({
|
||||
clearThemeIntent,
|
||||
settleManualThemeIntent,
|
||||
|
||||
@@ -126,3 +126,146 @@ test('cancelled startup update check resets its latch instead of skipping foreve
|
||||
assert.match(tail, /startupCheckTimeoutRef\.current = setTimeout\(async \(\) => \{\s*\n\s*checkArmed = false;/);
|
||||
assert.match(tail, /if \(checkArmed\) \{\s*\n\s*hasCheckedOnStartupRef\.current = false;\s*\n\s*\}/);
|
||||
});
|
||||
|
||||
test('terminal popup config survives StrictMode unsubscribe/resubscribe', () => {
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload.cjs', import.meta.url), 'utf8');
|
||||
const apiSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(preloadSource, /lastPayload:\s*null/);
|
||||
assert.match(
|
||||
preloadSource,
|
||||
/terminalPopupConfigState\.lastPayload = payload/,
|
||||
'incoming popup config must be retained beyond the one-shot pending slot',
|
||||
);
|
||||
|
||||
const subscribeAt = apiSource.indexOf('onTerminalPopupConfig:');
|
||||
assert.notEqual(subscribeAt, -1);
|
||||
const subscribe = apiSource.slice(subscribeAt, subscribeAt + 700);
|
||||
assert.match(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.pending \?\? terminalPopupConfigState\.lastPayload/,
|
||||
'resubscribe must replay lastPayload after pending was drained',
|
||||
);
|
||||
assert.match(subscribe, /terminalPopupConfigState\.pending = null/);
|
||||
assert.doesNotMatch(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.lastPayload = null/,
|
||||
'StrictMode remount must not clear lastPayload on subscribe',
|
||||
);
|
||||
});
|
||||
|
||||
test('vault init cancels the superseded StrictMode effect before publishing ready', () => {
|
||||
const vaultSource = readFileSync(
|
||||
new URL('../state/useVaultState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
'only the surviving init may mark the vault ready',
|
||||
);
|
||||
assert.match(initSlice, /if \(cancelled\) return;/);
|
||||
});
|
||||
|
||||
test('global hotkey registration cleans up across StrictMode remount', () => {
|
||||
const systemEffectsSource = readFileSync(
|
||||
new URL('../state/systemSettingsEffects.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const hotkeyAt = systemEffectsSource.indexOf('Persist and sync toggle window hotkey setting');
|
||||
assert.notEqual(hotkeyAt, -1);
|
||||
const hotkeyEffect = systemEffectsSource.slice(hotkeyAt, hotkeyAt + 2200);
|
||||
assert.match(hotkeyEffect, /let cancelled = false;/);
|
||||
assert.match(hotkeyEffect, /if \(cancelled\) return;/);
|
||||
assert.match(
|
||||
hotkeyEffect,
|
||||
/if \(didRegister\) \{\s*\n\s*bridge\?\.unregisterGlobalHotkey/,
|
||||
'cleanup must unregister a registration started by this effect',
|
||||
);
|
||||
// Early return before notify must not skip returning the cleanup function.
|
||||
assert.doesNotMatch(
|
||||
hotkeyEffect,
|
||||
/if \(!persistMountedRef\.current\) return;\s*\n\s*notifySettingsChanged/,
|
||||
);
|
||||
});
|
||||
|
||||
test('settings persistMountedRef resets on StrictMode cleanup', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const markAt = settingsSource.indexOf('Mark persist effects mounted AFTER all persist useEffects');
|
||||
assert.notEqual(markAt, -1);
|
||||
const markEffect = settingsSource.slice(markAt, markAt + 500);
|
||||
assert.match(markEffect, /persistMountedRef\.current = true;/);
|
||||
assert.match(
|
||||
markEffect,
|
||||
/return \(\) => \{\s*\n\s*persistMountedRef\.current = false;\s*\n\s*\};/,
|
||||
'remount must treat boot as a fresh mount, not a settings change',
|
||||
);
|
||||
});
|
||||
|
||||
test('tray panel connect flush latches against StrictMode double invoke', () => {
|
||||
const sideEffectsSource = readFileSync(
|
||||
new URL('./AppSideEffects.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(sideEffectsSource, /pendingTrayConnectFlushKeyRef/);
|
||||
assert.match(
|
||||
sideEffectsSource,
|
||||
/if \(pendingTrayConnectFlushKeyRef\.current === flushKey\) return;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ssh transport idle TTL notify latches against StrictMode double invoke', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(settingsSource, /lastPushedSshTransportIdleTtlRef/);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/if \(lastPushedSshTransportIdleTtlRef\.current === sshTransportIdleTtlMs\) return;/,
|
||||
);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/lastPushedSshTransportIdleTtlRef\.current = sshTransportIdleTtlMs;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('terminal selection Ask-AI payload is consumed once under StrictMode', () => {
|
||||
const hostSource = readFileSync(
|
||||
new URL('../../components/terminalLayer/TerminalLayerSupport.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(hostSource, /consumedTerminalSelectionRequestIds/);
|
||||
assert.doesNotMatch(
|
||||
hostSource,
|
||||
/consumedTerminalSelectionRequestIdRef/,
|
||||
'component refs reset on StrictMode remount; use a module Set',
|
||||
);
|
||||
assert.match(
|
||||
hostSource,
|
||||
/if \(consumedTerminalSelectionRequestIds\.has\(pendingTerminalSelection\.requestId\)\)/,
|
||||
);
|
||||
const latchAt = hostSource.indexOf('markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId)');
|
||||
const draftAt = hostSource.indexOf('updateDraft(scopeKey, defaultAgentId');
|
||||
assert.ok(latchAt > 0 && draftAt > latchAt, 'must latch before mutating the draft');
|
||||
});
|
||||
|
||||
test('Codex App Server interaction bridge is app-singleton like MCP approvals', () => {
|
||||
assert.match(appSource, /setupCodexAppServerInteractionBridge/);
|
||||
const panelSource = readFileSync(
|
||||
new URL('../../components/AIChatSidePanel.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panelSource,
|
||||
/setupCodexAppServerInteractionBridge/,
|
||||
'per-panel Codex IPC listeners fan out approvals under retained multi-tab mounts',
|
||||
);
|
||||
});
|
||||
@@ -267,6 +267,32 @@ test('shared host tree uses the followed terminal theme when follow-app terminal
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('follow-app host tree applies custom accent onto the published base theme', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'custom',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '0 100% 50%',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
assert.notEqual(resolved.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
|
||||
@@ -179,7 +179,9 @@ export function resolveWorkTabHostTreeTheme({
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (!activeHostId || followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
|
||||
@@ -81,6 +81,19 @@ test('notesStore gated helpers stay empty and never notify', () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
test('useNotesStore source gates subscribe when enabled is false', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const source = readFileSync(new URL('./notesStore.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /enabled \? subscribeNotes : subscribeNotesNoop/);
|
||||
assert.match(source, /enabled \? subscribeNotesActions : subscribeNotesNoop/);
|
||||
assert.match(source, /getNotesSnapshot/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
|
||||
'hidden notes mounts should keep the last live snapshot, not flash empty',
|
||||
);
|
||||
});
|
||||
|
||||
test('registerNotesActions exposes update handlers', () => {
|
||||
const calls: string[] = [];
|
||||
registerNotesActions({
|
||||
|
||||
@@ -112,20 +112,25 @@ const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
|
||||
|
||||
/**
|
||||
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
|
||||
*
|
||||
* Pass `{ enabled: false }` for retained-but-hidden mounts (e.g. terminal
|
||||
* notes side panel) so vault note publishes do not re-render them. Snapshot
|
||||
* reads still use the live store so reopen does not flash empty data.
|
||||
*/
|
||||
export function useNotesStore(): {
|
||||
export function useNotesStore(options?: { enabled?: boolean }): {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
updateNotes: NotesActions['updateNotes'];
|
||||
updateNoteGroups: NotesActions['updateNoteGroups'];
|
||||
} {
|
||||
const enabled = options?.enabled !== false;
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribeNotes,
|
||||
enabled ? subscribeNotes : subscribeNotesNoop,
|
||||
getNotesSnapshot,
|
||||
getNotesSnapshot,
|
||||
);
|
||||
const actions = useSyncExternalStore(
|
||||
subscribeNotesActions,
|
||||
enabled ? subscribeNotesActions : subscribeNotesNoop,
|
||||
getNotesActions,
|
||||
getNotesActions,
|
||||
);
|
||||
|
||||
@@ -62,20 +62,25 @@ export function useSystemSettingsEffects({
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
let didRegister = false;
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
setHotkeyRegistrationError(null);
|
||||
didRegister = true;
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result?.success === false) {
|
||||
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
|
||||
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
|
||||
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
|
||||
});
|
||||
@@ -87,9 +92,20 @@ export function useSystemSettingsEffects({
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip settings-sync IPC on initial mount; still return cleanup below.
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Drop Mount1's registration before Mount2 re-registers (StrictMode), and
|
||||
// release the accelerator on real unmount / disable transitions.
|
||||
if (didRegister) {
|
||||
bridge?.unregisterGlobalHotkey?.().catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to unregister hotkey on cleanup', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [
|
||||
toggleWindowHotkey,
|
||||
enabled,
|
||||
|
||||
@@ -756,7 +756,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
|
||||
// Push idle TTL to the main-process transport registry on load and whenever
|
||||
// it changes (including cross-window IPC/storage updates to local state).
|
||||
const lastPushedSshTransportIdleTtlRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (lastPushedSshTransportIdleTtlRef.current === sshTransportIdleTtlMs) return;
|
||||
lastPushedSshTransportIdleTtlRef.current = sshTransportIdleTtlMs;
|
||||
notifySettingsChanged(STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS, sshTransportIdleTtlMs);
|
||||
}, [sshTransportIdleTtlMs, notifySettingsChanged]);
|
||||
|
||||
@@ -1642,11 +1645,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged,
|
||||
});
|
||||
|
||||
// Fix 1: Mark all persist effects as mounted.
|
||||
// This MUST be declared AFTER all persist useEffects so that React runs it last
|
||||
// during the initial mount cycle (effects fire in declaration order).
|
||||
// Mark persist effects mounted AFTER all persist useEffects so React runs
|
||||
// this last (declaration order). Cleanup clears the latch for remount.
|
||||
useEffect(() => {
|
||||
persistMountedRef.current = true;
|
||||
return () => {
|
||||
persistMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const runtimeSource = readFileSync(new URL('./useThemeRuntime.ts', import.meta.url), 'utf8');
|
||||
const terminalHostSource = readFileSync(
|
||||
new URL('../app/hosts/TerminalHost.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('published currentTerminalTheme resolves without live custom accent', () => {
|
||||
// Base global appearance must use accentMode: 'theme' so HSL drag does not
|
||||
// mint a new theme object into TerminalHost / AppShell domain bags.
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/accentMode: 'theme',\s*\n\s*customAccent: '',/,
|
||||
'globalAppearance must ignore live custom accent',
|
||||
);
|
||||
assert.match(runtimeSource, /accentedGlobalAppearance/);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/currentTerminalTheme: globalAppearance\.theme/,
|
||||
'published theme must come from the stable base appearance',
|
||||
);
|
||||
});
|
||||
|
||||
test('TerminalHost injects accented appearance without bridging it', () => {
|
||||
assert.match(
|
||||
terminalHostSource,
|
||||
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
|
||||
'CSS vars still track live accent during drag',
|
||||
);
|
||||
const bridgeStart = terminalHostSource.indexOf('const themeBridgeActions = useMemo');
|
||||
assert.notEqual(bridgeStart, -1);
|
||||
const bridge = terminalHostSource.slice(bridgeStart, bridgeStart + 900);
|
||||
assert.match(bridge, /globalAppearance,/);
|
||||
assert.doesNotMatch(
|
||||
bridge,
|
||||
/accentedGlobalAppearance/,
|
||||
'theme bridge must not republish on accented appearance identity churn',
|
||||
);
|
||||
});
|
||||
@@ -65,8 +65,10 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
const accentRef = useRef({ accentMode, customAccent });
|
||||
accentRef.current = { accentMode, customAccent };
|
||||
|
||||
// Chrome / injection consumers need live accent on every settings change.
|
||||
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
|
||||
// Live accent settings are only for CSS-var injection. The published bag must
|
||||
// resolve against a stable base theme so accent drag does not rebuild
|
||||
// TerminalHost / AppShell domains every HSL tick.
|
||||
const appearanceSettingsForInject = useMemo((): TerminalAppearanceSettings => ({
|
||||
...appearanceSettingsBase,
|
||||
accentMode,
|
||||
customAccent,
|
||||
@@ -74,9 +76,19 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
|
||||
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettings,
|
||||
settings: {
|
||||
...appearanceSettingsBase,
|
||||
accentMode: 'theme',
|
||||
customAccent: '',
|
||||
},
|
||||
customThemes,
|
||||
}), [userIntent, appearanceSettings, customThemes]);
|
||||
}), [userIntent, appearanceSettingsBase, customThemes]);
|
||||
|
||||
const accentedGlobalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
|
||||
userIntent,
|
||||
settings: appearanceSettingsForInject,
|
||||
customThemes,
|
||||
}), [userIntent, appearanceSettingsForInject, customThemes]);
|
||||
|
||||
// Read accent from a ref so this callback identity does not churn on drag.
|
||||
// Callers that invoke it after an accent-only update still get the latest
|
||||
@@ -125,10 +137,12 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
}, []);
|
||||
|
||||
// Stable bag identity so App domain memos can depend on members (or the
|
||||
// bag) without thrashing on every parent render.
|
||||
// bag) without thrashing on every parent render. Accented appearance is
|
||||
// intentionally excluded — injection reads it separately.
|
||||
return useMemo(() => ({
|
||||
userIntent,
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
resolveFocusedAppearance,
|
||||
pickTheme,
|
||||
clearIntent,
|
||||
@@ -137,6 +151,7 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
|
||||
}), [
|
||||
userIntent,
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
resolveFocusedAppearance,
|
||||
pickTheme,
|
||||
clearIntent,
|
||||
|
||||
@@ -1039,11 +1039,13 @@ export const useVaultState = () => {
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const init = async () => {
|
||||
try {
|
||||
await withVaultImportLock("vault", async () => {
|
||||
recoverPluginImporterTransaction(localStorageAdapter, PLUGIN_IMPORT_TRANSACTION_KEYS);
|
||||
});
|
||||
if (cancelled) return;
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
@@ -1052,6 +1054,7 @@ export const useVaultState = () => {
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (cancelled) return;
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = normalizeVaultOrder(
|
||||
migrateHostsFromLegacyLineTimestamps(
|
||||
@@ -1098,6 +1101,7 @@ export const useVaultState = () => {
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (cancelled) return;
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
const orderedKeys = normalizeVaultOrder(decryptedKeys);
|
||||
setKeys(orderedKeys);
|
||||
@@ -1118,6 +1122,7 @@ export const useVaultState = () => {
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (cancelled) return;
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
const orderedIdentities = normalizeVaultOrder(decryptedIds);
|
||||
setIdentities(orderedIdentities);
|
||||
@@ -1133,6 +1138,7 @@ export const useVaultState = () => {
|
||||
if (savedProxyProfiles) {
|
||||
const proxyVer = ++proxyProfilesWriteVersion.current;
|
||||
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
|
||||
if (cancelled) return;
|
||||
if (proxyVer === proxyProfilesWriteVersion.current) {
|
||||
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
|
||||
setProxyProfiles(orderedProfiles);
|
||||
@@ -1143,6 +1149,8 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
@@ -1219,6 +1227,7 @@ export const useVaultState = () => {
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (cancelled) return;
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
|
||||
setGroupConfigs(sanitizedGC);
|
||||
@@ -1229,12 +1238,19 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
setVaultInitialized(true);
|
||||
// StrictMode remount cancels the first init; only the surviving effect
|
||||
// may publish "vault ready" or terminals can boot against empty keys.
|
||||
if (!cancelled) {
|
||||
setIsInitialized(true);
|
||||
setVaultInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -35,8 +35,8 @@ import {
|
||||
resolveDisplayedSession,
|
||||
} from './ai/aiPanelViewState';
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
endSendForKey,
|
||||
tryBeginSendForKey,
|
||||
} from './ai/draftSendGate';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import {
|
||||
@@ -55,7 +55,6 @@ import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSess
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
import { registerGrantPersister } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from '../infrastructure/ai/shared/codexAppServerInteractions';
|
||||
import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
|
||||
@@ -385,7 +384,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentDraftRef.current = currentDraft;
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
const draftSendInFlightRef = useRef(false);
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
@@ -1005,9 +1003,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
|
||||
const sendGateKey = currentSessionView?.id ?? `draft:${scopeKey}`;
|
||||
if (!tryBeginSendForKey(sendGateKey)) {
|
||||
return;
|
||||
}
|
||||
let sessionSendGateKey: string | null = null;
|
||||
|
||||
try {
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
@@ -1020,12 +1020,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
sessionSendGateKey = sessionId;
|
||||
if (!tryBeginSendForKey(sessionSendGateKey)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAIChatSessionStreaming(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
|
||||
const sendActiveProvider = isExternalAgent ? activeProvider : effectiveActiveProvider;
|
||||
@@ -1133,8 +1141,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
if (isDraftMode) {
|
||||
endDraftSend(draftSendInFlightRef);
|
||||
endSendForKey(sendGateKey);
|
||||
if (sessionSendGateKey && sessionSendGateKey !== sendGateKey) {
|
||||
endSendForKey(sessionSendGateKey);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -1155,12 +1164,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentAgentId !== 'catty'
|
||||
|| !session
|
||||
|| isStreaming
|
||||
|| isAIChatSessionStreaming(session.id)
|
||||
|| !effectiveActiveProvider
|
||||
|| !effectiveActiveModelId.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tryBeginSendForKey(session.id)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllersRef.current.set(session.id, controller);
|
||||
setStreamingForScope(session.id, true);
|
||||
@@ -1198,6 +1210,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (abortControllersRef.current.get(session.id) === controller) {
|
||||
abortControllersRef.current.delete(session.id);
|
||||
}
|
||||
endSendForKey(session.id);
|
||||
}
|
||||
}, [
|
||||
abortControllersRef,
|
||||
@@ -1296,8 +1309,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return registerGrantPersister((rule) => { addGrant(rule); });
|
||||
}, [addGrant]);
|
||||
|
||||
useEffect(() => setupCodexAppServerInteractionBridge(), []);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
setSteeringSessionId(current => current === activeSessionId ? null : current);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./LogView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('LogView applies live custom accent at the leaf from appearanceChromeStore', () => {
|
||||
assert.match(source, /useAppearanceChromeStore/);
|
||||
assert.match(source, /applyCustomAccentToTerminalTheme\(baseTheme, accentMode, customAccent\)/);
|
||||
// Must not rely on an accent-baked defaultTerminalTheme from AppShell bags.
|
||||
assert.match(
|
||||
source,
|
||||
/published defaultTerminalTheme is the stable base catalog/,
|
||||
);
|
||||
});
|
||||
+12
-6
@@ -9,6 +9,8 @@ import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, TerminalTheme } from "../types";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
import { applyCustomAccentToTerminalTheme } from "../domain/terminalAppearance";
|
||||
import { Button } from "./ui/button";
|
||||
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
|
||||
|
||||
@@ -40,6 +42,10 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
// Leaf accent: published defaultTerminalTheme is the stable base catalog
|
||||
// theme; apply live custom accent here so log replay matches terminal chrome
|
||||
// without thrashing AppShell on every HSL tick.
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const explicitThemeId = useMemo(() => {
|
||||
if (!log.themeId) return undefined;
|
||||
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|
||||
@@ -58,13 +64,13 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
if (previewTheme) {
|
||||
return previewTheme;
|
||||
}
|
||||
if (explicitThemeId) {
|
||||
return TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
const baseTheme = explicitThemeId
|
||||
? (TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
|| customThemes.find(t => t.id === explicitThemeId)
|
||||
|| defaultTerminalTheme;
|
||||
}
|
||||
return defaultTerminalTheme;
|
||||
}, [customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|| defaultTerminalTheme)
|
||||
: defaultTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [accentMode, customAccent, customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|
||||
const currentFontSize = log.fontSize ?? defaultFontSize;
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ import {
|
||||
shouldUseFreshSshConnectionForAutomation,
|
||||
} from "@/domain/hostConnectScripts.ts";
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
@@ -352,6 +353,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const message = openExternalErrorTextRef.current;
|
||||
toast.error(message.body, message.title);
|
||||
}, []);
|
||||
// Reactive vault-ready flag so restored panes re-arm boot after hydration
|
||||
// (module isVaultInitialized() alone is not a React dependency).
|
||||
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
|
||||
const connectScriptsConsumedRef = useRef(false);
|
||||
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
|
||||
const connectScriptsInFlightRef = useRef(false);
|
||||
@@ -4014,7 +4018,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, 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, 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, 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, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, 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, 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, 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, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -102,7 +102,10 @@ test("notes are not VaultView props — the notes section reads notesStore", ()
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(vaultViewSource, /prev\.notes === next\.notes/);
|
||||
assert.match(vaultViewLayoutSource, /useNotesStore\(\)/);
|
||||
assert.match(
|
||||
vaultViewLayoutSource,
|
||||
/useNotesStore\(\{\s*enabled:\s*isActive,?\s*\}\)/,
|
||||
);
|
||||
assert.match(vaultViewLayoutSource, /function VaultNotesSection/);
|
||||
});
|
||||
|
||||
|
||||
@@ -445,6 +445,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [pendingOpenNoteId, setPendingOpenNoteId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const handleOpenNoteIdHandled = useCallback(() => {
|
||||
setPendingOpenNoteId(null);
|
||||
}, []);
|
||||
const [pendingOpenSnippetId, setPendingOpenSnippetId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -1515,7 +1518,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onUpdateSnippetPackages,
|
||||
onUpdateSnippets,
|
||||
openNoteId: pendingOpenNoteId,
|
||||
onOpenNoteIdHandled: () => setPendingOpenNoteId(null),
|
||||
onOpenNoteIdHandled: handleOpenNoteIdHandled,
|
||||
openSnippetId: pendingOpenSnippetId,
|
||||
onOpenSnippetIdHandled: () => setPendingOpenSnippetId(null),
|
||||
Pin,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
endDraftSend,
|
||||
endSend,
|
||||
endSendForKey,
|
||||
tryBeginDraftSend,
|
||||
tryBeginSend,
|
||||
tryBeginSendForKey,
|
||||
} from "./draftSendGate.ts";
|
||||
|
||||
test("draft send gate allows only one in-flight draft send at a time", () => {
|
||||
@@ -16,3 +21,36 @@ test("draft send gate allows only one in-flight draft send at a time", () => {
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
});
|
||||
|
||||
test("send gate aliases cover session-mode re-entry the same way", () => {
|
||||
const gate = { current: false };
|
||||
assert.equal(tryBeginSend(gate), true);
|
||||
assert.equal(tryBeginSend(gate), false);
|
||||
endSend(gate);
|
||||
assert.equal(tryBeginSend(gate), true);
|
||||
});
|
||||
|
||||
test("module send latch survives across independent gate objects (remount-safe)", () => {
|
||||
const key = `test-send-${Date.now()}-${Math.random()}`;
|
||||
assert.equal(tryBeginSendForKey(key), true);
|
||||
assert.equal(tryBeginSendForKey(key), false);
|
||||
endSendForKey(key);
|
||||
assert.equal(tryBeginSendForKey(key), true);
|
||||
endSendForKey(key);
|
||||
});
|
||||
|
||||
test("AIChatSidePanel gates every send including session mode", () => {
|
||||
const source = readFileSync(new URL("../AIChatSidePanel.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /tryBeginSendForKey\(sendGateKey\)/);
|
||||
assert.match(source, /isAIChatSessionStreaming\(sessionId\)/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/isDraftMode && !tryBeginDraftSend/,
|
||||
"session-mode sends must share the sync re-entry gate",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/sendInFlightRef/,
|
||||
"component refs reset on StrictMode remount; use module key latch",
|
||||
);
|
||||
});
|
||||
@@ -10,3 +10,19 @@ export function tryBeginDraftSend(gate: { current: boolean }): boolean {
|
||||
export function endDraftSend(gate: { current: boolean }): void {
|
||||
gate.current = false;
|
||||
}
|
||||
|
||||
export const tryBeginSend = tryBeginDraftSend;
|
||||
export const endSend = endDraftSend;
|
||||
|
||||
const sendInFlightByKey = new Set<string>();
|
||||
|
||||
export function tryBeginSendForKey(key: string): boolean {
|
||||
if (!key || sendInFlightByKey.has(key)) return false;
|
||||
sendInFlightByKey.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endSendForKey(key: string): void {
|
||||
if (!key) return;
|
||||
sendInFlightByKey.delete(key);
|
||||
}
|
||||
@@ -473,7 +473,7 @@ const deleteLexicalTextRange = (range: Range, onUpdate: () => void): boolean =>
|
||||
return didDelete;
|
||||
};
|
||||
|
||||
export function InlineMarkdownEditor({
|
||||
export const InlineMarkdownEditor = React.memo(function InlineMarkdownEditor({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
@@ -486,6 +486,8 @@ export function InlineMarkdownEditor({
|
||||
const { t } = useI18n();
|
||||
const editorRef = useRef<MDXEditorMethods>(null);
|
||||
const latestMarkdownRef = useRef(value);
|
||||
const syncedPropValueRef = useRef(value);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastLinkActivationRef = useRef<{ href: string; at: number } | null>(null);
|
||||
const [hostPicker, setHostPicker] = useState<HostPickerState>({
|
||||
@@ -530,7 +532,14 @@ export function InlineMarkdownEditor({
|
||||
}, [hostCandidates, hostPicker.query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestMarkdownRef.current === value) return;
|
||||
if (latestMarkdownRef.current === value) {
|
||||
syncedPropValueRef.current = value;
|
||||
return;
|
||||
}
|
||||
if (latestMarkdownRef.current !== syncedPropValueRef.current) {
|
||||
return;
|
||||
}
|
||||
syncedPropValueRef.current = value;
|
||||
latestMarkdownRef.current = value;
|
||||
editorRef.current?.setMarkdown(value);
|
||||
}, [value]);
|
||||
@@ -650,9 +659,32 @@ export function InlineMarkdownEditor({
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(annotateHostLinks);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [annotateHostLinks, value]);
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let frame = 0;
|
||||
const schedule = () => {
|
||||
if (frame) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
annotateHostLinks();
|
||||
});
|
||||
};
|
||||
|
||||
schedule();
|
||||
if (editorMode !== "preview") {
|
||||
return () => {
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(schedule);
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [annotateHostLinks, editorMode]);
|
||||
|
||||
const annotateCodeBlockCopyButtons = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1001,4 +1033,4 @@ export function InlineMarkdownEditor({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
const layoutSource = readFileSync(
|
||||
new URL("../vault/VaultViewLayout.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("note content drafts stay in refs so MDX keystrokes do not rebuild the shell", () => {
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/const \[draftContent, setDraftContent\]/,
|
||||
"draftContent React state causes a full NotesManager render per keystroke",
|
||||
);
|
||||
assert.match(
|
||||
managerSource,
|
||||
/draftContentRef\.current = fields\.content;/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/setDraftContent\(fields\.content\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("NotesManager teardown flush uses a stable ref under StrictMode", () => {
|
||||
assert.match(managerSource, /flushNoteDraftRef\.current = flushNoteDraft/);
|
||||
assert.match(
|
||||
managerSource,
|
||||
/useEffect\(\(\) => \(\) => \{\s*\n\s*flushNoteDraftRef\.current\(\);\s*\n\s*\}, \[\]\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Vault notes section is memoized against unrelated VaultView churn", () => {
|
||||
assert.match(layoutSource, /const MemoVaultNotesSection = React\.memo/);
|
||||
assert.match(layoutSource, /<MemoVaultNotesSection\b/);
|
||||
assert.match(layoutSource, /const handleNotesOpenHost = useCallback/);
|
||||
assert.match(layoutSource, /onOpenHost=\{handleNotesOpenHost\}/);
|
||||
assert.match(
|
||||
layoutSource,
|
||||
/useNotesStore\(\{\s*\n\s*enabled:\s*isActive,\s*\n\s*\}\)/,
|
||||
);
|
||||
assert.match(
|
||||
layoutSource,
|
||||
/if \(next\.isActive && prev\.hosts !== next\.hosts\) return false;/,
|
||||
"hidden retained notes must ignore hosts identity churn",
|
||||
);
|
||||
});
|
||||
|
||||
test("hidden terminal notes side panel does not subscribe to notes publishes", () => {
|
||||
const slotsSource = readFileSync(
|
||||
new URL("../terminalLayer/terminalLayerSidePanelSlots.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(slotsSource, /useNotesStore\(\{\s*enabled:\s*isVisible\s*\}\)/);
|
||||
});
|
||||
|
||||
test("notes manager prefetches the MDXEditor chunk when becoming active", () => {
|
||||
assert.match(managerSource, /prefetchInlineMarkdownEditor/);
|
||||
assert.match(
|
||||
managerSource,
|
||||
/if \(!isActive\) return;\s*\n\s*prefetchInlineMarkdownEditor\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("mode toggle flushes ref-only content drafts before remounting the editor", () => {
|
||||
assert.match(
|
||||
managerSource,
|
||||
/flushNoteDraft\(\);\s*\n\s*setNoteEditorMode/,
|
||||
"preview/edit remount must see the in-progress body",
|
||||
);
|
||||
});
|
||||
|
||||
test("host-link annotation does not re-run on every markdown value keystroke", () => {
|
||||
const editorSource = readFileSync(
|
||||
new URL("./InlineMarkdownEditor.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
editorSource,
|
||||
/annotateHostLinks,\s*value\s*\]/,
|
||||
"value in annotateHostLinks effect deps walks the DOM on every keystroke",
|
||||
);
|
||||
assert.match(editorSource, /\[annotateHostLinks, editorMode\]/);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/syncedPropValueRef/,
|
||||
"external note publishes must not clobber an in-progress local draft",
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/latestMarkdownRef\.current !== syncedPropValueRef\.current/,
|
||||
);
|
||||
});
|
||||
@@ -83,6 +83,11 @@ const InlineMarkdownEditor = lazy(() =>
|
||||
import("./InlineMarkdownEditor").then((module) => ({ default: module.InlineMarkdownEditor })),
|
||||
);
|
||||
|
||||
/** Warm the MDXEditor chunk before Suspense. */
|
||||
export function prefetchInlineMarkdownEditor(): void {
|
||||
void import("./InlineMarkdownEditor");
|
||||
}
|
||||
|
||||
interface NoteFolderNode {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -412,7 +417,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
const draftTimerRef = useRef<number | null>(null);
|
||||
const [draftNoteId, setDraftNoteId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState<string | null>(null);
|
||||
const [draftContent, setDraftContent] = useState<string | null>(null);
|
||||
|
||||
const clearDraftTimer = useCallback(() => {
|
||||
if (draftTimerRef.current !== null) {
|
||||
@@ -432,7 +436,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
draftContentRef.current = null;
|
||||
setDraftNoteId(null);
|
||||
setDraftTitle(null);
|
||||
setDraftContent(null);
|
||||
if (title === null && content === null) return;
|
||||
commitNotes(sortedNotesRef.current.map((note) => {
|
||||
if (note.id !== noteId) return note;
|
||||
@@ -458,27 +461,34 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
flushNoteDraft();
|
||||
}
|
||||
draftNoteIdRef.current = noteId;
|
||||
setDraftNoteId(noteId);
|
||||
if (fields.title !== undefined) {
|
||||
draftTitleRef.current = fields.title;
|
||||
setDraftNoteId(noteId);
|
||||
setDraftTitle(fields.title);
|
||||
}
|
||||
if (fields.content !== undefined) {
|
||||
draftContentRef.current = fields.content;
|
||||
setDraftContent(fields.content);
|
||||
}
|
||||
scheduleNoteDraftFlush();
|
||||
}, [flushNoteDraft, scheduleNoteDraftFlush]);
|
||||
|
||||
const flushNoteDraftRef = useRef(flushNoteDraft);
|
||||
flushNoteDraftRef.current = flushNoteDraft;
|
||||
|
||||
useEffect(() => () => {
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft]);
|
||||
flushNoteDraftRef.current();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) return;
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
prefetchInlineMarkdownEditor();
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const flushOnTeardown = () => {
|
||||
flushNoteDraft();
|
||||
@@ -509,14 +519,12 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
? {
|
||||
...selectedNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
...(draftContent !== null ? { content: draftContent } : {}),
|
||||
}
|
||||
: selectedNote;
|
||||
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
|
||||
? {
|
||||
...overlayNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
...(draftContent !== null ? { content: draftContent } : {}),
|
||||
}
|
||||
: overlayNote;
|
||||
|
||||
@@ -643,9 +651,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
draftTitleRef.current = title;
|
||||
};
|
||||
|
||||
const saveNoteContentDraft = (note: VaultNote, content: string) => {
|
||||
updateNoteDraft(note.id, { content });
|
||||
};
|
||||
const saveNoteContentDraft = useCallback((noteId: string, content: string) => {
|
||||
updateNoteDraft(noteId, { content });
|
||||
}, [updateNoteDraft]);
|
||||
|
||||
const handleOpenHostFromNote = useCallback((host: Host, noteId: string) => {
|
||||
onOpenHost?.(host, { noteId });
|
||||
@@ -665,9 +673,12 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
data-note-mode-switch
|
||||
aria-label={label}
|
||||
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
|
||||
onClick={() => setNoteEditorMode((currentMode) => (
|
||||
currentMode === "edit" ? "preview" : "edit"
|
||||
))}
|
||||
onClick={() => {
|
||||
flushNoteDraft();
|
||||
setNoteEditorMode((currentMode) => (
|
||||
currentMode === "edit" ? "preview" : "edit"
|
||||
));
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</Button>
|
||||
@@ -1709,7 +1720,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView, content)}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
@@ -1799,7 +1810,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
onChange={(content) => saveNoteContentDraft(overlayNoteView, content)}
|
||||
onChange={(content) => saveNoteContentDraft(overlayNoteView.id, content)}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
||||
|
||||
@@ -455,6 +455,11 @@ test("terminal boot is cancelable and closes eagerly on cleanup", () => {
|
||||
|
||||
assert.match(effectsSource, /const bootAbort = new AbortController\(\)/);
|
||||
assert.match(effectsSource, /const bootStartOptions = \{ signal: bootAbort\.signal \}/);
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/queueMicrotask\(\(\) => \{\s*\n\s*if \(disposed\) return;\s*\n\s*void boot\(\);/,
|
||||
"backend boot must defer past StrictMode's synchronous re-invoke",
|
||||
);
|
||||
for (const starter of [
|
||||
"startPluginConnection",
|
||||
"startSerial",
|
||||
@@ -471,19 +476,36 @@ test("terminal boot is cancelable and closes eagerly on cleanup", () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup order: abort, then eager close, then the async capture/teardown.
|
||||
// Cleanup order: abort, then eager close + sync dispose for never-connected
|
||||
// boots (StrictMode remount), else the async capture/teardown path.
|
||||
const cleanupStart = effectsSource.indexOf(" disposed = true;");
|
||||
assert.notEqual(cleanupStart, -1);
|
||||
const cleanup = effectsSource.slice(cleanupStart);
|
||||
const abortAt = cleanup.indexOf("bootAbort.abort()");
|
||||
const closeAt = cleanup.indexOf("terminalBackend.closeSession(");
|
||||
const captureAt = cleanup.indexOf("void completeClose()");
|
||||
assert.ok(abortAt !== -1 && closeAt !== -1 && captureAt !== -1);
|
||||
assert.ok(abortAt < closeAt, "cleanup must abort before closing the session");
|
||||
assert.ok(closeAt < captureAt, "eager close must run before the async capture");
|
||||
// Attached popups never own the backend session, and a connected pane keeps
|
||||
// the existing capture-then-teardown path.
|
||||
assert.match(cleanup, /!attachExistingSession && !hasConnectedRef\.current/);
|
||||
const neverConnectedAt = cleanup.indexOf("if (!hasConnectedRef.current)");
|
||||
assert.ok(abortAt !== -1 && neverConnectedAt !== -1);
|
||||
assert.ok(abortAt < neverConnectedAt, "cleanup must abort before the never-connected close branch");
|
||||
const neverConnectedBranch = cleanup.slice(
|
||||
neverConnectedAt,
|
||||
cleanup.indexOf("const persistCloseCapture", neverConnectedAt),
|
||||
);
|
||||
const closeAt = neverConnectedBranch.indexOf("terminalBackend.closeSession(");
|
||||
const syncDisposeAt = neverConnectedBranch.indexOf("disposeOwnedRuntime();");
|
||||
const earlyReturnAt = neverConnectedBranch.indexOf("return;");
|
||||
assert.ok(closeAt !== -1 && syncDisposeAt !== -1 && earlyReturnAt !== -1);
|
||||
assert.ok(closeAt < syncDisposeAt, "eager close must run before sync runtime dispose");
|
||||
assert.ok(
|
||||
syncDisposeAt < earlyReturnAt,
|
||||
"never-connected boots must sync-dispose before leaving cleanup",
|
||||
);
|
||||
// Owner panes close the pending backend; attach popups only dispose xterm.
|
||||
assert.match(neverConnectedBranch, /if \(!attachExistingSession\)/);
|
||||
assert.match(effectsSource, /let ownedRuntime:/);
|
||||
assert.match(
|
||||
cleanup,
|
||||
/void completeClose\(\)/,
|
||||
"connected boots still use the async capture path",
|
||||
);
|
||||
|
||||
// An aborted boot must stop counting as the current attempt so the existing
|
||||
// orphan-close / attach-refusal guards cover cancellation too.
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
shouldSuppressHostStartupCommandOnReconnect,
|
||||
shouldStartTerminalBackend,
|
||||
} from "./restoredSessionGate.ts";
|
||||
import { setVaultInitialized } from "../../application/state/vaultInitStore.ts";
|
||||
|
||||
test("restored disconnected sessions initialize as connecting", () => {
|
||||
assert.equal(
|
||||
@@ -19,10 +20,27 @@ test("normal sessions initialize as connecting", () => {
|
||||
assert.equal(getInitialTerminalStatus(), "connecting");
|
||||
});
|
||||
|
||||
test("restored disconnected sessions start terminal backend", () => {
|
||||
test("restored disconnected sessions start terminal backend only after vault hydration", () => {
|
||||
setVaultInitialized(false);
|
||||
assert.equal(shouldStartTerminalBackend(), false);
|
||||
setVaultInitialized(true);
|
||||
assert.equal(shouldStartTerminalBackend(), true);
|
||||
});
|
||||
|
||||
test("terminal boot waits for vaultInitialized before creating a backend session", () => {
|
||||
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!attachExistingSession && !vaultInitialized\) \{\s*\n\s*return;/,
|
||||
"boot effect must wait for vault hydration before creating xterm/backend",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/vaultInitialized,/,
|
||||
"vaultInitialized must be in the boot effect dependency list",
|
||||
);
|
||||
});
|
||||
|
||||
test("host startup command policy distinguishes restored and automatic reconnects", () => {
|
||||
assert.equal(shouldSuppressHostStartupCommandOnReconnect("restored"), false);
|
||||
assert.equal(shouldSuppressHostStartupCommandOnReconnect("manual"), false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { isVaultInitialized } from "../../application/state/vaultInitStore";
|
||||
|
||||
export type TerminalReconnectMode = "restored" | "manual" | "automatic";
|
||||
|
||||
@@ -6,7 +7,11 @@ export const getInitialTerminalStatus = (): TerminalSession["status"] => (
|
||||
"connecting"
|
||||
);
|
||||
|
||||
export const shouldStartTerminalBackend = (): boolean => true;
|
||||
/**
|
||||
* Backend start waits for vault hydration so restored sessions cannot dial SSH
|
||||
* with empty keys while hosts have already been published mid-init.
|
||||
*/
|
||||
export const shouldStartTerminalBackend = (): boolean => isVaultInitialized();
|
||||
|
||||
export const shouldSuppressHostStartupCommandOnReconnect = (
|
||||
mode: TerminalReconnectMode,
|
||||
|
||||
@@ -602,6 +602,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const searchAddon = new SearchAddon();
|
||||
term.loadAddon(searchAddon);
|
||||
|
||||
for (const orphan of Array.from(ctx.container.querySelectorAll(":scope > .xterm"))) {
|
||||
orphan.remove();
|
||||
}
|
||||
|
||||
term.open(ctx.container);
|
||||
|
||||
// Inline raster images (Kitty graphics / SIXEL / iTerm IIP). Loaded right after
|
||||
|
||||
@@ -67,7 +67,7 @@ test("terminal close fully drains pending output before finalizing capture", ()
|
||||
new URL("../useTerminalEffects.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cleanupStart = source.indexOf("return () => {", source.indexOf("boot();"));
|
||||
const cleanupStart = source.indexOf("return () => {", source.indexOf("void boot();"));
|
||||
const flushIndex = source.indexOf("await flushPendingTerminalWritesBeforeHibernate(term)", cleanupStart);
|
||||
const incompleteIndex = source.indexOf("if (!flushed)", flushIndex);
|
||||
const finalizeIndex = source.indexOf("resolveConnectionLogCapturePayload(finalizeTerminalLogData)", cleanupStart);
|
||||
@@ -77,3 +77,40 @@ test("terminal close fully drains pending output before finalizing capture", ()
|
||||
assert.ok(incompleteIndex > flushIndex);
|
||||
assert.ok(finalizeIndex > flushIndex);
|
||||
});
|
||||
|
||||
test("never-connected StrictMode cleanup sync-disposes its owned xterm runtime", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../useTerminalEffects.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cleanupStart = source.indexOf("return () => {", source.indexOf("void boot();"));
|
||||
assert.ok(cleanupStart >= 0);
|
||||
const neverConnectedClose = source.indexOf(
|
||||
"if (!hasConnectedRef.current)",
|
||||
cleanupStart,
|
||||
);
|
||||
assert.ok(neverConnectedClose > cleanupStart);
|
||||
const branch = source.slice(
|
||||
neverConnectedClose,
|
||||
source.indexOf("const persistCloseCapture", neverConnectedClose),
|
||||
);
|
||||
assert.match(branch, /if \(!attachExistingSession\)/);
|
||||
assert.match(branch, /disposeOwnedRuntime\(\);/);
|
||||
assert.match(branch, /return;/);
|
||||
assert.ok(
|
||||
branch.indexOf("disposeOwnedRuntime();") < branch.indexOf("return;"),
|
||||
"orphaned StrictMode xterm must be disposed before leaving cleanup",
|
||||
);
|
||||
});
|
||||
|
||||
test("createXTermRuntime clears orphaned .xterm children before open", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./createXTermRuntime.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const openAt = source.indexOf("term.open(ctx.container);");
|
||||
assert.ok(openAt > 0);
|
||||
const beforeOpen = source.slice(Math.max(0, openAt - 350), openAt);
|
||||
assert.match(beforeOpen, /querySelectorAll\(":scope > \.xterm"\)/);
|
||||
assert.match(beforeOpen, /orphan\.remove\(\)/);
|
||||
});
|
||||
@@ -843,7 +843,7 @@ export const attachSessionToTerminal = (
|
||||
},
|
||||
) => {
|
||||
if (!isTerminalBootActive(ctx)) {
|
||||
closeOrphanBackendSession(ctx, id);
|
||||
closeOrphanBackendSession(ctx, id, { bootEpoch: ctx.bootEpochRef?.current });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
}
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, 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, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState } = ctx;
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, 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, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
|
||||
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
|
||||
terminalSettings,
|
||||
@@ -401,8 +401,16 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Restored sessions can mount while vault init has published hosts but not
|
||||
// yet decrypted keys. Wait for hydration (and re-run when it flips) so the
|
||||
// first SSH dial does not go out with an empty keychain.
|
||||
if (!attachExistingSession && !vaultInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let initialFitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let ownedRuntime: { dispose: () => void } | null = null;
|
||||
// Every boot owns a controller so cleanup can cancel an in-flight start
|
||||
// instead of waiting for the async capture/teardown below to finish.
|
||||
const bootAbort = new AbortController();
|
||||
@@ -425,6 +433,25 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
promptLineBreakStateRef.current = createPromptLineBreakState();
|
||||
|
||||
const disposeOwnedRuntime = () => {
|
||||
const runtime = ownedRuntime;
|
||||
ownedRuntime = null;
|
||||
if (!runtime) return;
|
||||
if (xtermRuntimeRef.current === runtime) {
|
||||
xtermRuntimeRef.current = null;
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
serializeAddonRef.current = null;
|
||||
searchAddonRef.current = null;
|
||||
hasRuntimeRef.current = false;
|
||||
}
|
||||
try {
|
||||
runtime.dispose();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to dispose terminal runtime on unmount", err);
|
||||
}
|
||||
};
|
||||
|
||||
const boot = async () => {
|
||||
try {
|
||||
if (disposed || !containerRef.current) return;
|
||||
@@ -514,6 +541,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
ownedRuntime = runtime;
|
||||
xtermRuntimeRef.current = runtime;
|
||||
termRef.current = runtime.term;
|
||||
fitAddonRef.current = runtime.fitAddon;
|
||||
@@ -774,7 +802,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}
|
||||
};
|
||||
|
||||
boot();
|
||||
queueMicrotask(() => {
|
||||
if (disposed) return;
|
||||
void boot();
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
@@ -785,6 +816,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
isBootActiveRef.current = false;
|
||||
if (hibernatedRef?.current) {
|
||||
forceCloseHibernatedSession?.();
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -793,19 +825,23 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
// closes anything. Tell the main process now so it can abort the pending
|
||||
// boot (passphrase prompt, TCP dial) for this epoch instead of letting it
|
||||
// finish against a pane that is already gone. Attached popups never own
|
||||
// the backend session, so they keep the display-route handoff path.
|
||||
if (!attachExistingSession && !hasConnectedRef.current) {
|
||||
try {
|
||||
const closeResult = terminalBackend.closeSession(
|
||||
sessionRef.current ?? sessionId,
|
||||
{ bootEpoch: bootEpochRef ? bootEpochRef.current : undefined },
|
||||
);
|
||||
void Promise.resolve(closeResult).catch((err: unknown) => {
|
||||
// the backend session (skip closeSession) but still sync-dispose xterm.
|
||||
if (!hasConnectedRef.current) {
|
||||
if (!attachExistingSession) {
|
||||
try {
|
||||
const closeResult = terminalBackend.closeSession(
|
||||
sessionRef.current ?? sessionId,
|
||||
{ bootEpoch: bootEpochRef ? bootEpochRef.current : undefined },
|
||||
);
|
||||
void Promise.resolve(closeResult).catch((err: unknown) => {
|
||||
logger.warn("Failed to cancel pending terminal boot on unmount", err);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending terminal boot on unmount", err);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending terminal boot on unmount", err);
|
||||
}
|
||||
}
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
const persistCloseCapture = (data: string, source: string, dataLength: number) => {
|
||||
@@ -828,15 +864,27 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
)) {
|
||||
// Remount already replaced the live refs; still drop this boot's
|
||||
// orphaned DOM node if dispose was deferred past the generation bump.
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
if (!flushed) {
|
||||
logger.warn("Terminal output did not drain before close capture; skipping stale capture");
|
||||
teardown();
|
||||
ownedRuntime = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTerminalCloseGenerationCurrent(
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
)) {
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionLogPayload = !terminalDataCapturedRef.current
|
||||
? resolveConnectionLogCapturePayload(finalizeTerminalLogData)
|
||||
: null;
|
||||
@@ -846,6 +894,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
connectionLogPayload.source,
|
||||
connectionLogPayload.data.length,
|
||||
);
|
||||
ownedRuntime = null;
|
||||
scheduleTerminalCloseTeardown(teardown);
|
||||
return;
|
||||
}
|
||||
@@ -859,32 +908,44 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
)) {
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
if (payload) {
|
||||
persistCloseCapture(payload.data, payload.source, payload.data.length);
|
||||
}
|
||||
ownedRuntime = null;
|
||||
scheduleTerminalCloseTeardown(teardown);
|
||||
} catch (err) {
|
||||
if (!isTerminalCloseGenerationCurrent(
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
)) {
|
||||
disposeOwnedRuntime();
|
||||
return;
|
||||
}
|
||||
logger.warn("Failed to serialize terminal data on unmount:", err);
|
||||
ownedRuntime = null;
|
||||
scheduleTerminalCloseTeardown(teardown);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ownedRuntime = null;
|
||||
teardown();
|
||||
};
|
||||
|
||||
void completeClose();
|
||||
};
|
||||
|
||||
}, [forceCloseHibernatedSession, handleTerminalDataCaptureOnce, host.id, sessionId]);
|
||||
}, [
|
||||
attachExistingSession,
|
||||
forceCloseHibernatedSession,
|
||||
handleTerminalDataCaptureOnce,
|
||||
host.id,
|
||||
sessionId,
|
||||
vaultInitialized,
|
||||
]);
|
||||
|
||||
|
||||
// Connection timeline and timeout visuals
|
||||
|
||||
@@ -360,6 +360,18 @@ function aiChatPanelsHostAreEqual(
|
||||
return true;
|
||||
}
|
||||
|
||||
const consumedTerminalSelectionRequestIds = new Set<string>();
|
||||
const CONSUMED_TERMINAL_SELECTION_REQUEST_ID_LIMIT = 64;
|
||||
|
||||
function markTerminalSelectionRequestConsumed(requestId: string): void {
|
||||
consumedTerminalSelectionRequestIds.add(requestId);
|
||||
if (consumedTerminalSelectionRequestIds.size <= CONSUMED_TERMINAL_SELECTION_REQUEST_ID_LIMIT) {
|
||||
return;
|
||||
}
|
||||
const oldest = consumedTerminalSelectionRequestIds.values().next().value;
|
||||
if (oldest !== undefined) consumedTerminalSelectionRequestIds.delete(oldest);
|
||||
}
|
||||
|
||||
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
mountedTabIds,
|
||||
activeTabId,
|
||||
@@ -395,15 +407,17 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTerminalSelection) return;
|
||||
if (consumedTerminalSelectionRequestIds.has(pendingTerminalSelection.requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = contextsByTabId.get(pendingTerminalSelection.tabId);
|
||||
if (!context) return;
|
||||
|
||||
const attachment = createTerminalSelectionAttachment(pendingTerminalSelection.text);
|
||||
if (!attachment) {
|
||||
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
|
||||
return;
|
||||
}
|
||||
markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId);
|
||||
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
|
||||
if (!attachment) return;
|
||||
|
||||
const scopeKey = `${context.scopeType}:${context.scopeTargetId ?? ''}`;
|
||||
const isSessionView =
|
||||
@@ -416,7 +430,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
...draft,
|
||||
attachments: [...draft.attachments, attachment],
|
||||
}));
|
||||
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
|
||||
}, [
|
||||
activeSessionIdMap,
|
||||
contextsByTabId,
|
||||
|
||||
@@ -406,12 +406,14 @@ function SidePanelNotesSlotInner({
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const openNoteRequest = (ctx.notesOpenNoteByTab as Map<string, { noteId: string; requestId: number }>).get(tabId) ?? null;
|
||||
// Gate subscription while Notes is hidden so vault edits (and the full-page
|
||||
// notebook) do not re-render this retained side-panel mount.
|
||||
const {
|
||||
notes,
|
||||
noteGroups,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
} = useNotesStore();
|
||||
} = useNotesStore({ enabled: isVisible });
|
||||
|
||||
const {
|
||||
NotesManager,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
|
||||
import { usePluginImporterCommit } from "../../application/state/usePluginImporterCommit";
|
||||
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
|
||||
@@ -36,6 +36,10 @@ const VaultSectionLoading = () => (
|
||||
/**
|
||||
* Notes section subscribes to notesStore instead of taking notes as VaultView
|
||||
* props, so note edits never invalidate the App vault domain bag.
|
||||
*
|
||||
* While inactive (retained but hidden), freeze hosts and drop the store
|
||||
* subscription so Hosts/Keys churn and note publishes do not reconcile the
|
||||
* heavy NotesManager + MDXEditor tree.
|
||||
*/
|
||||
function VaultNotesSection({
|
||||
NotesManager,
|
||||
@@ -52,7 +56,9 @@ function VaultNotesSection({
|
||||
onOpenNoteIdHandled: () => void;
|
||||
onOpenHost: (host: any, source?: { noteId?: string }) => void;
|
||||
}) {
|
||||
const { notes, noteGroups, updateNotes, updateNoteGroups } = useNotesStore();
|
||||
const { notes, noteGroups, updateNotes, updateNoteGroups } = useNotesStore({
|
||||
enabled: isActive,
|
||||
});
|
||||
return (
|
||||
<NotesManager
|
||||
notes={notes}
|
||||
@@ -68,6 +74,23 @@ function VaultNotesSection({
|
||||
);
|
||||
}
|
||||
|
||||
const MemoVaultNotesSection = React.memo(
|
||||
VaultNotesSection,
|
||||
(prev, next) => {
|
||||
if (
|
||||
prev.isActive !== next.isActive
|
||||
|| prev.openNoteId !== next.openNoteId
|
||||
|| prev.NotesManager !== next.NotesManager
|
||||
|| prev.onOpenNoteIdHandled !== next.onOpenNoteIdHandled
|
||||
|| prev.onOpenHost !== next.onOpenHost
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (next.isActive && prev.hosts !== next.hosts) return false;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Logs section subscribes to connectionLogsStore so every session start/exit
|
||||
* append stays out of the App vault/chrome domain bags.
|
||||
@@ -366,6 +389,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
source.groupName === path || source.groupName.startsWith(path + "/"),
|
||||
),
|
||||
);
|
||||
const handleNotesOpenHost = useCallback((host: any, source?: { noteId?: string }) => {
|
||||
if (source?.noteId && onOpenHostFromNote) {
|
||||
onOpenHostFromNote(host, source);
|
||||
return;
|
||||
}
|
||||
handleHostConnect(host);
|
||||
}, [handleHostConnect, onOpenHostFromNote]);
|
||||
const visibleTreeGroupPaths = React.useMemo(
|
||||
() => collectVisibleVaultGroupPaths(treeViewGroupTree),
|
||||
[treeViewGroupTree],
|
||||
@@ -1241,19 +1271,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
)}
|
||||
data-section="vault-notes-retained"
|
||||
>
|
||||
<VaultNotesSection
|
||||
<MemoVaultNotesSection
|
||||
NotesManager={NotesManager}
|
||||
hosts={hosts}
|
||||
isActive={currentSection === "notes"}
|
||||
openNoteId={openNoteId ?? null}
|
||||
onOpenNoteIdHandled={onOpenNoteIdHandled}
|
||||
onOpenHost={(host: any, source: any) => {
|
||||
if (source?.noteId && onOpenHostFromNote) {
|
||||
onOpenHostFromNote(host, source);
|
||||
return;
|
||||
}
|
||||
handleHostConnect(host);
|
||||
}}
|
||||
onOpenHost={handleNotesOpenHost}
|
||||
/>
|
||||
</div>
|
||||
{currentSection === "keys" && (
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# StrictMode + 消灭巨型订阅者
|
||||
|
||||
## 已拍板的目标
|
||||
|
||||
- 整棵 React 树开 `StrictMode`(主窗 + settings/tray/terminal-popup 所有 `root.render`)。
|
||||
- Terminal boot:cleanup **立即 abort/close**,Cancel 对齐 Disconnect 的 epoch 失效;非 plugin 协议也吃 `AbortSignal`。
|
||||
- 你列的 8 条重渲命门 **全部修完**,且验收标准是:**没有任何高频状态再让 App 根组件 / AppView 五域整壳无谓重渲**。
|
||||
- 做完后循环 Bugbot → fix → 再 review,直到 clean;再跑遗漏清单,有漏继续清。
|
||||
|
||||
## 核心架构(延续 #2790,上推到根)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph publishers [PublisherIslands_no_children_props]
|
||||
SettingsPub[SettingsPublisher]
|
||||
VaultPub[VaultPublisher]
|
||||
SessionPub[SessionPublisher]
|
||||
DialogsPub[DialogsPublisher]
|
||||
end
|
||||
|
||||
subgraph stores [ExternalStores_useSyncExternalStore]
|
||||
Notes[notesStore]
|
||||
Accent[appearanceChromeStore]
|
||||
ConnLogs[connectionLogsStore]
|
||||
SettingsChrome[settingsChromeStore]
|
||||
VaultSnap[vaultSnapshotStore]
|
||||
SessionSnap[sessionSnapshotStore]
|
||||
AISessions[aiSessionsStore]
|
||||
AIConfig[AIConfigContext_slow]
|
||||
end
|
||||
|
||||
subgraph shell [AppShell_memo_no_root_hooks]
|
||||
ChromeHost[ChromeHost]
|
||||
VaultHost[VaultHost]
|
||||
TerminalHost[TerminalHost]
|
||||
DialogsHost[DialogsHost]
|
||||
end
|
||||
|
||||
SettingsPub --> Accent
|
||||
SettingsPub --> SettingsChrome
|
||||
VaultPub --> Notes
|
||||
VaultPub --> ConnLogs
|
||||
VaultPub --> VaultSnap
|
||||
SessionPub --> SessionSnap
|
||||
SessionPub --> AISessions
|
||||
|
||||
ChromeHost --> Accent
|
||||
ChromeHost --> SettingsChrome
|
||||
VaultHost --> VaultSnap
|
||||
VaultHost --> Notes
|
||||
VaultHost --> ConnLogs
|
||||
TerminalHost --> SessionSnap
|
||||
TerminalHost --> AISessions
|
||||
TerminalHost --> AIConfig
|
||||
```
|
||||
|
||||
**关键不变量**
|
||||
|
||||
- Publisher 组件只跑现有 hooks(`useVaultState` / `useSessionState` / `useSettingsState` 等),通过 `useLayoutEffect`/`publish*` 写入 store,**不向子树传业务 props**。
|
||||
- 壳与岛屿只 `useSyncExternalStore`(或慢变 Context)按需订阅;**禁止**再在同一组件里 `useVaultState` + 渲染整棵 `AppView`。
|
||||
- Domain bag([`appViewDomains.ts`](application/app/appViewDomains.ts))要么删掉,要么缩成「岛屿内部的局部 props 包」——**不得**再由 App 根因 notes/accent/logs/settings/sessions 而重建。
|
||||
|
||||
复用现有模式:[`notesStore.ts`](application/state/notesStore.ts)、[`appearanceChromeStore.ts`](application/state/appearanceChromeStore.ts)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — 分支与设计落盘
|
||||
|
||||
- 从最新 `main` 开分支:`perf/strictmode-mega-subscriber-elimination`。
|
||||
- 写 spec:`docs/superpowers/specs/2026-08-07-strictmode-mega-subscriber-design.md`(验收清单 = 下文 Definition of Done)。
|
||||
- 写 plan 副本到 `docs/superpowers/plans/`(便于多 agent 对齐)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — StrictMode 硬化(可与 Phase 2 并行)
|
||||
|
||||
| 位点 | 改法 |
|
||||
|------|------|
|
||||
| [`index.tsx`](index.tsx) | 所有 `root.render` 外包 `<React.StrictMode>`(主窗 / settings / tray / terminal-popup) |
|
||||
| [`Terminal.tsx`](components/Terminal.tsx) `handleCancelConnect` | 对齐 `handleDisconnect`:`invalidateBootEpochForClose()` + `isBootActiveRef.current = false`,再 `cleanupSession` |
|
||||
| [`useTerminalEffects.ts`](components/terminal/useTerminalEffects.ts) | cleanup:**先** `AbortController.abort()` + `closeSession({ bootEpoch })`,再异步 capture/teardown;`boot()` 全协议传 `signal`(SSH/local/mosh/telnet/et/serial,plugin 已有) |
|
||||
| starters / bridge | 扩展 `AbortSignal` 到 dial 全程;与现有 `registerPendingBootAbort` / `abortPendingBoot` 对齐 |
|
||||
| [`App.tsx`](App.tsx) `pendingNewWindowSession` | payload identity latch:先 clear / consume,再 `createSessionFromCloneSource` |
|
||||
| External MCP startup | module single-flight `syncExternalMcpStartupStateOnce`;cleanup 不双飞 IPC |
|
||||
| [`useUpdateCheck`](application/app/) / [`useAppStartupEffects`](application/app/useAppStartupEffects.ts) | cleanup 重置错误 latch;update toast 按 version latch |
|
||||
| splash `rendererReady` | module once |
|
||||
| PF auto-start | 保留 ref latch,补 StrictMode 双 effect 测试;失败可清 latch 重试 |
|
||||
|
||||
**测试**:Cancel+epoch 源码/行为断言;boot cleanup 在 capture 前 close;MCP single-flight;pendingNewWindow 不双建;update toast 不双弹。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — 高频状态迁出 domain(清 2/3/4/5)
|
||||
|
||||
并行可拆 3 个 agent:
|
||||
|
||||
1. **notes**:从 [`appVaultDomain`](App.tsx) / VaultView props 移除;Vault/NotesManager 只 `useNotesStore()`。更新 [`VaultView.memo.test.tsx`](components/VaultView.memo.test.tsx):notes churn 不再打 VaultView/AppView。
|
||||
2. **accent**:从 `appChromeDomain` / `AppHostTreeLayer` props 移除;[`AppView.tsx`](application/app/AppView.tsx) 的 `appThemeStyle` 改为叶子/局部订阅 `appearanceChromeStore`(Vault 容器、HostTree chrome 各自订)。accent drag → **零 AppView 五域重建**。
|
||||
3. **connectionLogs**:新建 `connectionLogsStore`(同 notesStore 形状);`useVaultState` publish;logs section 才订阅;从 vault domain deps 删除。日志追加 → **不打 AppView**。
|
||||
4. **settings 整袋**:新建 `settingsChromeStore`(或扩展 appearance store),只发布 TopTabs/HostTree/chrome 真正需要的字段;`appChromeDomain` **禁止**再塞整袋 `settings`。任意无关 settings 字段变化不重建 chrome 壳。
|
||||
|
||||
同步改:[`AppFollowTerminalTheme.test.ts`](application/app/AppFollowTerminalTheme.test.ts)、[`AppMounts.test.ts`](application/app/AppMounts.test.ts)、[`appViewDomains.test.ts`](application/app/appViewDomains.test.ts)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — 物理拆掉 App 巨型订阅者(清 1,彻底)
|
||||
|
||||
目标:[`App.tsx`](App.tsx) / 当前内层 `App` **不再**同时持有 vault+session+settings 的 React state 并渲染壳。
|
||||
|
||||
落点(新文件,建议):
|
||||
|
||||
- `application/app/publishers/SettingsPublisher.tsx`
|
||||
- `application/app/publishers/VaultPublisher.tsx`
|
||||
- `application/app/publishers/SessionPublisher.tsx`
|
||||
- `application/app/publishers/DialogsPublisher.tsx`(或并入壳内低频岛)
|
||||
- `application/state/vaultSnapshotStore.ts`(hosts/keys/snippets/… 慢变快变按字段 notify;至少支持 selector)
|
||||
- `application/state/sessionSnapshotStore.ts`(sessions/workspaces;presentation 已有 store 的继续复用)
|
||||
- `application/app/AppShell.tsx` — memo,无巨型 hook
|
||||
- `ChromeHost` / `VaultHost` / `TerminalHost` / `DialogsHost` — 各自订阅
|
||||
|
||||
迁移策略:先 Publisher 旁路 publish + 岛屿读 store,再删 App 根上的 domain `useMemo` 与 props 漏斗;保持 IPC/handler 注册不丢。
|
||||
|
||||
验收:**在 Profiler 或契约测试中**,改 notes / accent / connectionLogs / 无关 settings / 单 session 流式更新时,`AppShell` 与无关 Host **不** re-render。
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Context 拆分(清 6/7/8)
|
||||
|
||||
1. **AI**:`aiSessionsStore`(`useSyncExternalStore` + per-scope selector)+ 慢变 `AIConfigContext`(providers/permissions)。[`TerminalLayerSupport.tsx`](components/terminalLayer/TerminalLayerSupport.tsx) 的 `AIStateProvider` 不再整包 `sessions` 塞 Context。`AIChatPanelsHost` 只订本 scope。
|
||||
2. **SFTP**:拆 `SftpHostsContext` 与 `SftpPaneCallbacksContext`(left/right 可再拆);保留已有 `SftpDragContext` / `activeTabStore`。
|
||||
3. **Toast**:`ToastActionsContext`(稳定 `showToast`/`dismissToast`,`useMemo`)与 `ToastStateContext`(仅 Toaster 订 `toasts`)。全局 `toast.*` 不变。
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — 开 StrictMode 压测与契约测试门禁
|
||||
|
||||
- Phase 1 修完后正式合入 StrictMode(若分 PR,最后一 PR 打开;本任务按用户要求一次做完则同分支打开)。
|
||||
- 新增/加强:
|
||||
- domain/store memo 契约测试(notes/accent/logs/settings/AI scope)
|
||||
- architecture 测试:AppShell/App 源码禁止 `useVaultState`+`useSessionState`+`useSettingsState` 同组件渲染
|
||||
- StrictMode 幂等:pendingNewWindow、MCP startup、update check/toast、Cancel boot
|
||||
- 跑:`npm test` 相关子集 + 关键文件全量;`npm run lint`。
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Bugbot 死循环到 clean
|
||||
|
||||
按 [`review-bugbot`](~/.cursor/skills-cursor/review-bugbot/SKILL.md):
|
||||
|
||||
1. `Diff: branch changes` 跑 Bugbot
|
||||
2. 修全部有效 finding
|
||||
3. 再跑,直到 **无有效问题**
|
||||
4. 若 diff 算不出,改 `natural language` 重试一次
|
||||
|
||||
穿插:security-review 仅在用户另要时;本任务默认 Bugbot 循环。
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — 遗漏审计(你要求的「不可以有遗漏」)
|
||||
|
||||
对照清单逐项打勾,漏则继续改:
|
||||
|
||||
- [ ] App 根无巨型三 hook 同渲染
|
||||
- [ ] notes / accent / connectionLogs / 整袋 settings 不进跨壳 domain
|
||||
- [ ] AI 流式不导致 sibling panel / 无关 Host 重渲
|
||||
- [ ] Sftp hosts 变不拖 callbacks 消费者(反之亦然)
|
||||
- [ ] Toast 更新不重渲整 App 树订阅者
|
||||
- [ ] StrictMode 已开;Cancel/boot abort/双挂载幂等测过
|
||||
- [ ] 旧 memo 测试与 #2790 契约仍绿,并补「父壳也不炸」断言
|
||||
- [ ] 无「父已炸、子假装没事」残留(以壳是否订阅为准,不以子 areEqual 为准)
|
||||
|
||||
---
|
||||
|
||||
## 多 agent 并行切分
|
||||
|
||||
| Agent | 范围 |
|
||||
|-------|------|
|
||||
| A | StrictMode + Terminal abort/Cancel + App 幂等 effects |
|
||||
| B | notes + connectionLogs store 迁出 + Vault 订阅改造 |
|
||||
| C | accent + settingsChromeStore + AppView/HostTree 叶子订 |
|
||||
| D | App Publisher/Shell 物理拆分 + vault/session snapshot stores |
|
||||
| E | AI sessions store + Sftp Context 拆 + Toast 拆 |
|
||||
| 主控 | 合入冲突、契约测试、遗漏审计、Bugbot 循环 |
|
||||
|
||||
---
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- Electron 长连接:StrictMode 双挂载依赖 Phase 1;未完成前不要只开 StrictMode。
|
||||
- 不引入新状态库依赖;沿用自研 `useSyncExternalStore` store。
|
||||
- 不借机大改产品行为;handler/IPC 语义保持。
|
||||
- ASCII 源码;中文仅注释/文案已有处。
|
||||
- 提交仅在你明确要求时再做。
|
||||
@@ -0,0 +1,80 @@
|
||||
# Design: StrictMode + eliminate App mega-subscribers
|
||||
|
||||
Date: 2026-08-07
|
||||
Status: Implemented on `cursor/cloud-agent-1786101120448-jga1a` (stacked on `perf/strictmode-mega-subscriber-elimination`)
|
||||
|
||||
## Summary
|
||||
|
||||
Open React `StrictMode` on every renderer root and remove the App-level
|
||||
“mega subscriber” that co-hosted vault + session + settings state while
|
||||
rendering the whole shell. High-churn fields (notes, accent, connection logs,
|
||||
AI sessions, toast, SFTP hosts) must update only the islands that read them.
|
||||
|
||||
## Goals
|
||||
|
||||
1. All `root.render` call sites wrap `<StrictMode>` (main, settings, tray,
|
||||
terminal-popup).
|
||||
2. Terminal boot/Cancel are StrictMode-safe: abort + epoch-scoped close before
|
||||
async capture; no first-connect race from double mount.
|
||||
3. `App.tsx` / `AppShell` never call `useVaultState` + `useSessionState` +
|
||||
`useSettingsState` in the same component that renders the shell.
|
||||
4. Dragging custom accent, appending connection logs, editing notes, or
|
||||
streaming AI tokens must not rebuild unrelated Host / AppView domains.
|
||||
|
||||
## Architecture
|
||||
|
||||
Publisher islands own the mega hooks and publish into external stores via
|
||||
`useSyncExternalStore`. Host islands subscribe to the slices they need and
|
||||
assemble domain bags field-by-field into `appShellPropsStore`. `AppShell` is
|
||||
memoized and only re-renders when published bags change by value equality.
|
||||
|
||||
| Publisher | Store(s) | Host |
|
||||
|-----------|----------|------|
|
||||
| SettingsPublisher | appearanceChromeStore, settingsChromeStore, terminalSettingsStore | ChromeHost (+ theme leaves) |
|
||||
| VaultPublisher | vaultSnapshotStore, notesStore, connectionLogsStore, shellHistoryStore | VaultHost |
|
||||
| SessionPublisher | sessionSnapshotStore, aiSessionsStore | TerminalHost |
|
||||
| (dialogs local UI) | appLocalUiStore | DialogsHost |
|
||||
|
||||
## StrictMode invariants
|
||||
|
||||
- Terminal boot defers past the synchronous remount (`queueMicrotask`) and
|
||||
scopes worker close by `bootEpoch`.
|
||||
- Never-connected cleanup sync-disposes the owned xterm runtime.
|
||||
- Vault async init cancels the superseded effect and only the survivor may call
|
||||
`setVaultInitialized(true)`.
|
||||
- Backend start waits for vault hydration so restored panes do not dial with an
|
||||
empty keychain.
|
||||
- One-shot preload subscriptions (terminal popup config) retain `lastPayload`
|
||||
across unsubscribe/resubscribe.
|
||||
- Startup latches (clone-session, PF auto-start, update toast, rendererReady,
|
||||
MCP sync) are idempotent under double invoke.
|
||||
|
||||
## Accent isolation
|
||||
|
||||
Published `currentTerminalTheme` is the **base** catalog theme. Live custom
|
||||
accent is applied at leaves (`appearanceChromeStore`) and in CSS-var injection,
|
||||
never baked into the terminal/chrome domain bag on every HSL tick.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [x] App root has no three mega hooks co-rendering the shell
|
||||
- [x] notes / accent / connectionLogs / whole settings bag stay out of
|
||||
cross-shell domain deps
|
||||
- [x] AI streaming does not re-render sibling panels / unrelated Hosts
|
||||
- [x] SFTP hosts context is split from pane callbacks
|
||||
- [x] Toast state updates do not re-render action-only subscribers
|
||||
- [x] StrictMode enabled; Cancel/boot/double-mount contracts green
|
||||
- [x] Child memo tests plus parent-shell / Host bag assertions
|
||||
- [x] No “parent exploded, child pretends OK” (shell subscription is the bar)
|
||||
- [x] Bugbot loop clean on the delivery PR
|
||||
|
||||
## Non-goals
|
||||
|
||||
- New third-party state libraries
|
||||
- Product behavior changes beyond StrictMode-correctness and re-render isolation
|
||||
- Public API changes to Electron CLI / MCP surfaces
|
||||
|
||||
## Plan pointer
|
||||
|
||||
Implementation steps live in
|
||||
`docs/superpowers/plans/2026-08-07-strictmode-mega-subscriber.md`.
|
||||
@@ -400,6 +400,10 @@ function createTerminalWorkerManager(options = {}) {
|
||||
const outputRoutePending = new Map();
|
||||
const pendingSessionStartSequences = new Map();
|
||||
const latestSessionStartSequences = new Map();
|
||||
// bootEpoch for the latest pending / opened same-id start. Stale StrictMode
|
||||
// orphan closes carry an older epoch and must not cancel a newer remount.
|
||||
const pendingSessionStartBootEpochs = new Map();
|
||||
const sessionBootEpochs = new Map();
|
||||
const suppressedPendingOutputSessions = new Set();
|
||||
const workerSessionIds = new Set();
|
||||
const sessionWebContentsIds = new Map();
|
||||
@@ -900,6 +904,50 @@ function createTerminalWorkerManager(options = {}) {
|
||||
closedSessionGenerations.delete(sessionId);
|
||||
}
|
||||
|
||||
function normalizeBootEpoch(bootEpoch) {
|
||||
if (!Number.isFinite(bootEpoch)) return undefined;
|
||||
return Number(bootEpoch);
|
||||
}
|
||||
|
||||
function rememberPendingStartBootEpoch(sessionId, bootEpoch) {
|
||||
const normalized = normalizeBootEpoch(bootEpoch);
|
||||
if (!sessionId || normalized === undefined) return;
|
||||
pendingSessionStartBootEpochs.set(sessionId, normalized);
|
||||
}
|
||||
|
||||
function promotePendingStartBootEpoch(sessionId, bootEpoch) {
|
||||
const normalized = normalizeBootEpoch(bootEpoch) ?? pendingSessionStartBootEpochs.get(sessionId);
|
||||
if (!sessionId || normalized === undefined) return;
|
||||
sessionBootEpochs.set(sessionId, normalized);
|
||||
}
|
||||
|
||||
function clearSessionBootEpochTracking(sessionId) {
|
||||
if (!sessionId) return;
|
||||
pendingSessionStartBootEpochs.delete(sessionId);
|
||||
sessionBootEpochs.delete(sessionId);
|
||||
}
|
||||
|
||||
function resolveOwnerBootEpoch(sessionId) {
|
||||
if (!sessionId) return undefined;
|
||||
if (pendingSessionStartBootEpochs.has(sessionId)) {
|
||||
return pendingSessionStartBootEpochs.get(sessionId);
|
||||
}
|
||||
return sessionBootEpochs.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch-scoped closes dispose one specific boot. When a newer remount already
|
||||
* owns the same sessionId, the stale close must be a no-op for lifecycle
|
||||
* bookkeeping — otherwise StrictMode abort → orphan close kills the remount
|
||||
* with "closed before its output route opened".
|
||||
*/
|
||||
function shouldSkipStaleEpochClose(sessionId, bootEpoch) {
|
||||
const closeEpoch = normalizeBootEpoch(bootEpoch);
|
||||
if (closeEpoch === undefined || !sessionId) return false;
|
||||
const ownerEpoch = resolveOwnerBootEpoch(sessionId);
|
||||
return ownerEpoch !== undefined && ownerEpoch > closeEpoch;
|
||||
}
|
||||
|
||||
function canPruneClosedSessionTombstone(sessionId) {
|
||||
return !pendingSessionStartSequences.has(sessionId)
|
||||
&& !sessionGenerations.has(sessionId)
|
||||
@@ -951,6 +999,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
if (entry?.requestedSessionId
|
||||
&& pendingSessionStartSequences.get(entry.requestedSessionId) === entry.requestSequence) {
|
||||
pendingSessionStartSequences.delete(entry.requestedSessionId);
|
||||
pendingSessionStartBootEpochs.delete(entry.requestedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,6 +1079,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
if (!sessionId) return;
|
||||
markSessionClosed(sessionId);
|
||||
latestSessionStartSequences.delete(sessionId);
|
||||
clearSessionBootEpochTracking(sessionId);
|
||||
suppressedPendingOutputSessions.delete(sessionId);
|
||||
workerSessionIds.delete(sessionId);
|
||||
clearBufferedOutput(sessionId);
|
||||
@@ -1297,6 +1347,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
promotePendingStartBootEpoch(sessionId, entry.bootEpoch);
|
||||
entry.resolve(message.result);
|
||||
}, (error) => {
|
||||
if (pending.get(message.requestId) !== entry) return;
|
||||
@@ -1715,6 +1766,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
reject,
|
||||
requestSequence,
|
||||
requestedSessionId,
|
||||
bootEpoch: normalizeBootEpoch(payload?.bootEpoch),
|
||||
webContentsId: optionsForRequest.webContentsId,
|
||||
opensOutputSession: channel === "netcatty:start"
|
||||
|| channel === "netcatty:local:reconnect"
|
||||
@@ -1724,6 +1776,10 @@ function createTerminalWorkerManager(options = {}) {
|
||||
});
|
||||
let notifyClosedAfterPost = false;
|
||||
if (channel === "netcatty:close:await" && payload?.sessionId) {
|
||||
if (shouldSkipStaleEpochClose(payload.sessionId, payload?.bootEpoch)) {
|
||||
pending.delete(requestId);
|
||||
return Promise.resolve({ skipped: true, reason: "boot-epoch-mismatch" });
|
||||
}
|
||||
const closesPendingLifecycle = hasPendingSessionLifecycle(payload.sessionId);
|
||||
notifyClosedAfterPost = !closedSessions.has(payload.sessionId) || closesPendingLifecycle;
|
||||
cancelPendingSessionStart(payload.sessionId);
|
||||
@@ -1736,6 +1792,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
}
|
||||
pendingSessionStartSequences.set(requestedSessionId, requestSequence);
|
||||
latestSessionStartSequences.set(requestedSessionId, requestSequence);
|
||||
rememberPendingStartBootEpoch(requestedSessionId, payload?.bootEpoch);
|
||||
// Track host id for SFTP transfer session leases (global transfer center).
|
||||
if (typeof payload?.hostId === "string" && payload.hostId) {
|
||||
sessionHostIds.set(requestedSessionId, payload.hostId);
|
||||
@@ -1833,6 +1890,11 @@ function createTerminalWorkerManager(options = {}) {
|
||||
|
||||
function send(channel, payload, optionsForSend = {}) {
|
||||
pruneClosedSessionTombstones();
|
||||
if (channel === "netcatty:close"
|
||||
&& payload?.sessionId
|
||||
&& shouldSkipStaleEpochClose(payload.sessionId, payload?.bootEpoch)) {
|
||||
return;
|
||||
}
|
||||
if (channel === "netcatty:close"
|
||||
&& payload?.sessionId
|
||||
&& closedSessions.has(payload.sessionId)
|
||||
@@ -1956,6 +2018,8 @@ function createTerminalWorkerManager(options = {}) {
|
||||
outputRoutePending.clear();
|
||||
pendingSessionStartSequences.clear();
|
||||
latestSessionStartSequences.clear();
|
||||
pendingSessionStartBootEpochs.clear();
|
||||
sessionBootEpochs.clear();
|
||||
suppressedPendingOutputSessions.clear();
|
||||
workerSessionIds.clear();
|
||||
sessionWebContentsIds.clear();
|
||||
|
||||
@@ -3170,6 +3170,117 @@ test("an old start response cannot reopen a session claimed by a later same-id s
|
||||
assert.deepEqual(routed, [{ sessionId: "session-1", data: "new-banner" }]);
|
||||
});
|
||||
|
||||
|
||||
test("start-close-start StrictMode order keeps the remount start alive", async () => {
|
||||
const child = new FakeChild();
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId(id) {
|
||||
return { id, send() {} };
|
||||
},
|
||||
},
|
||||
},
|
||||
workerScriptPath: "/worker.cjs",
|
||||
});
|
||||
|
||||
// Mount1 start
|
||||
const first = manager.request(
|
||||
"netcatty:local:start",
|
||||
{ sessionId: "session-1", bootEpoch: 1 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
const firstRequest = child.messages.at(-1);
|
||||
|
||||
// Mount1 cleanup close (before remount start)
|
||||
manager.send("netcatty:close", { sessionId: "session-1", bootEpoch: 1 }, { webContentsId: 7 });
|
||||
|
||||
// Mount2 remount start
|
||||
const second = manager.request(
|
||||
"netcatty:local:start",
|
||||
{ sessionId: "session-1", bootEpoch: 2 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
const secondRequest = child.messages.at(-1);
|
||||
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: firstRequest.requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
sessionGeneration: 0,
|
||||
});
|
||||
await assert.rejects(first, /superseded by a newer start request|closed before its output route opened/u);
|
||||
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: secondRequest.requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
sessionGeneration: 1,
|
||||
});
|
||||
await second;
|
||||
assert.equal(manager.hasOpenSession("session-1"), true);
|
||||
});
|
||||
|
||||
test("stale bootEpoch close must not reject a newer same-id start (StrictMode remount)", async () => {
|
||||
const child = new FakeChild();
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId(id) {
|
||||
return { id, send() {} };
|
||||
},
|
||||
},
|
||||
},
|
||||
workerScriptPath: "/worker.cjs",
|
||||
});
|
||||
|
||||
const first = manager.request(
|
||||
"netcatty:local:start",
|
||||
{ sessionId: "session-1", bootEpoch: 1 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
const firstRequest = child.messages.at(-1);
|
||||
const second = manager.request(
|
||||
"netcatty:local:start",
|
||||
{ sessionId: "session-1", bootEpoch: 2 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
const secondRequest = child.messages.at(-1);
|
||||
|
||||
// Orphan close from the aborted StrictMode boot after the remount start is pending.
|
||||
// This is the exact IPC order that produces:
|
||||
// superseded → "closed before its output route opened"
|
||||
const closeResult = await manager.request(
|
||||
"netcatty:close:await",
|
||||
{ sessionId: "session-1", bootEpoch: 1 },
|
||||
{ webContentsId: 7 },
|
||||
);
|
||||
assert.deepEqual(closeResult, { skipped: true, reason: "boot-epoch-mismatch" });
|
||||
manager.send("netcatty:close", { sessionId: "session-1", bootEpoch: 1 }, { webContentsId: 7 });
|
||||
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: firstRequest.requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
sessionGeneration: 0,
|
||||
});
|
||||
await assert.rejects(first, /superseded by a newer start request|closed before its output route opened/u);
|
||||
|
||||
child.emit("message", {
|
||||
kind: "response",
|
||||
requestId: secondRequest.requestId,
|
||||
result: { sessionId: "session-1" },
|
||||
sessionGeneration: 1,
|
||||
});
|
||||
await second;
|
||||
assert.equal(manager.hasOpenSession("session-1"), true);
|
||||
});
|
||||
|
||||
|
||||
test("closed-session tombstones are bounded without admitting recent stale output", async () => {
|
||||
const child = new FakeChild();
|
||||
const tapped = [];
|
||||
|
||||
@@ -58,6 +58,9 @@ const updateErrorListeners = new Set();
|
||||
const updateNeedsSaveListeners = new Set();
|
||||
const terminalPopupConfigState = {
|
||||
pending: null,
|
||||
// Keep the last delivered payload so StrictMode remount (unsubscribe →
|
||||
// resubscribe) can replay config after the one-shot pending slot was drained.
|
||||
lastPayload: null,
|
||||
listeners: new Set(),
|
||||
};
|
||||
|
||||
@@ -340,10 +343,12 @@ ipcRenderer.on("netcatty:zmodem:detect", (_event, payload) => {
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:window:terminalPopupConfig", (_event, payload) => {
|
||||
terminalPopupConfigState.lastPayload = payload;
|
||||
if (terminalPopupConfigState.listeners.size === 0) {
|
||||
terminalPopupConfigState.pending = payload;
|
||||
return;
|
||||
}
|
||||
terminalPopupConfigState.pending = null;
|
||||
terminalPopupConfigState.listeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
|
||||
@@ -443,12 +443,14 @@ function createPreloadApi(ctx) {
|
||||
},
|
||||
onTerminalPopupConfig: (cb) => {
|
||||
terminalPopupConfigState.listeners.add(cb);
|
||||
if (terminalPopupConfigState.pending) {
|
||||
const pending = terminalPopupConfigState.pending;
|
||||
const replay = terminalPopupConfigState.pending ?? terminalPopupConfigState.lastPayload;
|
||||
if (replay) {
|
||||
// Drain the one-shot pending slot once a live subscriber exists, but keep
|
||||
// lastPayload so StrictMode remount can resubscribe and still setConfig.
|
||||
terminalPopupConfigState.pending = null;
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
cb(pending);
|
||||
cb(replay);
|
||||
} catch (err) {
|
||||
console.error("Terminal popup config callback failed", err);
|
||||
}
|
||||
|
||||
@@ -360,6 +360,7 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
const sessionCloseEpochs = new Map();
|
||||
const sessionLifecycleTombstoneTimes = new Map();
|
||||
const sessionStartMarkers = new Set();
|
||||
const pendingSessionStartBootEpochs = new Map();
|
||||
let urgentInputPorts = null;
|
||||
const now = typeof options.now === "function" ? options.now : Date.now;
|
||||
const sessionLifecycleTombstoneTtlMs = Number.isFinite(options.sessionLifecycleTombstoneTtlMs)
|
||||
@@ -382,6 +383,25 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
sessionLifecycleTombstoneTimes.delete(sessionId);
|
||||
sessionOutputGenerations.delete(sessionId);
|
||||
sessionCloseEpochs.delete(sessionId);
|
||||
pendingSessionStartBootEpochs.delete(sessionId);
|
||||
}
|
||||
|
||||
function normalizeBootEpoch(bootEpoch) {
|
||||
if (!Number.isFinite(bootEpoch)) return undefined;
|
||||
return Number(bootEpoch);
|
||||
}
|
||||
|
||||
function rememberPendingStartBootEpoch(sessionId, bootEpoch) {
|
||||
const normalized = normalizeBootEpoch(bootEpoch);
|
||||
if (!sessionId || normalized === undefined) return;
|
||||
pendingSessionStartBootEpochs.set(sessionId, normalized);
|
||||
}
|
||||
|
||||
function shouldSkipStaleEpochClose(sessionId, bootEpoch) {
|
||||
const closeEpoch = normalizeBootEpoch(bootEpoch);
|
||||
if (closeEpoch === undefined || !sessionId) return false;
|
||||
const ownerEpoch = pendingSessionStartBootEpochs.get(sessionId);
|
||||
return ownerEpoch !== undefined && ownerEpoch > closeEpoch;
|
||||
}
|
||||
|
||||
function pruneSessionLifecycleTombstones() {
|
||||
@@ -589,6 +609,7 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
void handleRequest(message);
|
||||
return;
|
||||
}
|
||||
rememberPendingStartBootEpoch(sessionId, message.payload?.bootEpoch);
|
||||
const closeEpoch = sessionCloseEpochs.get(sessionId) ?? 0;
|
||||
const previous = sessionOperationTails.get(sessionId);
|
||||
const previousKind = sessionOperationKinds.get(sessionId);
|
||||
@@ -620,7 +641,18 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
else handleSend(message);
|
||||
return;
|
||||
}
|
||||
if (shouldSkipStaleEpochClose(sessionId, message.payload?.bootEpoch)) {
|
||||
if (expectsResponse) {
|
||||
parentPort.postMessage({
|
||||
kind: "response",
|
||||
requestId: message.requestId,
|
||||
result: { skipped: true, reason: "boot-epoch-mismatch" },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
sessionCloseEpochs.set(sessionId, (sessionCloseEpochs.get(sessionId) ?? 0) + 1);
|
||||
pendingSessionStartBootEpochs.delete(sessionId);
|
||||
touchSessionLifecycleTombstone(sessionId);
|
||||
const previous = sessionOperationTails.get(sessionId);
|
||||
const current = (previous || Promise.resolve()).catch(() => {}).then(async () => {
|
||||
|
||||
@@ -308,3 +308,68 @@ test('AgentRuntime delegates steering only while a compatible turn is active', a
|
||||
attachedImages: [],
|
||||
}), { status: 'inactive' });
|
||||
});
|
||||
|
||||
test('AgentRuntime runTurn rejects a concurrent start for the same session', async () => {
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
|
||||
class BusyMockDriver implements TurnDriver {
|
||||
readonly backend = 'catty' as const;
|
||||
readonly runs: TurnInput[] = [];
|
||||
|
||||
async run(input: TurnInput): Promise<void> {
|
||||
this.runs.push(input);
|
||||
await firstGate;
|
||||
}
|
||||
|
||||
abort(): void {}
|
||||
}
|
||||
|
||||
const driver = new BusyMockDriver();
|
||||
const runtime = new AgentRuntime({ drivers: [driver], traceStore: new TraceStore() });
|
||||
const ui = {
|
||||
addMessageToSession: () => {},
|
||||
updateLastMessage: () => {},
|
||||
updateMessageById: () => {},
|
||||
reportStreamError: () => {},
|
||||
setStreamingForScope: () => {},
|
||||
};
|
||||
|
||||
const first = runtime.runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-busy',
|
||||
sendScopeKey: 'scope',
|
||||
userText: 'one',
|
||||
signal: new AbortController().signal,
|
||||
currentSession: undefined,
|
||||
assistantMsgId: 'a1',
|
||||
context: {} as never,
|
||||
maxIterations: 1,
|
||||
bridge: null,
|
||||
ui,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => runtime.runTurn({
|
||||
backend: 'catty',
|
||||
chatSessionId: 'chat-busy',
|
||||
sendScopeKey: 'scope',
|
||||
userText: 'two',
|
||||
signal: new AbortController().signal,
|
||||
currentSession: undefined,
|
||||
assistantMsgId: 'a2',
|
||||
context: {} as never,
|
||||
maxIterations: 1,
|
||||
bridge: null,
|
||||
ui,
|
||||
}),
|
||||
(err: unknown) => err instanceof Error
|
||||
&& (err as Error & { code?: string }).code === 'AGENT_TURN_BUSY',
|
||||
);
|
||||
|
||||
releaseFirst?.();
|
||||
await first;
|
||||
assert.equal(driver.runs.length, 1);
|
||||
});
|
||||
@@ -99,9 +99,10 @@ export class AgentRuntime {
|
||||
}
|
||||
|
||||
const chatSessionId = input.chatSessionId;
|
||||
const priorTurn = this.activeTurnPromises.get(chatSessionId);
|
||||
if (priorTurn) {
|
||||
await priorTurn.catch(() => {});
|
||||
if (this.activeTurnPromises.has(chatSessionId)) {
|
||||
const error = new Error(`Agent turn already in progress for session ${chatSessionId}`);
|
||||
(error as Error & { code?: string }).code = 'AGENT_TURN_BUSY';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const turnPromise = this.runTurnInternal(input, driver);
|
||||
|
||||
@@ -269,3 +269,35 @@ test('cancelApprovalTimeout asks main to drop Codex App Server interaction timer
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('requestApproval joins an existing waiter for the same toolCallId', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const toolCallId = `dup-approval-${Date.now()}`;
|
||||
const first = requestApproval(toolCallId, 'sftp_write', { path: '/tmp/a' }, 'chat-1', 60_000);
|
||||
const second = requestApproval(toolCallId, 'sftp_write', { path: '/tmp/a' }, 'chat-1', 60_000);
|
||||
resolveApproval(toolCallId, { approved: true, scope: 'once' });
|
||||
assert.deepEqual(await Promise.all([first, second]), [true, true]);
|
||||
clearAllPendingApprovals();
|
||||
});
|
||||
|
||||
test('resolveApproval skips MCP IPC when the pending entry is already gone', () => {
|
||||
clearAllPendingApprovals();
|
||||
const calls: Array<{ id: string; approved: boolean }> = [];
|
||||
const previous = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
respondMcpApproval: async (id: string, approved: boolean) => {
|
||||
calls.push({ id, approved });
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
resolveApproval('mcp_approval_stale', true);
|
||||
assert.deepEqual(calls, []);
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = previous;
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
@@ -193,6 +193,17 @@ export function requestApproval(
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const existing = pendingApprovals.get(toolCallId);
|
||||
if (existing) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const previousResolve = existing.resolve;
|
||||
existing.resolve = (resolution) => {
|
||||
previousResolve(resolution);
|
||||
resolve(resolution.approved);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
emitApprovalEvent('approval_requested', request);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
@@ -308,19 +319,23 @@ export function resolveApproval(
|
||||
const entry = pendingApprovals.get(toolCallId);
|
||||
const request = entry?.request;
|
||||
|
||||
if (entry) {
|
||||
pendingApprovals.delete(toolCallId);
|
||||
entry.resolve(resolution);
|
||||
if (!entry) {
|
||||
// Stale UI click after the map entry was already drained — do not fan out
|
||||
// a second MCP IPC response for a missing approval.
|
||||
return;
|
||||
}
|
||||
|
||||
if (request) {
|
||||
let persistedGrantId: string | undefined;
|
||||
if (approved && request.source !== 'codex-app-server' && persistGrants?.length) {
|
||||
for (const grant of persistGrants) {
|
||||
grantPersister?.(grant);
|
||||
persistedGrantId = grant.id;
|
||||
}
|
||||
pendingApprovals.delete(toolCallId);
|
||||
entry.resolve(resolution);
|
||||
|
||||
let persistedGrantId: string | undefined;
|
||||
if (approved && request?.source !== 'codex-app-server' && persistGrants?.length) {
|
||||
for (const grant of persistGrants) {
|
||||
grantPersister?.(grant);
|
||||
persistedGrantId = grant.id;
|
||||
}
|
||||
}
|
||||
if (request) {
|
||||
emitApprovalEvent('approval_resolved', request, {
|
||||
outcome: approved ? 'approved' : 'denied',
|
||||
persistedGrantId,
|
||||
|
||||
@@ -118,3 +118,33 @@ test('Codex App Server interaction gate replays requests and forwards typed resp
|
||||
teardown();
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
});
|
||||
|
||||
test('Codex App Server interaction bridge is ref-counted across setup/teardown', () => {
|
||||
let subscribeCount = 0;
|
||||
let unsubscribeCount = 0;
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
onCodexAppServerInteractionRequest: () => {
|
||||
subscribeCount += 1;
|
||||
return () => {
|
||||
unsubscribeCount += 1;
|
||||
};
|
||||
},
|
||||
onCodexAppServerInteractionCleared: () => () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const first = setupCodexAppServerInteractionBridge();
|
||||
const second = setupCodexAppServerInteractionBridge();
|
||||
assert.equal(subscribeCount, 1, 'nested setup must not stack ipc listeners');
|
||||
first();
|
||||
assert.equal(unsubscribeCount, 0, 'first teardown while second still live keeps the bridge');
|
||||
second();
|
||||
assert.equal(unsubscribeCount, 1);
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
});
|
||||
@@ -85,7 +85,10 @@ function notifyCleared(interactionIds: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function setupCodexAppServerInteractionBridge(): () => void {
|
||||
let bridgeInstallCount = 0;
|
||||
let bridgeTeardown: (() => void) | null = null;
|
||||
|
||||
function installCodexAppServerInteractionBridge(): () => void {
|
||||
const bridge = (window as unknown as {
|
||||
netcatty?: {
|
||||
onCodexAppServerInteractionRequest?: (
|
||||
@@ -123,6 +126,25 @@ export function setupCodexAppServerInteractionBridge(): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* App-singleton IPC bridge (same pattern as setupMcpApprovalBridge).
|
||||
* Ref-counted so StrictMode remount / accidental multi-mount does not stack
|
||||
* ipcRenderer listeners.
|
||||
*/
|
||||
export function setupCodexAppServerInteractionBridge(): () => void {
|
||||
bridgeInstallCount += 1;
|
||||
if (bridgeInstallCount === 1) {
|
||||
bridgeTeardown = installCodexAppServerInteractionBridge();
|
||||
}
|
||||
return () => {
|
||||
bridgeInstallCount = Math.max(0, bridgeInstallCount - 1);
|
||||
if (bridgeInstallCount === 0) {
|
||||
bridgeTeardown?.();
|
||||
bridgeTeardown = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function onCodexAppServerInteraction(listener: InteractionListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
|
||||
Reference in new issue
Block a user