Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 9abfa87f71 chore: drop remount latch comment
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 e74c0600ec fix: remount-safe Ask-AI terminal selection latch
Move consumed requestIds to a module Set so StrictMode remount cannot
append the same selection chip twice before parent state clears.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 f32c6ef8f5 chore: strip StrictMode sermon comments after Bugbot/Comment Sicko pass
Keep effect-order and public-export contracts; drop remount/perf narration
that the latches and tests already encode.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 80d49e5d9f fix: keep note drafts across mode toggle and external note publishes
Flush ref-only content before edit/preview remount, and skip prop→editor
setMarkdown while a local draft diverges from the last synced value.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 4569e6aa95 test: allow gated useNotesStore in VaultNotesSection memo contract
The retained notes section now passes enabled:isActive so hidden mounts do
not subscribe; update the VaultView memo source assertion accordingly.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 4b79eb1332 fix: remount-safe AI send latch and skip stale MCP approval IPC
Replace the component sendInFlightRef with a module key latch so StrictMode
panel remount cannot reopen a mid-flight send, and no-op resolveApproval when
the pending entry is already drained.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 1c1ab80918 fix: clear remaining AI StrictMode double-fire hazards
Latch Ask-AI terminal selection on requestId before draft mutation, hoist
Codex App Server IPC bridge to App (ref-counted), and make Catty
requestApproval join an existing waiter for the same toolCallId.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 a6c52ff1b4 perf: freeze inactive vault notes against hosts churn and prefetch MDXEditor
Gate the retained VaultNotesSection notesStore subscription while hidden,
ignore hosts identity in its memo compare when inactive, and warm the lazy
InlineMarkdownEditor chunk when notes become active.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 63df81c1cc fix: latch SSH transport idle TTL notify under StrictMode
Avoid duplicate main-process notifySettingsChanged on effect double-invoke
when the idle TTL value has not changed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 8b369d7d4d perf: gate hidden notes panel subscribe and skip host-link annotate on keystrokes
Hidden terminal notes mounts keep a live snapshot read but stop listening to
publishes. Host-link DOM annotation follows hosts/mode (preview MutationObserver)
instead of every markdown value change.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 364e2ff686 fix: clear remaining StrictMode doubles and notes editor jank
Gate every AI send and reject concurrent runTurn, latch tray connect flush
and settings persistMounted remounts, and stop notes content drafts from
rebuilding MDXEditor/shell on each keystroke.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 e29ba085a6 docs: add StrictMode mega-subscriber design and plan artifacts
Force-add Phase 0 spec/plan under docs/superpowers (gitignored locally)
so the delivery branch carries the approved design and plan copy.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 74faa46744 perf: finish StrictMode mega-subscriber plan (accent isolation + docs)
Stabilize published terminal themes across custom-accent drag, apply accent
at chrome/log leaves and CSS injection only, land Phase 0 design/plan docs,
and lock Host-bag / Bugbot-clean contracts for the isolation checklist.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
Cursor Agentand陈大猫 c93bb77176 fix(terminal): stop first SSH fail under StrictMode remount
Defer backend boot past the double-invoke, cancel superseded vault init,
gate start on vault hydration, and harden hotkey/attach cleanup so the
first connect no longer races a stale close or empty keychain.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:00 +00:00
bincxz 9fb61841f7 Cursor: Apply local changes for cloud agent 2026-08-07 14:38:00 +00:00
52 changed files with 1552 additions and 126 deletions

No files matched your search

+5
View File
@@ -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*\)/);
+10 -1
View File
@@ -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,
+24
View File
@@ -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,
+8 -3
View File
@@ -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);
-3
View File
@@ -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();
+6 -1
View File
@@ -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',
);
});
+26
View File
@@ -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');
+3 -1
View File
@@ -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);
+13
View File
@@ -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({
+8 -3
View File
@@ -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,
);
+19 -3
View File
@@ -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,
+8 -3
View File
@@ -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',
);
});
+20 -5
View File
@@ -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,
+19 -3
View File
@@ -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(() => {
+20 -9
View File
@@ -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);
+15
View File
@@ -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
View File
@@ -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;
+5 -1
View File
@@ -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 (
<>
+4 -1
View File
@@ -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/);
});
+4 -1
View File
@@ -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,
+38
View File
@@ -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",
);
});
+16
View File
@@ -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);
}
+38 -6
View File
@@ -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/,
);
});
+27 -16
View File
@@ -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)}
+31 -9
View File
@@ -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);
+6 -1
View File
@@ -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;
}
+75 -14
View File
@@ -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,
+34 -10
View File
@@ -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 = [];
+5
View File
@@ -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);
+5 -3
View File
@@ -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);
}
+32
View File
@@ -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);
});
+4 -3
View File
@@ -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();
}
});
+25 -10
View File
@@ -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); };