Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 4bcb98b9d6 fix: invalidate Escape-dismissed autocomplete cascade fetches
Escape at level 0 kept the main-row selection, so late directory listings
could reinstall panels after dismissal. Dismiss via a versioned cascade
helper and bind level-0 prefetch installs to suggestion identity.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 02:19:40 +00:00
netcatty-bot de17e1baf2 fix: address Codex review on PR #2824 2026-08-08 22:55:37 +00:00
netcatty-bot d8011adfbd fix: address Codex review on PR #2824 2026-08-08 22:22:24 +00:00
netcatty-bot 53df32e686 fix: address Codex review on PR #2824 2026-08-08 21:51:36 +00:00
netcatty-bot 9c120ed2fa fix: address Codex review on PR #2824 2026-08-08 21:16:34 +00:00
Cursor Agentand陈大猫 a287d303aa fix(terminal): prefetch autocomplete sub-dirs after selection commits
Calling fetchSubDirForIndex from the startTransition refresh path raced
when directory listings resolved before selectedIndex flushed, so panels
were dropped. Prefetch from a post-commit effect instead, and skip when
cascade panels are already open.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:28:36 +00:00
Cursor Agentand陈大猫 7185b38a46 fix(terminal): prefetch sub-dir panels for default autocomplete selection
Default-selecting the first suggestion skipped fetchSubDirForIndex, so
directory cascade panels and ArrowRight stayed inert until ArrowUp/Down.
Also keep open cascade panels across unchanged suggestion refreshes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:25:58 +00:00
netcatty-bot f06c9aace0 fix(#2821): automated Cursor CLI fix 2026-08-08 01:54:59 +00:00
3490b0673d fix: StrictMode 双发清理 + 笔记页卡顿优化 (#2810)
* Cursor: Apply local changes for cloud agent

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* chore: drop remount latch comment

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:38:24 +00:00
陈大猫andCursor 70607cb26d perf: StrictMode + Host-island mega-subscriber elimination (#2809)
Split vault/session/settings into publishers and store-backed Hosts so hot
fields no longer rebuild the whole shell, and harden Terminal boot / startup
effects for StrictMode double-mounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 14:37:37 +00:00
a56b868d10 fix(system-manager): show Ascend 910B NPU info from CANN 24.x npu-smi tables (#2812)
* fix(system-manager): parse CANN 24.x Ascend npu-smi info tables

ModelArts / CANN 24.1 tables include Hugepages-Usage and Bus-Id chip rows
that the old regex rejected, so the GPU tab showed zero Ascend devices
even when npu-smi was present. Parse summary+chip pairs like nputop,
always collect the info dump, and fall back to info -m for NPU IDs.

Closes #2811

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* feat(system-manager): harden Ascend parsing from nputop fixtures and polish GPU UI

Borrow real npu-smi samples from youyve/nputop (Apache-2.0) so CI covers
910B2C, 310B4, Atlas A3 multi-chip, and 310P3 process tables without
hardware. Aggregate multi-chip NPUs, parse info-dump processes, and surface
npu-smi driver versions.

GPU tab now keeps a short util/HBM sparkline history and ResourceBar uses
smooth width transitions with load-aware tones, closer to an nvtop feel
inside the existing system-manager chrome.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 13:44:18 +00:00
110 changed files with 9406 additions and 2620 deletions

No files matched your search

+61 -2041
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -7,6 +7,7 @@ import {
} from '../state/activeTabStore';
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
@@ -19,7 +20,7 @@ import type {
import type { LogView } from '../state/logViewState';
import type { EditorTabChrome } from '../state/editorTabStore';
interface AppActiveTabChromeProps {
export interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
applyAppTheme: () => void;
@@ -29,8 +30,6 @@ interface AppActiveTabChromeProps {
workspaceById: Map<string, Workspace>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
accentMode: 'theme' | 'custom';
customAccent: string;
editorTabs: readonly EditorTabChrome[];
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
@@ -44,6 +43,9 @@ interface AppActiveTabChromeProps {
* re-renders this null-rendering component (and the self-subscribing leaves)
* instead of forcing the entire App tree (which holds all vault/session/
* settings state and rebuilds the giant AppView ctx) to re-render.
*
* Accent comes from appearanceChromeStore so color-picker drag does not
* rebuild AppShell chrome props.
*/
export function AppActiveTabChrome({
showSftpTab,
@@ -55,14 +57,13 @@ export function AppActiveTabChrome({
workspaceById,
currentTerminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
editorTabs,
logViews,
resolveSessionAppearance,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
const { accentMode, customAccent } = useAppearanceChromeStore();
useEffect(() => {
if (!showSftpTab && activeTabId === 'sftp') {
+25 -20
View File
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const appSideEffectsSource = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
const terminalHostSource = readFileSync(new URL("./hosts/TerminalHost.tsx", import.meta.url), "utf8");
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
@@ -13,28 +14,32 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
assert.match(appSource, /useThemeRuntime\(/);
assert.match(appSource, /pickTerminalTheme\(themeId\)/);
assert.match(appSource, /pickTheme: pickTerminalTheme/);
// Domain deps must use stable members, not the whole themeRuntime bag.
assert.match(appSource, /clearThemeIntent, settleManualThemeIntent, pickTerminalTheme, resolveFocusedAppearance/);
assert.match(terminalHostSource, /useThemeRuntime\(/);
assert.match(terminalHostSource, /pickTerminalTheme\(themeId\)/);
assert.match(terminalHostSource, /pickTheme: pickTerminalTheme/);
// Theme members are listed field-by-field on the TerminalHost bag (not themeRuntime bag).
assert.match(terminalHostSource, /clearThemeIntent,/);
assert.match(terminalHostSource, /settleManualThemeIntent,/);
assert.match(terminalHostSource, /pickTerminalTheme,/);
assert.match(terminalHostSource, /resolveSessionAppearance: resolveFocusedAppearance/);
assert.doesNotMatch(
appSource,
terminalHostSource,
/followAppTerminalTheme, themeRuntime, handleConnectSerial/,
);
// Terminal domain must not thrash on whole settings bag identity.
assert.match(appSource, /sshDebugLogsEnabled,/);
assert.match(terminalHostSource, /sshDebugLogsEnabled:/);
assert.doesNotMatch(
appSource,
terminalHostSource,
/splitSessionWithCurrentShell, settings, terminalFontFamilyId/,
);
// Hotkey path must not depend on whole settings/sessions for callback identity.
assert.match(appSource, /showSftpTab: showSftpTabRef\.current/);
assert.match(appSource, /sessions: sessionsRef\.current/);
assert.match(appSource, /connectionLogs: connectionLogsRef\.current/);
assert.match(appSource, /useTerminalAppearanceInjection/);
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
assert.match(appSource, /clearThemeIntent\(\)/);
assert.match(appSideEffectsSource, /showSftpTab: showSftpTabRef\.current/);
assert.match(appSideEffectsSource, /sessions: sessionsRef\.current/);
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/);
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
@@ -43,10 +48,10 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
});
test("default terminal theme selection clears the current mode override", () => {
assert.match(appSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
assert.match(appSource, /setTerminalThemeId\(themeId\)/);
assert.match(appSource, /resolvedTheme === 'dark'[\s\S]*setTerminalThemeDarkId\(TERMINAL_THEME_AUTO\)/);
assert.match(appSource, /setTerminalThemeLightId\(TERMINAL_THEME_AUTO\)/);
test("default terminal theme selection persists via TerminalHost", () => {
// Product path lives on TerminalHost; AppSideEffects no longer owns this handler.
assert.match(terminalHostSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
assert.match(terminalHostSource, /setTerminalThemeId\(themeId\)/);
assert.match(terminalHostSource, /TERMINAL_THEME_AUTO/);
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
});
+4 -6
View File
@@ -1,6 +1,7 @@
import React, { memo, useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import type { EditorTabChrome } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
@@ -26,9 +27,7 @@ interface AppHostTreeLayerProps {
editorTabs: readonly EditorTabChrome[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
@@ -60,9 +59,9 @@ function appHostTreeLayerAreEqual(
&& prev.editorTabs === next.editorTabs
&& prev.logViews === next.logViews
&& prev.orderedTabs === next.orderedTabs
&& prev.accentMode === next.accentMode
// accentMode / customAccent intentionally omitted — read from
// appearanceChromeStore so accent drag does not rebuild the App shell.
&& prev.currentTerminalTheme === next.currentTerminalTheme
&& prev.customAccent === next.customAccent
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
&& prev.hostById === next.hostById
&& prev.themeById === next.themeById
@@ -83,9 +82,7 @@ const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
editorTabs,
logViews,
orderedTabs,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
@@ -96,6 +93,7 @@ const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const { accentMode, customAccent } = useAppearanceChromeStore();
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
/**
* Owns dialog / queue / ephemeral React state for the main window.
*
* The concrete state currently lives in `AppSideEffects` (same React tree)
* and is published into `appLocalUiStore` for Host islands. This provider is
* the composition slot the architecture requires so App itself never owns
* domain bags or mega-hook subscriptions.
*/
export function AppLocalStateProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}
+4 -2
View File
@@ -53,10 +53,12 @@ test('vault activation suppresses inherited text color transitions', () => {
});
test('vault surface carries app theme vars while terminal chrome is active', () => {
const appThemeStyleSource = readFileSync(new URL('./useAppThemeStyle.ts', import.meta.url), 'utf8');
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
assert.match(appViewSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
assert.match(appViewSource, /<VaultViewContainer appThemeStyle=\{appThemeStyle\}>/);
assert.match(appThemeStyleSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
assert.match(appThemeStyleSource, /useAppearanceChromeStore/);
assert.match(appViewSource, /VaultThemedSurface|useAppThemeStyle|appThemeStyle/);
});
test('active tab chrome keeps removed theme side effects unmounted', () => {
@@ -0,0 +1,224 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const here = dirname(fileURLToPath(import.meta.url));
const appShellSource = readFileSync(join(here, 'AppShell.tsx'), 'utf8');
const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
const hostsDir = join(here, 'hosts');
const hostFiles = existsSync(hostsDir)
? readdirSync(hostsDir).filter((name) => name.endsWith('.tsx') || name.endsWith('.ts'))
: [];
const hostSources = Object.fromEntries(
hostFiles.map((name) => [name, readFileSync(join(hostsDir, name), 'utf8')]),
);
const MEGA_HOOKS = ['useVaultState', 'useSessionState', 'useSettingsState'] as const;
const APP_RUNTIME_HOOKS = [
'useAppVaultRuntime',
'useAppSessionRuntime',
'useAppSettingsRuntime',
] as const;
test('AppShell does not co-host the vault/session/settings mega hooks', () => {
for (const hook of MEGA_HOOKS) {
assert.doesNotMatch(
appShellSource,
new RegExp(`${hook}\\s*\\(`),
`AppShell must not call ${hook}(); Hosts subscribe to stores instead`,
);
}
});
test('AppShell composes the four Host islands', () => {
assert.match(appShellSource, /<VaultHost\b/);
assert.match(appShellSource, /<TerminalHost\b/);
assert.match(appShellSource, /<ChromeHost\b/);
assert.match(appShellSource, /<DialogsHost\b/);
});
test('AppShell renders the shell from store-backed bags only', () => {
assert.match(appShellSource, /useSyncExternalStore|useAppShellProps/);
assert.match(appShellSource, /appViewDomainsEqual/);
assert.match(appShellSource, /<AppView domains=\{domains\} \/>/);
assert.match(appShellSource, /<AppActiveTabChrome \{\.\.\.chrome\} \/>/);
});
test('App renders through AppShell and owns no domain bags', () => {
assert.match(appSource, /<AppShell\b/);
assert.doesNotMatch(appSource, /<AppView\b/);
assert.doesNotMatch(appSource, /<AppActiveTabChrome\b/);
assert.doesNotMatch(appSource, /<ConfirmDialog\b/);
assert.doesNotMatch(appSource, /<PortForwardHostKeyDialog\b/);
assert.doesNotMatch(appSource, /appVaultDomain\s*=/);
assert.doesNotMatch(appSource, /appTerminalDomain\s*=/);
assert.doesNotMatch(appSource, /appChromeDomain\s*=/);
assert.doesNotMatch(appSource, /appDialogsDomain\s*=/);
});
test('App.tsx does not subscribe to vault/session/settings runtimes or mega hooks', () => {
for (const hook of MEGA_HOOKS) {
assert.doesNotMatch(appSource, new RegExp(`\\b${hook}\\s*\\(`));
}
for (const hook of APP_RUNTIME_HOOKS) {
assert.doesNotMatch(
appSource,
new RegExp(`\\b${hook}\\s*\\(`),
`App.tsx must not call ${hook}(); move subscriptions into Hosts / AppSideEffects`,
);
}
assert.match(appSource, /<VaultPublisher>/);
assert.match(appSource, /<SessionPublisher\b/);
assert.match(appSource, /<SettingsPublisher\b/);
assert.match(appSource, /<AppSideEffects\b/);
});
test('publishers own the mega hooks and store fan-out', () => {
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
assert.match(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
assert.match(sessionPublisherSource, /publishSessionSnapshot\(/);
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(/);
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(null\)/);
});
test('publishers hand their runtime to App through the app runtime bridge', () => {
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(vault\)/);
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(null\)/);
assert.match(vaultPublisherSource, /<AppVaultRuntimeContext\.Provider value=\{vaultForApp\}>/);
assert.match(vaultPublisherSource, /notes: _notes/);
assert.match(vaultPublisherSource, /connectionLogs: _connectionLogs/);
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(session\)/);
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(null\)/);
assert.match(sessionPublisherSource, /<AppSessionRuntimeContext\.Provider value=\{session\}>/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(null\)/);
assert.match(settingsPublisherSource, /<AppSettingsRuntimeContext\.Provider value=\{settings\}>/);
});
test('VaultHost builds from vault snapshot stores', () => {
const source = hostSources['VaultHost.tsx'];
assert.ok(source, 'application/app/hosts/VaultHost.tsx must exist');
assert.match(source, /useVaultSnapshot/);
assert.match(source, /useVaultSnapshotActions|getVaultSnapshotActions/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
});
test('TerminalHost builds from session snapshot + terminal settings store', () => {
const source = hostSources['TerminalHost.tsx'];
assert.ok(source, 'application/app/hosts/TerminalHost.tsx must exist');
assert.match(source, /useSessionSnapshot/);
assert.match(source, /useSessionSnapshotActions|getSessionSnapshotActions/);
assert.match(source, /useTerminalSettingsStore|getTerminalSettingsSnapshot/);
assert.doesNotMatch(source, /\buseSessionState\s*\(/);
assert.doesNotMatch(source, /\buseAppSessionRuntime\s*\(/);
});
test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
const source = hostSources['ChromeHost.tsx'];
assert.ok(source, 'application/app/hosts/ChromeHost.tsx must exist');
assert.match(source, /useSettingsChromeStore|getSettingsChromeSnapshot/);
assert.match(source, /useSessionSnapshot|useSessionSnapshotField|getSessionSnapshot/);
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseSettingsState\s*\(/);
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
});
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
const source = hostSources['DialogsHost.tsx'];
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
});
test('AppSideEffects may use runtime hooks; App must not', () => {
const sideEffectsPath = join(here, 'AppSideEffects.tsx');
assert.ok(existsSync(sideEffectsPath), 'application/app/AppSideEffects.tsx must exist');
const sideEffectsSource = readFileSync(sideEffectsPath, 'utf8');
assert.doesNotMatch(sideEffectsSource, /\bfunction App\b|\bconst App\b/);
assert.match(
sideEffectsSource,
/useAppVaultRuntime|useAppSessionRuntime|useAppSettingsRuntime|getAppVaultRuntime|getAppSessionRuntime|getAppSettingsRuntime/,
);
});
test('AppSideEffects does not build domain bags for Hosts', () => {
const sideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.doesNotMatch(sideEffectsSource, /appVaultDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appTerminalDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appChromeDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appDialogsDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appMountsDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appViewDomains\s*=/);
assert.doesNotMatch(sideEffectsSource, /appShellChrome\s*=/);
assert.doesNotMatch(sideEffectsSource, /appShellOverlays\s*=/);
// Flat glue only — no prepared domain bags on the handlers bridge.
assert.doesNotMatch(sideEffectsSource, /vaultDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /terminalDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /chromeDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /dialogsDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /mountsDomain\s*:/);
assert.match(sideEffectsSource, /registerAppHandlers\s*\(/);
assert.match(sideEffectsSource, /publishAppLocalUi\s*\(/);
});
test('Hosts assemble bags field-by-field without spreading prepared domains', () => {
for (const name of ['VaultHost.tsx', 'TerminalHost.tsx', 'ChromeHost.tsx', 'DialogsHost.tsx']) {
const source = hostSources[name];
assert.ok(source, `application/app/hosts/${name} must exist`);
assert.doesNotMatch(
source,
/handlers\?\.(vault|terminal|chrome|dialogs)Domain|handlers\?\.appShellChrome|handlers\?\.appShellOverlays/,
`${name} must not read prepared *Domain / appShell* bags from handlers`,
);
assert.doesNotMatch(
source,
/\.\.\.\s*prepared/,
`${name} must not spread a prepared domain bag`,
);
assert.match(source, /getAppHandlers|subscribeAppHandlers/);
}
});
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*\)/);
});
+68
View File
@@ -0,0 +1,68 @@
import { memo } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { ConfirmDialog } from '../../components/ui/confirm-dialog';
import { PortForwardHostKeyDialog } from '../../components/port-forwarding';
import { AppActiveTabChrome } from './AppActiveTabChrome';
import { AppView } from './AppView';
import {
useAppShellProps,
type AppShellOverlays,
} from './appShellPropsStore';
import { ChromeHost } from './hosts/ChromeHost';
import { DialogsHost } from './hosts/DialogsHost';
import { TerminalHost } from './hosts/TerminalHost';
import { VaultHost } from './hosts/VaultHost';
export type { AppShellOverlays };
/**
* The rendered main window. Host islands subscribe to stores and publish
* domain / chrome / overlay bags into `appShellPropsStore`; this shell only
* re-renders when those bag identities change (see `appViewDomainsEqual` /
* chrome / overlays identity checks in the store).
*
* Default `memo` is enough: AppShell takes no props, so parent App updates
* do not force a re-render; store subscriptions via `useAppShellProps` still
* drive updates when Hosts publish new bag identities.
*
* `AppShell.architecture.test.ts` enforces Host composition and that the
* three mega hooks never reappear here.
*/
function AppShellView() {
const { t } = useI18n();
const { domains, chrome, overlays } = useAppShellProps();
return (
<>
<VaultHost />
<TerminalHost />
<ChromeHost />
<DialogsHost />
{domains && chrome && overlays ? (
<>
<PortForwardHostKeyDialog onAddKnownHost={overlays.onAddKnownHost} />
<ConfirmDialog
open={overlays.deleteHostConfirm !== null}
title={
overlays.deleteHostConfirm
? t('confirm.deleteHost', { name: overlays.deleteHostConfirm.name })
: ''
}
confirmLabel={t('action.delete')}
destructive
onOpenChange={(open) => {
if (!open) overlays.onCancelDeleteHost();
}}
onConfirm={overlays.onConfirmDeleteHost}
/>
<AppActiveTabChrome {...chrome} />
<AppView domains={domains} />
</>
) : null}
</>
);
}
export const AppShell = memo(AppShellView);
AppShell.displayName = 'AppShell';
+1768
View File
File diff suppressed because it is too large. Load diff
+143 -76
View File
@@ -24,8 +24,12 @@ import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../state/settingsChromeStore';
import { useAppThemeStyle } from './useAppThemeStyle';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
@@ -128,6 +132,82 @@ function RenameDraftDialog({
}
/**
* Applies app theme CSS vars to the vault surface. Subscribing here (instead of
* in AppView) keeps accent-picker drags from rebuilding the whole shell; the
* `children` element identity is unchanged so VaultView bails out.
*/
function AppVaultThemeSurface({
VaultViewContainer,
children,
}: {
VaultViewContainer: React.ComponentType<any>;
children: React.ReactNode;
}) {
const appThemeStyle = useAppThemeStyle();
return (
<VaultViewContainer appThemeStyle={appThemeStyle}>
{children}
</VaultViewContainer>
);
}
/** Plugin keybinding host with locally derived theme tokens and locale. */
function AppPluginKeybindingThemeHost({
sessions,
workspaces,
}: {
sessions: any[];
workspaces: any[];
}) {
const appThemeStyle = useAppThemeStyle();
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
[appThemeStyle],
);
return (
<AppPluginKeybindingHost
locale={uiLanguage}
theme={resolvedTheme}
themeTokens={pluginThemeTokens}
sessions={sessions}
workspaces={workspaces}
/>
);
}
/**
* Log replay surface reads the latest log body from connectionLogsStore, so
* terminal-data appends never touch the App domain bags.
*/
function AppLogViewSurface({
LogViewWrapper,
logView,
defaultTerminalTheme,
defaultFontSize,
onClose,
}: {
LogViewWrapper: React.ComponentType<any>;
logView: any;
defaultTerminalTheme: any;
defaultFontSize: number;
onClose: () => void;
}) {
const { connectionLogs, updateConnectionLog } = useConnectionLogsStore();
const latestLog =
connectionLogs.find((log) => log.id === logView.connectionLogId) ?? logView.log;
return (
<LogViewWrapper
logView={{ ...logView, log: latestLog }}
defaultTerminalTheme={defaultTerminalTheme}
defaultFontSize={defaultFontSize}
onClose={onClose}
onUpdateLog={updateConnectionLog}
/>
);
}
export type AppViewProps = {
domains: AppViewDomains;
};
@@ -176,30 +256,50 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
accentMode, addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource,
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
// Chrome-visible settings slice comes from settingsChromeStore, not from the
// App chrome domain bag — the whole `settings` object changes identity on
// every settings render and would rebuild the shell.
const {
theme: themePreference,
resolvedTheme,
windowOpacity,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
hostClickBehavior,
showOnlyUngroupedHostsInRoot,
dynamicTabTitleMode,
disableTerminalFontZoom,
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
} = useSettingsChromeStore();
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
const handleTerminalCommandExecuted = useCallback((
command: string,
hostId: string,
@@ -217,21 +317,6 @@ function AppViewInner({ domains }: AppViewProps) {
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
}, [setAddToWorkspaceDialog]);
const appThemeStyle = useMemo(() => {
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? settings.darkUiThemeId : settings.lightUiThemeId,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, resolvedTheme, settings.darkUiThemeId, settings.lightUiThemeId]);
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
[appThemeStyle],
);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -354,7 +439,7 @@ function AppViewInner({ domains }: AppViewProps) {
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
themePreference={settings.theme}
themePreference={themePreference}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
@@ -374,21 +459,21 @@ function AppViewInner({ domains }: AppViewProps) {
onCloseLogView={closeLogView}
onCloseTabsBatch={closeTabsBatch}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onThemeChange={settings.setTheme}
onThemeChange={setTheme}
onOpenSettings={handleOpenSettings}
externalMcpEnabled={externalMcpToggle.enabled}
onToggleExternalMcp={externalMcpToggle.setEnabled}
showExternalMcpToggle={!isPeerSessionWindow}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
windowOpacity={windowOpacity}
setWindowOpacity={setWindowOpacity}
onSyncNow={handleSyncNowManual}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
showSftpTab={settings.showSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
dynamicTabTitleMode={settings.terminalSettings.dynamicTabTitleMode}
showSftpTab={showSftpTab}
showHostTreeSidebar={showHostTreeSidebar}
dynamicTabTitleMode={dynamicTabTitleMode}
editorTabs={editorTabs}
pluginViewTabs={pluginViewTabs}
onClosePluginViewTab={closePluginViewTab}
@@ -398,7 +483,7 @@ function AppViewInner({ domains }: AppViewProps) {
<div className="flex-1 relative min-h-0">
<AppHostTreeLayer
enabled={settings.showHostTreeSidebar}
enabled={showHostTreeSidebar}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
@@ -407,9 +492,7 @@ function AppViewInner({ domains }: AppViewProps) {
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
accentMode={accentMode}
currentTerminalTheme={currentTerminalTheme}
customAccent={customAccent}
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
@@ -444,7 +527,7 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateHosts={updateHosts}
/>
<VaultViewContainer appThemeStyle={appThemeStyle}>
<AppVaultThemeSurface VaultViewContainer={VaultViewContainer}>
<VaultView
hosts={hosts}
keys={keys}
@@ -452,11 +535,8 @@ function AppViewInner({ domains }: AppViewProps) {
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
customGroups={customGroups}
knownHosts={effectiveKnownHosts}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessionCount={sessions.filter((s) => !s.hiddenFromTabs).length}
hotkeyScheme={hotkeyScheme}
@@ -480,8 +560,6 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
onUpdateCustomGroups={updateCustomGroups}
onCommitPluginImporterData={commitPluginImporterData}
onUpdateKnownHosts={updateKnownHosts}
@@ -492,14 +570,11 @@ function AppViewInner({ domains }: AppViewProps) {
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
onConvertKnownHost={convertKnownHostToHost}
onToggleConnectionLogSaved={toggleConnectionLogSaved}
onDeleteConnectionLog={deleteConnectionLog}
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
onRunSnippet={runSnippet}
onOpenLogView={openLogView}
showRecentHosts={settings.showRecentHosts}
hostClickBehavior={settings.hostClickBehavior}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
showRecentHosts={showRecentHosts}
hostClickBehavior={hostClickBehavior}
showOnlyUngroupedHostsInRoot={showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
deepLinkHostDraft={deepLinkHostDraft}
@@ -508,7 +583,7 @@ function AppViewInner({ domains }: AppViewProps) {
onVaultFocusRequestHandled={() => setVaultFocusRequest(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
</AppVaultThemeSurface>
<SftpViewMount
hosts={terminalHosts}
@@ -554,14 +629,12 @@ function AppViewInner({ domains }: AppViewProps) {
clearThemeIntent={ctx.clearThemeIntent}
settleManualThemeIntent={ctx.settleManualThemeIntent}
resolveSessionAppearance={ctx.resolveSessionAppearance}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
restoreTerminalCwd={settings.restoreTerminalCwd}
disableTerminalFontZoom={disableTerminalFontZoom}
restoreTerminalCwd={restoreTerminalCwd}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={handleDefaultTerminalThemeChange}
@@ -612,8 +685,8 @@ function AppViewInner({ domains }: AppViewProps) {
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
@@ -623,7 +696,7 @@ function AppViewInner({ domains }: AppViewProps) {
sessionLogsFormat={sessionLogsFormat}
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
sshDebugLogsEnabled={sshDebugLogsEnabled}
showHostTreeSidebar={settings.showHostTreeSidebar}
showHostTreeSidebar={showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
onStartSessionRename={startSessionRename}
@@ -631,21 +704,18 @@ function AppViewInner({ domains }: AppViewProps) {
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
/>
{/* Log Views - readonly terminal replays */}
{logViews.map(logView => {
// Get the latest log data from connectionLogs to reflect updates
const latestLog = connectionLogs.find(l => l.id === logView.connectionLogId) || logView.log;
return (
<LogViewWrapper
key={logView.id}
logView={{ ...logView, log: latestLog }}
defaultTerminalTheme={currentTerminalTheme}
defaultFontSize={terminalFontSize}
onClose={() => closeLogView(logView.id)}
onUpdateLog={updateConnectionLog}
/>
);
})}
{/* Log Views - readonly terminal replays. The latest log body comes
from connectionLogsStore inside AppLogViewSurface. */}
{logViews.map((logView: any) => (
<AppLogViewSurface
key={logView.id}
LogViewWrapper={LogViewWrapper}
logView={logView}
defaultTerminalTheme={currentTerminalTheme}
defaultFontSize={terminalFontSize}
onClose={() => closeLogView(logView.id)}
/>
))}
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.map((tab) => (
@@ -662,10 +732,7 @@ function AppViewInner({ domains }: AppViewProps) {
</LazyLoadBoundary>
))}
<AppPluginKeybindingHost
locale={settings.uiLanguage}
theme={resolvedTheme}
themeTokens={pluginThemeTokens}
<AppPluginKeybindingThemeHost
sessions={sessions}
workspaces={workspaces}
/>
@@ -756,7 +823,7 @@ function AppViewInner({ domains }: AppViewProps) {
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onEditHost={(host) => {
+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,
+12 -7
View File
@@ -18,12 +18,12 @@ export type ResolveActiveChromeThemeInput = {
customAccent: string;
editorTabs: readonly EditorTabChrome[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
hostById: ReadonlyMap<string, Host>;
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
sessionById: ReadonlyMap<string, TerminalSession>;
themeById: ReadonlyMap<string, TerminalTheme>;
workspaceById: ReadonlyMap<string, Workspace>;
};
export function isActiveChromeThemeResolvable({
@@ -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);
+64
View File
@@ -0,0 +1,64 @@
/**
* Stable accessor bridge for App-local handlers that Host islands need when
* assembling domain bags. `AppSideEffects` registers the live glue via
* `useLayoutEffect`; Hosts call `getAppHandlers()` instead of receiving mega
* props from App.
*
* Keep this intentionally loose (`Record<string, unknown>`): the handler set
* tracks App glue, not a frozen public API.
*/
type Listener = () => void;
export type AppHandlers = Record<string, unknown>;
function handlersShallowEqual(prev: AppHandlers, next: AppHandlers): boolean {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return false;
for (const key of nextKeys) {
if (prev[key] !== next[key]) return false;
}
return true;
}
class AppHandlersBridge {
private handlers: AppHandlers | null = null;
private listeners = new Set<Listener>();
get = (): AppHandlers | null => this.handlers;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: AppHandlers | null): void {
if (this.handlers === next) return;
if (
this.handlers
&& next
&& handlersShallowEqual(this.handlers, next)
) {
return;
}
this.handlers = next;
for (const listener of this.listeners) listener();
}
}
const bridge = new AppHandlersBridge();
export function registerAppHandlers(handlers: AppHandlers | null): void {
bridge.set(handlers);
}
export function getAppHandlers(): AppHandlers | null {
return bridge.get();
}
export function subscribeAppHandlers(listener: Listener): () => void {
return bridge.subscribe(listener);
}
+116
View File
@@ -0,0 +1,116 @@
import { useSyncExternalStore } from 'react';
import type { Host, PortForwardingRule } from '../../domain/models';
import type { VaultSection } from '../../components/VaultView';
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import type { PassphraseRequest } from '../../components/PassphraseModal';
type Listener = () => void;
/**
* Dialog / queue / ephemeral UI owned outside the vault/session/settings
* mega hooks. DialogsHost / VaultHost / TerminalHost subscribe here so
* AppSideEffects never has to rebuild domain bags when a modal opens.
*
* `portForwardingRules` is published here as a thin derived slice: the PF
* hook still lives in AppSideEffects (tray / sync / auto-start), but Hosts
* must not receive a prepared terminal domain bag.
*/
export type AppLocalUiSnapshot = {
isQuickSwitcherOpen: boolean;
isCreateWorkspaceOpen: boolean;
addToWorkspaceDialog:
| { mode: 'append'; workspaceId: string }
| { mode: 'create' }
| null;
quickSearch: string;
protocolSelectHost: Host | null;
navigateToSection: VaultSection | null;
deepLinkHostDraft: Host | null;
ephemeralHosts: readonly Host[];
portForwardingRules: readonly PortForwardingRule[];
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
passphraseQueue: readonly PassphraseRequest[];
deleteHostConfirm: { hostId: string; name: string } | null;
vaultFocusRequest: unknown;
openNoteRequest: unknown;
emptyVaultConflict: unknown;
};
export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
isQuickSwitcherOpen: false,
isCreateWorkspaceOpen: false,
addToWorkspaceDialog: null,
quickSearch: '',
protocolSelectHost: null,
navigateToSection: null,
deepLinkHostDraft: null,
ephemeralHosts: Object.freeze([]) as readonly Host[],
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
deleteHostConfirm: null,
vaultFocusRequest: null,
openNoteRequest: null,
emptyVaultConflict: null,
});
class AppLocalUiStore {
private snapshot: AppLocalUiSnapshot = EMPTY_APP_LOCAL_UI;
private listeners = new Set<Listener>();
getSnapshot = (): AppLocalUiSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AppLocalUiSnapshot): void {
const prev = this.snapshot;
if (
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
&& prev.quickSearch === next.quickSearch
&& prev.protocolSelectHost === next.protocolSelectHost
&& prev.navigateToSection === next.navigateToSection
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
&& prev.ephemeralHosts === next.ephemeralHosts
&& prev.portForwardingRules === next.portForwardingRules
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
&& prev.passphraseQueue === next.passphraseQueue
&& prev.deleteHostConfirm === next.deleteHostConfirm
&& prev.vaultFocusRequest === next.vaultFocusRequest
&& prev.openNoteRequest === next.openNoteRequest
&& prev.emptyVaultConflict === next.emptyVaultConflict
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) listener();
}
}
export const appLocalUiStore = new AppLocalUiStore();
export function publishAppLocalUi(snapshot: AppLocalUiSnapshot): void {
appLocalUiStore.setSnapshot(snapshot);
}
export function getAppLocalUiSnapshot(): AppLocalUiSnapshot {
return appLocalUiStore.getSnapshot();
}
export function subscribeAppLocalUi(listener: Listener): () => void {
return appLocalUiStore.subscribe(listener);
}
export function useAppLocalUiStore(): AppLocalUiSnapshot {
return useSyncExternalStore(
subscribeAppLocalUi,
getAppLocalUiSnapshot,
getAppLocalUiSnapshot,
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useSyncExternalStore } from 'react';
import type { KnownHost } from '../../types';
import type { AppActiveTabChromeProps } from './AppActiveTabChrome';
import { appViewDomainsEqual, type AppViewDomains } from './appViewDomains';
type Listener = () => void;
export type AppShellOverlays = {
onAddKnownHost: (knownHost: KnownHost) => void;
deleteHostConfirm: { hostId: string; name: string } | null;
onCancelDeleteHost: () => void;
onConfirmDeleteHost: () => void;
};
export type AppShellPropsSnapshot = {
domains: AppViewDomains | null;
chrome: AppActiveTabChromeProps | null;
overlays: AppShellOverlays | null;
};
const EMPTY: AppShellPropsSnapshot = Object.freeze({
domains: null,
chrome: null,
overlays: null,
});
/**
* Host islands publish domain / chrome / overlay bags here. `AppShell`
* subscribes via `useSyncExternalStore` and only re-renders when bag
* identities change (`appViewDomainsEqual` + chrome/overlays identity).
*/
class AppShellPropsStore {
private snapshot: AppShellPropsSnapshot = EMPTY;
private listeners = new Set<Listener>();
getSnapshot = (): AppShellPropsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setDomains(domains: AppViewDomains): void {
const prev = this.snapshot;
if (prev.domains && appViewDomainsEqual(prev.domains, domains)) {
return;
}
this.snapshot = { ...prev, domains };
for (const listener of this.listeners) listener();
}
setChrome(chrome: AppActiveTabChromeProps): void {
if (this.snapshot.chrome === chrome) return;
this.snapshot = { ...this.snapshot, chrome };
for (const listener of this.listeners) listener();
}
setOverlays(overlays: AppShellOverlays): void {
if (this.snapshot.overlays === overlays) return;
this.snapshot = { ...this.snapshot, overlays };
for (const listener of this.listeners) listener();
}
setDomainSlice<K extends keyof AppViewDomains>(
key: K,
slice: AppViewDomains[K],
): void {
const prev = this.snapshot.domains;
if (prev && prev[key] === slice) return;
const domains = {
vault: prev?.vault ?? {},
terminal: prev?.terminal ?? {},
chrome: prev?.chrome ?? {},
dialogs: prev?.dialogs ?? {},
mounts: prev?.mounts ?? {},
[key]: slice,
} as AppViewDomains;
this.setDomains(domains);
}
}
export const appShellPropsStore = new AppShellPropsStore();
export function publishAppShellDomains(domains: AppViewDomains): void {
appShellPropsStore.setDomains(domains);
}
export function publishAppShellDomainSlice<K extends keyof AppViewDomains>(
key: K,
slice: AppViewDomains[K],
): void {
appShellPropsStore.setDomainSlice(key, slice);
}
export function publishAppShellChrome(chrome: AppActiveTabChromeProps): void {
appShellPropsStore.setChrome(chrome);
}
export function publishAppShellOverlays(overlays: AppShellOverlays): void {
appShellPropsStore.setOverlays(overlays);
}
export function getAppShellPropsSnapshot(): AppShellPropsSnapshot {
return appShellPropsStore.getSnapshot();
}
export function subscribeAppShellProps(listener: Listener): () => void {
return appShellPropsStore.subscribe(listener);
}
export function useAppShellProps(): AppShellPropsSnapshot {
return useSyncExternalStore(
subscribeAppShellProps,
getAppShellPropsSnapshot,
getAppShellPropsSnapshot,
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { TERMINAL_THEMES } from '../../../infrastructure/config/terminalThemes';
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
import { useI18n } from '../../i18n/I18nProvider';
import { useCustomThemes } from '../../state/customThemeStore';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { toEditorTabId } from '../../state/activeTabStore';
import {
getSessionSnapshotActions,
useSessionSnapshot,
useSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import { useSettingsChromeStore } from '../../state/settingsChromeStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import {
getTerminalSettingsActions,
useTerminalSettingsStore,
} from '../../state/terminalSettingsStore';
import { usePluginViewTabs } from '../../state/pluginViewTabStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellChrome,
publishAppShellDomainSlice,
} from '../appShellPropsStore';
import {
getThemeRuntimeActions,
subscribeThemeRuntimeActions,
} from '../themeRuntimeBridge';
const IS_MAC_CLIENT =
typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
/**
* Chrome island: TopTabs / active-tab chrome from settings chrome store,
* appearance chrome, and selective session/vault snapshot fields. Assembles
* chrome + shell chrome bags field-by-field — never spreads a prepared bag.
*/
export function ChromeHost() {
const { t } = useI18n();
const settingsChrome = useSettingsChromeStore();
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const terminalSettings = useTerminalSettingsStore();
const editorTabs = useEditorTabChromeList();
const pluginViewTabs = usePluginViewTabs();
void pluginViewTabs;
const customThemes = useCustomThemes();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const themeRuntime = useSyncExternalStore(
subscribeThemeRuntimeActions,
getThemeRuntimeActions,
getThemeRuntimeActions,
);
const orphanSessionsForShellRef = useRef(session.orphanSessions);
const orphanSessionsForShell = retainStableSessionsIgnoringPresentation(
orphanSessionsForShellRef.current,
session.orphanSessions as never,
);
orphanSessionsForShellRef.current = orphanSessionsForShell as typeof session.orphanSessions;
const themeById = useMemo(
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
const hostById = useMemo(
() => new Map(vault.hosts.map((host) => [host.id, host])),
[vault.hosts],
);
const sessionById = useMemo(
() => new Map(session.sessions.map((s) => [s.id, s])),
[session.sessions],
);
const workspaceById = useMemo(
() => new Map(session.workspaces.map((workspace) => [workspace.id, workspace])),
[session.workspaces],
);
const editorTabTopIds = useMemo(
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
[editorTabs],
);
const pluginViewTabIds = useMemo(
() => pluginViewTabs.map((tab) => tab.id),
[pluginViewTabs],
);
const additionalWorkTabIds = useMemo(
() => [...editorTabTopIds, ...pluginViewTabIds],
[editorTabTopIds, pluginViewTabIds],
);
const orderedTabsWithEditors = useMemo(
() => sessionActions?.getOrderedWorkTabs(additionalWorkTabIds) ?? ['vault'],
[additionalWorkTabIds, sessionActions],
);
const reorderWorkTabs = useCallback((
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
) => {
sessionActions?.reorderTabs(draggedId, targetId, position, additionalWorkTabIds);
}, [additionalWorkTabIds, sessionActions]);
const chromeDomain = useMemo(() => {
if (!handlers) return null;
return {
closeLogView: sessionActions?.closeLogView,
handleEndSessionDrag: handlers.handleEndSessionDrag,
handleOpenQuickSwitcher: handlers.handleOpenQuickSwitcher,
handleOpenSettings: handlers.handleOpenSettings,
handleRootContextMenu: handlers.handleRootContextMenu,
handleSyncNowManual: handlers.handleSyncNowManual,
isMacClient: IS_MAC_CLIENT,
logViews: session.logViews,
openLogView: sessionActions?.openLogView,
orderedTabsWithEditors,
orphanSessions: orphanSessionsForShell,
reorderWorkTabs,
resetSessionRename: sessionActions?.resetSessionRename,
resetWorkspaceRename: sessionActions?.resetWorkspaceRename,
sessionRenameTarget: session.sessionRenameTarget,
setActiveTabId: sessionActions?.setActiveTabId,
startSessionRename: sessionActions?.startSessionRename,
renameSessionInline: sessionActions?.renameSessionInline,
startWorkspaceRename: sessionActions?.startWorkspaceRename,
submitSessionRename: sessionActions?.submitSessionRename,
submitWorkspaceRename: sessionActions?.submitWorkspaceRename,
t,
themeById,
workspaceRenameTarget: session.workspaceRenameTarget,
};
}, [
handlers,
orderedTabsWithEditors,
orphanSessionsForShell,
reorderWorkTabs,
session.logViews,
session.sessionRenameTarget,
session.workspaceRenameTarget,
sessionActions,
t,
themeById,
]);
// Call-time getters so chrome can publish before Publisher layout effects
// register action slots — avoids undefined applyAppTheme/setActiveTabId on
// the first Host publish (startup TypeError under StrictMode).
const setActiveTabId = useCallback((id: string) => {
getSessionSnapshotActions()?.setActiveTabId?.(id);
}, []);
const applyAppTheme = useCallback(() => {
getTerminalSettingsActions()?.applyAppTheme?.();
}, []);
const appShellChrome = useMemo(() => {
if (!handlers) return null;
// Theme runtime actions may still be null on the first paint before
// TerminalHost registers them; wait so AppActiveTabChrome never mounts
// with a missing resolveSessionAppearance / currentTerminalTheme.
if (!themeRuntime?.currentTerminalTheme || !themeRuntime?.resolveFocusedAppearance) {
return null;
}
return {
showSftpTab: settingsChrome.showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
themeById,
workspaceById,
currentTerminalTheme: themeRuntime.currentTerminalTheme,
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
editorTabs,
logViews: session.logViews,
resolveSessionAppearance: themeRuntime.resolveFocusedAppearance,
t,
};
}, [
applyAppTheme,
editorTabs,
handlers,
hostById,
session.logViews,
sessionById,
setActiveTabId,
settingsChrome.showSftpTab,
t,
terminalSettings.followAppTerminalTheme,
themeById,
themeRuntime,
workspaceById,
]);
useLayoutEffect(() => {
if (chromeDomain) publishAppShellDomainSlice('chrome', chromeDomain);
if (appShellChrome) publishAppShellChrome(appShellChrome as never);
}, [appShellChrome, chromeDomain]);
return null;
}
+107
View File
@@ -0,0 +1,107 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getHostSearchMatch } from '../../../lib/searchMatcher';
import type { Host } from '../../../types';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellDomainSlice,
publishAppShellOverlays,
} from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
const EMPTY_HOST_RESULTS: Host[] = [];
/**
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
* selective vault hosts subscription for quick-search results when open.
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
*/
export function DialogsHost() {
const local = useAppLocalUiStore();
const vault = useVaultSnapshot();
const editorTabs = useEditorTabChromeList();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const quickResults = useMemo(() => {
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
const term = local.quickSearch.trim();
if (!term) return vault.hosts as Host[];
return (vault.hosts as Host[])
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
const dialogsDomain = useMemo(() => {
if (!handlers) return null;
return {
addToWorkspaceDialog: local.addToWorkspaceDialog,
clearAndRemoveSource: handlers.clearAndRemoveSource,
clearAndRemoveSources: handlers.clearAndRemoveSources,
editorTabs,
emptyVaultConflict: local.emptyVaultConflict,
handleHostConnectWithProtocolCheck: handlers.handleHostConnectWithProtocolCheck,
handleKeyboardInteractiveCancel: handlers.handleKeyboardInteractiveCancel,
handleKeyboardInteractiveSubmit: handlers.handleKeyboardInteractiveSubmit,
handlePassphraseCancel: handlers.handlePassphraseCancel,
handlePassphraseSkip: handlers.handlePassphraseSkip,
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
handleProtocolSelect: handlers.handleProtocolSelect,
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
passphraseQueue: local.passphraseQueue,
protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
setProtocolSelectHost: handlers.setProtocolSelectHost,
setQuickSearch: handlers.setQuickSearch,
};
}, [
editorTabs,
handlers,
local.addToWorkspaceDialog,
local.emptyVaultConflict,
local.isCreateWorkspaceOpen,
local.isQuickSwitcherOpen,
local.keyboardInteractiveQueue,
local.passphraseQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
]);
const overlays = useMemo(() => {
if (!handlers) return null;
return {
onAddKnownHost: handlers.handleAddKnownHost as (knownHost: never) => void,
deleteHostConfirm: local.deleteHostConfirm,
onCancelDeleteHost: handlers.handleCancelDeleteHost as () => void,
onConfirmDeleteHost: handlers.handleConfirmDeleteHost as () => void,
};
}, [handlers, local.deleteHostConfirm]);
useLayoutEffect(() => {
if (dialogsDomain) publishAppShellDomainSlice('dialogs', dialogsDomain);
if (overlays) publishAppShellOverlays(overlays as never);
}, [dialogsDomain, overlays]);
return null;
}
+279
View File
@@ -0,0 +1,279 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { TERMINAL_THEME_AUTO } from '../../../domain/terminalAppearance';
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
import { getAppSettingsRuntime } from '../../state/appRuntimeBridge';
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
import { useCustomThemes } from '../../state/customThemeStore';
import {
useSessionSnapshot,
useSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../../state/settingsChromeStore';
import {
useTerminalSettingsActions,
useTerminalSettingsStore,
} from '../../state/terminalSettingsStore';
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
import { registerThemeRuntimeActions } from '../themeRuntimeBridge';
/**
* Terminal island: sessions from `sessionSnapshotStore`, terminal settings
* from `terminalSettingsStore`, mutators via snapshot actions, theme runtime
* owned here, glue handlers from the app handlers bridge. Assembles the full
* terminal domain bag field-by-field — never spreads a prepared bag.
*/
export function TerminalHost() {
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const vaultActions = useVaultSnapshotActions();
const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions();
const {
followAppTerminalTheme,
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
} = terminalSettings;
const settingsChrome = useSettingsChromeStore();
const settingsChromeActions = useSettingsChromeActions();
const appearance = useAppearanceChromeStore();
const customThemes = useCustomThemes();
const local = useAppLocalUiStore();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
// Call-time getters: SettingsPublisher registers the runtime in a layout
// effect after this Host's first render. Capturing noop setters into
// useThemeRuntime would permanently drop follow-app UI theme persistence.
const setLightUiThemeId = useCallback((id: string) => {
getAppSettingsRuntime()?.setLightUiThemeId?.(id);
}, []);
const setDarkUiThemeId = useCallback((id: string) => {
getAppSettingsRuntime()?.setDarkUiThemeId?.(id);
}, []);
const themeRuntime = useThemeRuntime({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
resolvedTheme: settingsChrome.resolvedTheme,
lightUiThemeId: settingsChrome.lightUiThemeId,
darkUiThemeId: settingsChrome.darkUiThemeId,
accentMode: appearance.accentMode,
customAccent: appearance.customAccent,
customThemes,
setTheme: settingsChromeActions.setTheme,
setLightUiThemeId,
setDarkUiThemeId,
});
const {
globalAppearance,
accentedGlobalAppearance,
clearIntent: clearThemeIntent,
settleManualIntent: settleManualThemeIntent,
pickTheme: pickTerminalTheme,
resolveFocusedAppearance,
currentTerminalTheme,
} = themeRuntime;
// 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,
});
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
useLayoutEffect(() => {
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
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,
pickTerminalTheme,
resolveFocusedAppearance: resolveFocusedAppearance as (...args: never[]) => unknown,
currentTerminalTheme,
globalAppearance,
}), [
clearThemeIntent,
currentTerminalTheme,
globalAppearance,
pickTerminalTheme,
resolveFocusedAppearance,
settleManualThemeIntent,
]);
useLayoutEffect(() => {
registerThemeRuntimeActions(themeBridgeActions);
return () => {
registerThemeRuntimeActions(null);
};
}, [themeBridgeActions]);
const sessionsForShellRef = useRef(session.sessions);
const sessionsForShell = retainStableSessionsIgnoringPresentation(
sessionsForShellRef.current,
session.sessions as never,
);
sessionsForShellRef.current = sessionsForShell as typeof session.sessions;
const hostById = useMemo(
() => new Map(vault.hosts.map((host) => [host.id, host])),
[vault.hosts],
);
const terminalHosts = useMemo(
() => (
local.ephemeralHosts.length > 0
? [...vault.hosts, ...local.ephemeralHosts]
: vault.hosts
),
[local.ephemeralHosts, vault.hosts],
);
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
// Persist the default theme for ephemeral/manual hosts. Mode overrides
// reset to auto so the chosen theme becomes the new baseline for the
// current resolved UI mode (same behavior as pre-Host App).
terminalSettingsActions?.setTerminalThemeId(themeId);
if (settingsChrome.resolvedTheme === 'dark') {
terminalSettingsActions?.setTerminalThemeDarkId(TERMINAL_THEME_AUTO);
} else {
terminalSettingsActions?.setTerminalThemeLightId(TERMINAL_THEME_AUTO);
}
}, [settingsChrome.resolvedTheme, terminalSettingsActions]);
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
pickTerminalTheme(themeId);
}, [pickTerminalTheme]);
const terminalDomain = useMemo(() => {
if (!handlers) return null;
return {
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
closeSession: sessionActions?.closeSession,
closeTabsBatch: handlers.closeTabsBatch,
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
closeWorkspace: sessionActions?.closeWorkspace,
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
createWorkspaceWithHosts: handlers.createWorkspaceWithHosts,
currentTerminalTheme,
draggingSessionId: session.draggingSessionId,
editorWordWrap: terminalSettings.editorWordWrap,
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
clearThemeIntent,
settleManualThemeIntent,
pickTerminalTheme,
resolveSessionAppearance: resolveFocusedAppearance,
handleConnectSerial: handlers.handleConnectSerial,
handleConnectToHost: handlers.handleConnectToHost,
handleCreateLocalTerminal: handlers.handleCreateLocalTerminal,
handleDefaultTerminalThemeChange,
handleFollowAppTerminalThemeChange,
handleHotkeyAction: handlers.handleHotkeyAction,
handleSessionStatusChange: handlers.handleSessionStatusChange,
handleTerminalDataCapture: handlers.handleTerminalDataCapture,
handleUpdateHostFromTerminal: handlers.handleUpdateHostFromTerminal,
hostById,
terminalHosts,
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
portForwardingRules: local.portForwardingRules,
removeSessionFromWorkspace: sessionActions?.removeSessionFromWorkspace,
reorderWorkspaceSessions: sessionActions?.reorderWorkspaceSessions,
runSnippet: handlers.runSnippet,
sessionLogsDir: terminalSettings.sessionLogsDir,
sessionLogsEnabled: terminalSettings.sessionLogsEnabled,
sessionLogsFormat: terminalSettings.sessionLogsFormat,
sessionLogsTimestampsEnabled: terminalSettings.sessionLogsTimestampsEnabled,
sessions: sessionsForShell,
setDraggingSessionId: sessionActions?.setDraggingSessionId,
setEditorWordWrap: terminalSettingsActions?.setEditorWordWrap,
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
sftpAutoSync: terminalSettings.sftpAutoSync,
sftpDefaultViewMode: terminalSettings.sftpDefaultViewMode,
sftpDoubleClickBehavior: terminalSettings.sftpDoubleClickBehavior,
sftpShowHiddenFiles: terminalSettings.sftpShowHiddenFiles,
sftpUseCompressedUpload: terminalSettings.sftpUseCompressedUpload,
splitSessionWithCurrentShell: handlers.splitSessionWithCurrentShell,
sshDebugLogsEnabled: terminalSettings.sshDebugLogsEnabled,
terminalFontFamilyId: terminalSettings.terminalFontFamilyId,
terminalFontSize: terminalSettings.terminalFontSize,
terminalSettings: terminalSettings.terminalSettings,
terminalThemeId: terminalSettings.terminalThemeId,
toggleBroadcast: sessionActions?.toggleBroadcast,
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
toggleSidePanelRef: handlers.toggleSidePanelRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: vaultActions?.updateHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
updateSessionDynamicTitle: sessionActions?.updateSessionDynamicTitle,
updateSessionCodingCliProvider: sessionActions?.updateSessionCodingCliProvider,
updateTerminalSetting: terminalSettingsActions?.updateTerminalSetting,
workspaces: session.workspaces,
};
}, [
clearThemeIntent,
currentTerminalTheme,
handleDefaultTerminalThemeChange,
handleFollowAppTerminalThemeChange,
handlers,
hostById,
local.openNoteRequest,
local.portForwardingRules,
pickTerminalTheme,
resolveFocusedAppearance,
session.draggingSessionId,
session.workspaces,
sessionActions,
sessionsForShell,
settleManualThemeIntent,
terminalHosts,
terminalSettings,
terminalSettingsActions,
vaultActions,
]);
useLayoutEffect(() => {
if (terminalDomain) publishAppShellDomainSlice('terminal', terminalDomain);
}, [terminalDomain]);
return null;
}
+104
View File
@@ -0,0 +1,104 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
import { APP_MOUNTS_DOMAIN } from './mountsDomain';
/**
* Vault island: catalog from `vaultSnapshotStore`, glue handlers from the
* app handlers bridge, local vault UI from `appLocalUiStore`. Assembles the
* full vault domain bag field-by-field — never spreads a prepared bag from
* AppSideEffects.
*/
export function VaultHost() {
const vault = useVaultSnapshot();
const actions = useVaultSnapshotActions();
const local = useAppLocalUiStore();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(vault.knownHosts as never) ?? [],
[vault.knownHosts],
);
const vaultDomain = useMemo(() => {
if (!handlers) return null;
return {
addShellHistoryEntry: actions?.addShellHistoryEntry,
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
convertKnownHostToHost: actions?.convertKnownHostToHost,
customGroups: vault.customGroups,
deepLinkHostDraft: local.deepLinkHostDraft,
effectiveKnownHosts,
groupConfigs: vault.groupConfigs,
handleAddKnownHost: handlers.handleAddKnownHost,
handleDeleteHost: handlers.handleDeleteHost,
handleOpenHostFromVaultNote: handlers.handleOpenHostFromVaultNote,
handleOpenVaultHostFromChat: handlers.handleOpenVaultHostFromChat,
handleOpenVaultNoteFromChat: handlers.handleOpenVaultNoteFromChat,
handleOpenVaultSectionFromChat: handlers.handleOpenVaultSectionFromChat,
handleOpenVaultSnippetFromChat: handlers.handleOpenVaultSnippetFromChat,
hosts: vault.hosts,
identities: vault.identities,
importOrReuseKey: actions?.importOrReuseKey,
keys: vault.keys,
managedSources: vault.managedSources,
navigateToSection: local.navigateToSection,
proxyProfiles: vault.proxyProfiles,
readPersistedHosts: actions?.readPersistedHosts,
readPersistedManagedSources: actions?.readPersistedManagedSources,
setDeepLinkHostDraft: handlers.setDeepLinkHostDraft,
setNavigateToSection: handlers.setNavigateToSection,
setVaultFocusRequest: handlers.setVaultFocusRequest,
snippetPackages: vault.snippetPackages,
snippets: vault.snippets,
unmanageSource: handlers.unmanageSource,
updateCustomGroups: actions?.updateCustomGroups,
updateGroupConfigs: actions?.updateGroupConfigs,
updateHosts: actions?.updateHosts,
updateIdentities: actions?.updateIdentities,
updateKeys: actions?.updateKeys,
updateKnownHosts: actions?.updateKnownHosts,
updateManagedSources: actions?.updateManagedSources,
updateProxyProfiles: actions?.updateProxyProfiles,
updateSnippetPackages: actions?.updateSnippetPackages,
updateSnippets: actions?.updateSnippets,
vaultFocusRequest: local.vaultFocusRequest,
};
}, [
actions,
effectiveKnownHosts,
handlers,
local.deepLinkHostDraft,
local.navigateToSection,
local.vaultFocusRequest,
vault.customGroups,
vault.groupConfigs,
vault.hosts,
vault.identities,
vault.keys,
vault.managedSources,
vault.proxyProfiles,
vault.snippetPackages,
vault.snippets,
]);
useLayoutEffect(() => {
if (vaultDomain) publishAppShellDomainSlice('vault', vaultDomain);
publishAppShellDomainSlice('mounts', APP_MOUNTS_DOMAIN);
}, [vaultDomain]);
return null;
}
+9
View File
@@ -0,0 +1,9 @@
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from '../AppMounts';
/** Lazy mount wrappers — stable module identity for the app lifetime. */
export const APP_MOUNTS_DOMAIN = Object.freeze({
VaultViewContainer,
SftpViewMount,
TerminalLayerMount,
LogViewWrapper,
});
@@ -0,0 +1,217 @@
import { useLayoutEffect, useMemo } from 'react';
import {
AppSessionRuntimeContext,
registerAppSessionRuntime,
} from '../../state/appRuntimeBridge';
import {
publishSessionSnapshot,
registerSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import { useSessionState } from '../../state/useSessionState';
import type { ReactNode } from 'react';
export type SessionPublisherProps = {
/** Peer session windows must not write the main window's restore record. */
persistSessionRestore: boolean;
children?: ReactNode;
};
/**
* Owns `useSessionState` and publishes it the same three ways `VaultPublisher`
* publishes the vault: catalog into `sessionSnapshotStore`, mutators into its
* action slot, and the whole runtime onto `appRuntimeBridge` for App.
*/
export function SessionPublisher({ persistSessionRestore, children }: SessionPublisherProps) {
const session = useSessionState({ persistSessionRestore });
const {
sessions,
orphanSessions,
workspaces,
logViews,
draggingSessionId,
sessionRenameTarget,
workspaceRenameTarget,
setActiveTabId,
closeSession,
closeSessions,
closeWorkspace,
openLogView,
closeLogView,
setDraggingSessionId,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
connectToHost,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
splitSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
copySession,
copyWorkspace,
createSessionFromCloneSource,
updateSessionRestoreCwd,
getSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
} = session;
useLayoutEffect(() => {
registerAppSessionRuntime(session);
}, [session]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppSessionRuntime(null);
}, []);
useLayoutEffect(() => {
publishSessionSnapshot({
sessions,
orphanSessions,
workspaces,
logViews,
draggingSessionId,
sessionRenameTarget,
workspaceRenameTarget,
});
}, [
draggingSessionId,
logViews,
orphanSessions,
sessionRenameTarget,
sessions,
workspaceRenameTarget,
workspaces,
]);
const sessionActions = useMemo(() => ({
setActiveTabId,
closeSession,
closeSessions,
closeWorkspace,
openLogView,
closeLogView,
setDraggingSessionId,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
connectToHost,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
splitSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
copySession,
copyWorkspace,
createSessionFromCloneSource,
updateSessionRestoreCwd,
getSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
}), [
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride,
closeLogView,
closeSession,
closeSessions,
closeWorkspace,
connectToHost,
copySession,
copyWorkspace,
createLocalTerminal,
createSerialSession,
createSessionFromCloneSource,
createWorkspaceFromSessions,
createWorkspaceFromTargets,
createWorkspaceWithHosts,
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
moveFocusInWorkspace,
openLogView,
removeSessionFromWorkspace,
renameSessionInline,
reorderTabs,
reorderWorkspaceSessions,
resetSessionRename,
resetWorkspaceRename,
runSnippet,
setActiveTabId,
setDraggingSessionId,
setWorkspaceFocusedSession,
splitSession,
startSessionRename,
startWorkspaceRename,
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
updateSessionFontSize,
updateSessionRestoreCwd,
updateSessionStatus,
updateSplitSizes,
]);
useLayoutEffect(() => {
registerSessionSnapshotActions(sessionActions);
return () => {
registerSessionSnapshotActions(null);
};
}, [sessionActions]);
return (
<AppSessionRuntimeContext.Provider value={session}>
{children}
</AppSessionRuntimeContext.Provider>
);
}
@@ -0,0 +1,47 @@
import { useLayoutEffect, type ReactNode } from 'react';
import {
AppSettingsRuntimeContext,
registerAppSettingsRuntime,
} from '../../state/appRuntimeBridge';
import { useSettingsState } from '../../state/useSettingsState';
export type SettingsPublisherProps = {
/** Peer session windows must not drive the main window's settings IPC sync. */
enableSettingsSync: boolean;
/** Peer session windows must not re-apply OS-level system settings effects. */
enableSystemEffects: boolean;
children?: ReactNode;
};
/**
* Owns `useSettingsState` and hands it to consumers the same way
* `VaultPublisher` / `SessionPublisher` hand over their runtimes: a context for
* render-time reads and the `appRuntimeBridge` slot for imperative callers.
*
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
* happens inside `useSettingsState`, so this publisher only relocates the hook
* out of the component that also builds the shell's domain bags.
*/
export function SettingsPublisher({
enableSettingsSync,
enableSystemEffects,
children,
}: SettingsPublisherProps) {
const settings = useSettingsState({ enableSettingsSync, enableSystemEffects });
useLayoutEffect(() => {
registerAppSettingsRuntime(settings);
}, [settings]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppSettingsRuntime(null);
}, []);
return (
<AppSettingsRuntimeContext.Provider value={settings}>
{children}
</AppSettingsRuntimeContext.Provider>
);
}
@@ -0,0 +1,197 @@
import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
import {
AppVaultRuntimeContext,
registerAppVaultRuntime,
type AppVaultContextValue,
} from '../../state/appRuntimeBridge';
import {
publishVaultSnapshot,
registerVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { useVaultState } from '../../state/useVaultState';
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
export type VaultPublisherProps = {
children?: ReactNode;
};
function vaultContextValuesEqual(
prev: AppVaultContextValue,
next: AppVaultContextValue,
): boolean {
const keys = Object.keys(next) as Array<keyof AppVaultContextValue>;
return keys.every((key) => prev[key] === next[key]);
}
/**
* Owns `useVaultState` and publishes it three ways: the catalog into
* `vaultSnapshotStore` for shell surfaces that subscribe to a slice, the
* mutators into the same store's action slot, and a **catalog-only** runtime
* onto `AppVaultRuntimeContext` for App.
*
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are intentionally
* absent from the context value: they churn on a different cadence and already
* fan out through dedicated stores. Including them would force App (the
* domain-bag builder) to re-render on every note edit or session log append.
* Imperative callers that still need the full hook return use
* `getAppVaultRuntime()`.
*/
export function VaultPublisher({ children }: VaultPublisherProps) {
const vault = useVaultState();
const {
isInitialized,
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
knownHosts,
managedSources,
groupConfigs,
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
convertKnownHostToHost,
readPersistedHosts,
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
} = vault;
useLayoutEffect(() => {
registerAppVaultRuntime(vault);
}, [vault]);
// Only a real unmount clears the slot. A re-render re-registers through the
// effect above, and StrictMode's simulated remount re-runs both.
useLayoutEffect(() => () => {
registerAppVaultRuntime(null);
}, []);
// useVaultState decrypts hosts/keys before it reads known hosts, so the state
// is briefly empty at boot even when storage has entries. Publish the same
// storage fallback App uses so a subscriber connecting during that window
// does not re-prompt for a fingerprint it already trusts.
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
useLayoutEffect(() => {
publishVaultSnapshot({
isVaultInitialized: isInitialized,
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
knownHosts: effectiveKnownHosts,
managedSources,
groupConfigs,
});
}, [
customGroups,
effectiveKnownHosts,
groupConfigs,
hosts,
identities,
isInitialized,
keys,
managedSources,
proxyProfiles,
snippetPackages,
snippets,
]);
useLayoutEffect(() => {
registerVaultSnapshotActions({
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
convertKnownHostToHost,
readPersistedHosts,
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
});
return () => {
registerVaultSnapshotActions(null);
};
}, [
addShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
convertKnownHostToHost,
importOrReuseKey,
readPersistedHosts,
readPersistedManagedSources,
removeShellHistoryEntry,
updateCustomGroups,
updateGroupConfigs,
updateHostDistro,
updateHostLastConnected,
updateHosts,
updateIdentities,
updateKeys,
updateKnownHosts,
updateManagedSources,
updateProxyProfiles,
updateSnippetPackages,
updateSnippets,
]);
// Strip high-churn fields, then retain the previous object identity when only
// those fields (or exportData) changed so React context consumers stay quiet.
const vaultForAppRef = useRef<AppVaultContextValue | null>(null);
const vaultForApp = useMemo((): AppVaultContextValue => {
const {
notes: _notes,
noteGroups: _noteGroups,
connectionLogs: _connectionLogs,
shellHistory: _shellHistory,
exportData: _exportData,
...catalog
} = vault;
const prev = vaultForAppRef.current;
if (prev && vaultContextValuesEqual(prev, catalog)) {
return prev;
}
vaultForAppRef.current = catalog;
return catalog;
}, [vault]);
return (
<AppVaultRuntimeContext.Provider value={vaultForApp}>
{children}
</AppVaultRuntimeContext.Provider>
);
}
@@ -0,0 +1,271 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
const appSideEffectsSource = readFileSync(
new URL('./AppSideEffects.tsx', import.meta.url),
'utf8',
);
const indexSource = readFileSync(new URL('../../index.tsx', import.meta.url), 'utf8');
const startupEffectsSource = readFileSync(
new URL('./useAppStartupEffects.ts', import.meta.url),
'utf8',
);
const updateCheckSource = readFileSync(
new URL('../state/useUpdateCheck.ts', import.meta.url),
'utf8',
);
const portForwardingAutoStartSource = readFileSync(
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
'utf8',
);
test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
let cursor = 0;
for (let index = 0; index < renderCalls.length; index += 1) {
const renderAt = indexSource.indexOf('root.render(', cursor);
assert.notEqual(renderAt, -1);
const opener = indexSource.slice(renderAt, renderAt + 60);
assert.match(opener, /root\.render\(\s*<StrictMode>/, `root.render #${index + 1} lacks StrictMode`);
cursor = renderAt + 'root.render('.length;
}
});
test('clone-session payload is consumed once even if the effect re-runs', () => {
const effectStart = appSideEffectsSource.indexOf('consumedNewWindowSessionRef');
assert.notEqual(effectStart, -1, 'clone-session effect must latch on payload identity');
const effectEnd = appSideEffectsSource.indexOf(
'}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);',
effectStart,
);
assert.notEqual(effectEnd, -1);
const body = appSideEffectsSource.slice(effectStart, effectEnd);
// A ref comparison is required: clearing the state only lands on the next
// render, so a re-invoked effect still closes over the same payload.
assert.match(body, /if \(consumedNewWindowSessionRef\.current === pendingNewWindowSession\) return;/);
assert.ok(
body.indexOf('consumedNewWindowSessionRef.current = pending')
< body.indexOf('createSessionFromCloneSource(pending.sourceSession'),
'payload must be marked consumed before the clone is created',
);
});
test('rendererReady is notified once per renderer process', () => {
assert.match(appSource, /^let rendererReadySent = false;$/m);
const guardAt = appSource.indexOf('if (!rendererReadySent) {');
assert.notEqual(guardAt, -1);
const guarded = appSource.slice(guardAt, guardAt + 200);
assert.match(guarded, /rendererReadySent = true;/);
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
assert.ok(
guarded.indexOf('rendererReadySent = true;')
< guarded.indexOf('netcattyBridge.get()?.rendererReady?.()'),
'the latch must be set before the IPC call so a re-entrant effect is blocked',
);
});
test('update-available toast latches on the release version', () => {
const latchAt = startupEffectsSource.indexOf('toastedUpdateVersionRef');
assert.notEqual(latchAt, -1);
assert.match(startupEffectsSource, /const toastedUpdateVersionRef = useRef<string \| null>\(null\)/);
assert.match(startupEffectsSource, /if \(toastedUpdateVersionRef\.current === version\) return;/);
const guardAt = startupEffectsSource.indexOf('if (toastedUpdateVersionRef.current === version) return;');
const toastAt = startupEffectsSource.indexOf('toast.info(', guardAt);
assert.notEqual(toastAt, -1);
assert.ok(guardAt < toastAt, 'the version latch must gate the toast call');
});
test('port-forward auto-start runs once across a StrictMode double effect', () => {
assert.match(
portForwardingAutoStartSource,
/const autoStartExecutedRef = useRef\(false\);/,
'the launch auto-start needs a module-render latch, not just an effect dep list',
);
const effectStart = portForwardingAutoStartSource.indexOf('if (autoStartExecutedRef.current) return;');
assert.notEqual(effectStart, -1, 'the effect must bail out when the latch is already set');
const effectEnd = portForwardingAutoStartSource.indexOf(
'}, [\n enabled,\n isVaultInitialized,\n runAutoStart,\n ]);',
effectStart,
);
assert.notEqual(effectEnd, -1, 'auto-start effect dep list moved; update this contract');
const body = portForwardingAutoStartSource.slice(effectStart, effectEnd);
// StrictMode invokes the effect twice with the same render's closure, so the
// latch has to be written before the async run is kicked off — awaiting or
// deferring the write would let the second invoke start a duplicate tunnel.
const latchAt = body.indexOf('autoStartExecutedRef.current = true;');
const runAt = body.indexOf('void runAutoStart();');
assert.notEqual(latchAt, -1);
assert.notEqual(runAt, -1);
assert.ok(latchAt < runAt, 'the latch must be set before runAutoStart() is called');
// The vault gate must also sit before the latch: latching on a pre-hydration
// invoke would permanently suppress the real auto-start.
assert.ok(
body.indexOf('if (!isVaultInitialized) return;') < latchAt,
'the vault gate must precede the latch write',
);
});
test('cancelled startup update check resets its latch instead of skipping forever', () => {
const scheduleAt = updateCheckSource.indexOf('let checkArmed = true;');
assert.notEqual(scheduleAt, -1);
const tail = updateCheckSource.slice(scheduleAt);
// The latch is only meaningful once the timer actually fires; a cleanup that
// cancels it beforehand must let the next effect schedule again.
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',
);
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Theme runtime actions produced by TerminalHost (`useThemeRuntime`).
* AppSideEffects handlers (default/follow theme changes) call through this
* bridge instead of co-hosting the hook.
*/
type Listener = () => void;
export type ThemeRuntimeBridgeActions = {
clearThemeIntent: () => void;
settleManualThemeIntent: () => void;
pickTerminalTheme: (themeId: string) => void;
resolveFocusedAppearance: (...args: never[]) => unknown;
currentTerminalTheme: unknown;
globalAppearance: unknown;
};
class ThemeRuntimeBridge {
private actions: ThemeRuntimeBridgeActions | null = null;
private listeners = new Set<Listener>();
get = (): ThemeRuntimeBridgeActions | null => this.actions;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: ThemeRuntimeBridgeActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.listeners) listener();
}
}
const bridge = new ThemeRuntimeBridge();
export function registerThemeRuntimeActions(
actions: ThemeRuntimeBridgeActions | null,
): void {
bridge.set(actions);
}
export function getThemeRuntimeActions(): ThemeRuntimeBridgeActions | null {
return bridge.get();
}
export function subscribeThemeRuntimeActions(listener: Listener): () => void {
return bridge.subscribe(listener);
}
+3
View File
@@ -213,6 +213,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
}, [dedicatedResumeHosts, enabled, identities, isVaultInitialized, keys, knownHosts, terminalSettings]);
// Show toast notification when update is available (only when auto-download is idle)
const toastedUpdateVersionRef = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return;
// Skip "update available" toast if auto-download has already started or completed
@@ -221,6 +222,8 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
if (toastedUpdateVersionRef.current === version) return;
toastedUpdateVersionRef.current = version;
toast.info(
t('update.available.message', { version }),
{
+28
View File
@@ -0,0 +1,28 @@
import type React from 'react';
import { useMemo } from 'react';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { useSettingsChromeStore } from '../state/settingsChromeStore';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
/**
* App theme CSS variables for surfaces that need them (vault surface, plugin
* theme tokens). Reads accent from appearanceChromeStore and UI theme ids from
* settingsChromeStore so accent drags only re-render the leaf that applies the
* vars, never the App shell.
*/
export function useAppThemeStyle(): React.CSSProperties {
const { accentMode, customAccent } = useAppearanceChromeStore();
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
return useMemo(() => {
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
}
+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);
+80
View File
@@ -0,0 +1,80 @@
import { useSyncExternalStore } from 'react';
import type { AISession } from '../../infrastructure/ai/types';
import type { DraftsByScope, PanelViewByScope } from './aiStateSnapshots';
type Listener = () => void;
export type AISessionsSnapshot = {
sessions: readonly AISession[];
activeSessionIdMap: Readonly<Record<string, string | null>>;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
};
const EMPTY_SESSIONS: readonly AISession[] = Object.freeze([]);
const EMPTY_MAP: Readonly<Record<string, string | null>> = Object.freeze({});
const EMPTY_DRAFTS: DraftsByScope = Object.freeze({});
const EMPTY_PANEL: PanelViewByScope = Object.freeze({});
export const EMPTY_AI_SESSIONS_SNAPSHOT: AISessionsSnapshot = Object.freeze({
sessions: EMPTY_SESSIONS,
activeSessionIdMap: EMPTY_MAP,
draftsByScope: EMPTY_DRAFTS,
panelViewByScope: EMPTY_PANEL,
});
/**
* Hot AI chat state — streaming message updates must not invalidate
* slow AI config Context consumers (providers, permissions, etc.).
*/
class AISessionsStore {
private snapshot: AISessionsSnapshot = EMPTY_AI_SESSIONS_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): AISessionsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AISessionsSnapshot): void {
if (
this.snapshot.sessions === next.sessions
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
&& this.snapshot.draftsByScope === next.draftsByScope
&& this.snapshot.panelViewByScope === next.panelViewByScope
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const aiSessionsStore = new AISessionsStore();
export function publishAISessionsSnapshot(snapshot: AISessionsSnapshot): void {
aiSessionsStore.setSnapshot(snapshot);
}
export function getAISessionsSnapshot(): AISessionsSnapshot {
return aiSessionsStore.getSnapshot();
}
export function subscribeAISessions(listener: Listener): () => void {
return aiSessionsStore.subscribe(listener);
}
export function useAISessionsStore(): AISessionsSnapshot {
return useSyncExternalStore(
subscribeAISessions,
getAISessionsSnapshot,
getAISessionsSnapshot,
);
}
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppSessionRuntime,
getAppSettingsRuntime,
getAppVaultRuntime,
registerAppSessionRuntime,
registerAppSettingsRuntime,
registerAppVaultRuntime,
subscribeAppSessionRuntime,
subscribeAppSettingsRuntime,
subscribeAppVaultRuntime,
type AppSessionRuntime,
type AppSettingsRuntime,
type AppVaultRuntime,
} from './appRuntimeBridge';
const vaultRuntime = (label: string) => ({ label }) as unknown as AppVaultRuntime;
const sessionRuntime = (label: string) => ({ label }) as unknown as AppSessionRuntime;
const settingsRuntime = (label: string) => ({ label }) as unknown as AppSettingsRuntime;
test('vault slot exposes the last registered runtime and notifies on change', () => {
let notifications = 0;
const unsubscribe = subscribeAppVaultRuntime(() => {
notifications += 1;
});
const first = vaultRuntime('first');
registerAppVaultRuntime(first);
assert.equal(getAppVaultRuntime(), first);
assert.equal(notifications, 1);
// Re-registering the same identity is what a render with unchanged state
// does; it must not wake subscribers.
registerAppVaultRuntime(first);
assert.equal(notifications, 1);
const second = vaultRuntime('second');
registerAppVaultRuntime(second);
assert.equal(getAppVaultRuntime(), second);
assert.equal(notifications, 2);
unsubscribe();
registerAppVaultRuntime(null);
assert.equal(getAppVaultRuntime(), null);
assert.equal(notifications, 2, 'unsubscribed listeners stay quiet');
});
test('session slot is independent from the vault slot', () => {
let vaultNotifications = 0;
let sessionNotifications = 0;
const unsubscribeVault = subscribeAppVaultRuntime(() => {
vaultNotifications += 1;
});
const unsubscribeSession = subscribeAppSessionRuntime(() => {
sessionNotifications += 1;
});
const runtime = sessionRuntime('session');
registerAppSessionRuntime(runtime);
assert.equal(getAppSessionRuntime(), runtime);
assert.equal(sessionNotifications, 1);
assert.equal(vaultNotifications, 0);
unsubscribeVault();
unsubscribeSession();
registerAppSessionRuntime(null);
});
test('settings slot is independent from the vault and session slots', () => {
let settingsNotifications = 0;
let otherNotifications = 0;
const unsubscribeSettings = subscribeAppSettingsRuntime(() => {
settingsNotifications += 1;
});
const unsubscribeVault = subscribeAppVaultRuntime(() => {
otherNotifications += 1;
});
const unsubscribeSession = subscribeAppSessionRuntime(() => {
otherNotifications += 1;
});
const runtime = settingsRuntime('settings');
registerAppSettingsRuntime(runtime);
assert.equal(getAppSettingsRuntime(), runtime);
assert.equal(settingsNotifications, 1);
assert.equal(otherNotifications, 0);
// A re-render with unchanged state re-registers the same identity.
registerAppSettingsRuntime(runtime);
assert.equal(settingsNotifications, 1);
unsubscribeSettings();
unsubscribeVault();
unsubscribeSession();
registerAppSettingsRuntime(null);
assert.equal(getAppSettingsRuntime(), null);
});
+138
View File
@@ -0,0 +1,138 @@
import { createContext, useContext } from 'react';
import type { useSessionState } from './useSessionState';
import type { useSettingsState } from './useSettingsState';
import type { useVaultState } from './useVaultState';
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
/**
* The slice of the vault runtime that `VaultPublisher` puts on the React
* context.
*
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are deliberately
* absent. They churn on every note keystroke, session start/exit, and history
* append, and publishing them here would re-render every context consumer for
* data those consumers only ever read at call time. Consumers subscribe to
* `notesStore` / `connectionLogsStore` / `shellHistoryStore` instead, or read
* the full runtime imperatively through `getAppVaultRuntime()`.
*
* `exportData` is omitted for the same reason: its callback identity tracks the
* notes arrays, so keeping it would reintroduce the churn it is meant to avoid.
*/
export type AppVaultContextValue = Omit<
AppVaultRuntime,
'notes' | 'noteGroups' | 'connectionLogs' | 'shellHistory' | 'exportData'
>;
type Listener = () => void;
/**
* Holds the live return of one mega hook so the component that owns the hook
* and the components that consume it no longer have to be the same component.
*
* `VaultPublisher` / `SessionPublisher` / `SettingsPublisher` own
* `useVaultState` / `useSessionState` / `useSettingsState` and expose the value
* two ways:
*
* - through a React context, for render-time consumers (App). Context keeps the
* read synchronous and non-null from the first render, which a store written
* in a layout effect cannot do for a descendant.
* - through this module slot, for imperative callers that need the current
* runtime outside of a render (`getAppVaultRuntime()` in an event handler or
* a bridge callback) and for non-descendant subscribers.
*/
class RuntimeSlot<T> {
private value: T | null = null;
private listeners = new Set<Listener>();
get = (): T | null => this.value;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: T | null): void {
if (this.value === next) return;
this.value = next;
for (const listener of this.listeners) {
listener();
}
}
}
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
vaultSlot.set(runtime);
}
export function getAppVaultRuntime(): AppVaultRuntime | null {
return vaultSlot.get();
}
export function subscribeAppVaultRuntime(listener: Listener): () => void {
return vaultSlot.subscribe(listener);
}
export function registerAppSessionRuntime(runtime: AppSessionRuntime | null): void {
sessionSlot.set(runtime);
}
export function getAppSessionRuntime(): AppSessionRuntime | null {
return sessionSlot.get();
}
export function subscribeAppSessionRuntime(listener: Listener): () => void {
return sessionSlot.subscribe(listener);
}
export function registerAppSettingsRuntime(runtime: AppSettingsRuntime | null): void {
settingsSlot.set(runtime);
}
export function getAppSettingsRuntime(): AppSettingsRuntime | null {
return settingsSlot.get();
}
export function subscribeAppSettingsRuntime(listener: Listener): () => void {
return settingsSlot.subscribe(listener);
}
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
/** Read the vault runtime owned by `VaultPublisher`. */
export function useAppVaultRuntime(): AppVaultContextValue {
const runtime = useContext(AppVaultRuntimeContext);
if (!runtime) {
throw new Error('useAppVaultRuntime must be rendered inside <VaultPublisher>');
}
return runtime;
}
/** Read the session runtime owned by `SessionPublisher`. */
export function useAppSessionRuntime(): AppSessionRuntime {
const runtime = useContext(AppSessionRuntimeContext);
if (!runtime) {
throw new Error('useAppSessionRuntime must be rendered inside <SessionPublisher>');
}
return runtime;
}
/** Read the settings runtime owned by `SettingsPublisher`. */
export function useAppSettingsRuntime(): AppSettingsRuntime {
const runtime = useContext(AppSettingsRuntimeContext);
if (!runtime) {
throw new Error('useAppSettingsRuntime must be rendered inside <SettingsPublisher>');
}
return runtime;
}
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConnectionLog } from '../../domain/models.ts';
import {
EMPTY_CONNECTION_LOGS_SNAPSHOT,
getConnectionLogsActions,
getConnectionLogsSnapshot,
getEmptyConnectionLogsSnapshot,
publishConnectionLogsSnapshot,
registerConnectionLogsActions,
subscribeConnectionLogs,
subscribeConnectionLogsActions,
} from './connectionLogsStore.ts';
const baseLog: ConnectionLog = {
id: 'log-1',
sessionId: 'session-1',
hostId: 'host-1',
hostLabel: 'Example',
hostname: 'example.com',
username: 'user',
protocol: 'ssh',
startTime: 1000,
localUsername: 'local',
localHostname: 'machine',
saved: false,
};
test('connectionLogsStore notifies subscribers only when the log array identity changes', () => {
const events: number[] = [];
const unsubscribe = subscribeConnectionLogs(() => {
events.push(getConnectionLogsSnapshot().connectionLogs.length);
});
const firstLogs = [baseLog];
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
assert.equal(events.at(-1), 1);
assert.equal(getConnectionLogsSnapshot().connectionLogs, firstLogs);
// Same identity must not wake the Vault logs section on unrelated renders.
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
assert.equal(events.length, 1);
const secondLogs = [baseLog, { ...baseLog, id: 'log-2', startTime: 2000 }];
publishConnectionLogsSnapshot({ connectionLogs: secondLogs });
assert.equal(events.at(-1), 2);
assert.equal(events.length, 2);
unsubscribe();
publishConnectionLogsSnapshot({ connectionLogs: [] });
assert.equal(events.length, 2);
});
test('connectionLogsStore exposes a frozen empty snapshot for gated mounts', () => {
assert.equal(getEmptyConnectionLogsSnapshot(), EMPTY_CONNECTION_LOGS_SNAPSHOT);
assert.equal(getEmptyConnectionLogsSnapshot().connectionLogs.length, 0);
assert.equal(Object.isFrozen(EMPTY_CONNECTION_LOGS_SNAPSHOT), true);
});
test('registerConnectionLogsActions exposes the vault log mutators', () => {
const calls: string[] = [];
const actionEvents: number[] = [];
const unsubscribe = subscribeConnectionLogsActions(() => {
actionEvents.push(actionEvents.length);
});
registerConnectionLogsActions({
updateConnectionLog: () => {
calls.push('update');
},
toggleConnectionLogSaved: () => {
calls.push('toggle');
},
deleteConnectionLog: () => {
calls.push('delete');
},
clearUnsavedConnectionLogs: () => {
calls.push('clear');
},
});
const actions = getConnectionLogsActions();
assert.ok(actions);
actions.updateConnectionLog('log-1', { saved: true });
actions.toggleConnectionLogSaved('log-1');
actions.deleteConnectionLog('log-1');
actions.clearUnsavedConnectionLogs();
assert.deepEqual(calls, ['update', 'toggle', 'delete', 'clear']);
assert.equal(actionEvents.length, 1);
registerConnectionLogsActions(null);
assert.equal(getConnectionLogsActions(), null);
assert.equal(actionEvents.length, 2);
unsubscribe();
});
test('connectionLogsStore keeps values and actions on independent slots', () => {
const valueEvents: number[] = [];
const actionEvents: number[] = [];
const unsubValues = subscribeConnectionLogs(() => {
valueEvents.push(valueEvents.length);
});
const unsubActions = subscribeConnectionLogsActions(() => {
actionEvents.push(actionEvents.length);
});
// Re-registering unstable mutator identities must not invalidate the logs.
registerConnectionLogsActions({
updateConnectionLog: () => {},
toggleConnectionLogSaved: () => {},
deleteConnectionLog: () => {},
clearUnsavedConnectionLogs: () => {},
});
assert.equal(valueEvents.length, 0);
assert.equal(actionEvents.length, 1);
publishConnectionLogsSnapshot({ connectionLogs: [baseLog] });
assert.equal(valueEvents.length, 1);
assert.equal(actionEvents.length, 1);
registerConnectionLogsActions(null);
unsubValues();
unsubActions();
});
+135
View File
@@ -0,0 +1,135 @@
import { useSyncExternalStore } from 'react';
import type { ConnectionLog } from '../../domain/models';
type Listener = () => void;
export type ConnectionLogsSnapshot = {
connectionLogs: readonly ConnectionLog[];
};
export type ConnectionLogsActions = {
updateConnectionLog: (id: string, updates: Partial<ConnectionLog>) => void;
toggleConnectionLogSaved: (id: string) => void;
deleteConnectionLog: (id: string) => void;
clearUnsavedConnectionLogs: () => void;
};
const EMPTY_CONNECTION_LOGS: readonly ConnectionLog[] = Object.freeze([]);
export const EMPTY_CONNECTION_LOGS_SNAPSHOT: ConnectionLogsSnapshot = Object.freeze({
connectionLogs: EMPTY_CONNECTION_LOGS,
});
/**
* External store for connection logs so the Vault logs section and log-view
* replays can subscribe without keeping logs in the App domain bags. Every
* session start/exit appends a log, which would otherwise rebuild the whole
* shell.
*/
class ConnectionLogsStore {
private snapshot: ConnectionLogsSnapshot = EMPTY_CONNECTION_LOGS_SNAPSHOT;
private actions: ConnectionLogsActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): ConnectionLogsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: ConnectionLogsSnapshot): void {
if (this.snapshot.connectionLogs === next.connectionLogs) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): ConnectionLogsActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: ConnectionLogsActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const connectionLogsStore = new ConnectionLogsStore();
export function publishConnectionLogsSnapshot(
snapshot: ConnectionLogsSnapshot,
): void {
connectionLogsStore.setSnapshot(snapshot);
}
export function getConnectionLogsSnapshot(): ConnectionLogsSnapshot {
return connectionLogsStore.getSnapshot();
}
export function subscribeConnectionLogs(listener: Listener): () => void {
return connectionLogsStore.subscribe(listener);
}
export function getEmptyConnectionLogsSnapshot(): ConnectionLogsSnapshot {
return EMPTY_CONNECTION_LOGS_SNAPSHOT;
}
export function registerConnectionLogsActions(
actions: ConnectionLogsActions | null,
): void {
connectionLogsStore.setActions(actions);
}
export function getConnectionLogsActions(): ConnectionLogsActions | null {
return connectionLogsStore.getActions();
}
export function subscribeConnectionLogsActions(listener: Listener): () => void {
return connectionLogsStore.subscribeActions(listener);
}
const noopUpdateConnectionLog: ConnectionLogsActions['updateConnectionLog'] = () => {};
const noopToggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'] = () => {};
const noopDeleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'] = () => {};
const noopClearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'] = () => {};
/** Subscribe to the connection log catalog plus its vault mutation actions. */
export function useConnectionLogsStore(): {
connectionLogs: ConnectionLog[];
updateConnectionLog: ConnectionLogsActions['updateConnectionLog'];
toggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'];
deleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'];
clearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'];
} {
const snapshot = useSyncExternalStore(
subscribeConnectionLogs,
getConnectionLogsSnapshot,
getConnectionLogsSnapshot,
);
const actions = useSyncExternalStore(
subscribeConnectionLogsActions,
getConnectionLogsActions,
getConnectionLogsActions,
);
return {
connectionLogs: snapshot.connectionLogs as ConnectionLog[],
updateConnectionLog: actions?.updateConnectionLog ?? noopUpdateConnectionLog,
toggleConnectionLogSaved: actions?.toggleConnectionLogSaved ?? noopToggleConnectionLogSaved,
deleteConnectionLog: actions?.deleteConnectionLog ?? noopDeleteConnectionLog,
clearUnsavedConnectionLogs: actions?.clearUnsavedConnectionLogs ?? noopClearUnsavedConnectionLogs,
};
}
+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,
);
@@ -88,7 +88,13 @@ test("session restore persistence can be disabled for non-main windows", () => {
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appSource, /persistSessionRestore: !isPeerSessionWindow/);
// App no longer calls useSessionState; SessionPublisher owns the hook and
// App passes the peer-window flag down to it.
assert.match(appSource, /<SessionPublisher persistSessionRestore=\{!isPeerSessionWindow\}>/);
assert.match(
readFileSync(new URL("../app/publishers/SessionPublisher.tsx", import.meta.url), "utf8"),
/useSessionState\(\{ persistSessionRestore \}\)/,
);
assert.match(indexSource, /hash === '#\/session-window'/);
assert.match(registerBridgesSource, /route: "session-window"/);
assert.match(registerBridgesSource, /registerAsMainWindow: false/);
@@ -100,7 +106,8 @@ test("session restore persistence can be disabled for non-main windows", () => {
});
test("session peer windows do not run main-window startup effects", () => {
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../app/AppSideEffects.tsx", import.meta.url), "utf8");
const appRootSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
@@ -112,7 +119,13 @@ test("session peer windows do not run main-window startup effects", () => {
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appSource, /useSettingsState\(\{[^}]*enableSettingsSync: !isPeerSessionWindow[^}]*enableSystemEffects: !isPeerSessionWindow/s);
// SettingsPublisher owns useSettingsState; App only forwards the peer-window
// flags to it.
assert.match(appRootSource, /<SettingsPublisher\s+enableSettingsSync=\{!isPeerSessionWindow\}\s+enableSystemEffects=\{!isPeerSessionWindow\}/s);
assert.match(
readFileSync(new URL("../app/publishers/SettingsPublisher.tsx", import.meta.url), "utf8"),
/useSettingsState\(\{ enableSettingsSync, enableSystemEffects \}\)/,
);
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /if \(isPeerSessionWindow \|\| !isVaultInitialized \|\| versionBackupAttemptedRef\.current\) return;/);
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TerminalSession } from '../../domain/models.ts';
import {
EMPTY_SESSION_SNAPSHOT,
getSessionSnapshot,
getSessionSnapshotActions,
publishSessionSnapshot,
registerSessionSnapshotActions,
sessionSnapshotsEqual,
subscribeSessionSnapshot,
} from './sessionSnapshotStore.ts';
function makeSession(id: string): TerminalSession {
return { id, hostLabel: id, status: 'connected' } as TerminalSession;
}
test('sessionSnapshotStore notifies only when a field identity changes', () => {
const seen: number[] = [];
const unsubscribe = subscribeSessionSnapshot(() => {
seen.push(getSessionSnapshot().sessions.length);
});
const sessions = [makeSession('s1')];
const snapshot = { ...EMPTY_SESSION_SNAPSHOT, sessions, orphanSessions: sessions };
publishSessionSnapshot(snapshot);
assert.deepEqual(seen, [1]);
assert.equal(getSessionSnapshot().sessions, sessions);
publishSessionSnapshot({ ...snapshot });
assert.deepEqual(seen, [1]);
// Presentation-only fields still count: rename targets drive dialog state.
publishSessionSnapshot({ ...snapshot, sessionRenameTarget: sessions[0] });
assert.deepEqual(seen, [1, 1]);
assert.equal(getSessionSnapshot().sessionRenameTarget, sessions[0]);
unsubscribe();
publishSessionSnapshot(EMPTY_SESSION_SNAPSHOT);
assert.deepEqual(seen, [1, 1]);
});
test('sessionSnapshotsEqual compares every published field', () => {
assert.equal(
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, { ...EMPTY_SESSION_SNAPSHOT }),
true,
);
assert.equal(
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, {
...EMPTY_SESSION_SNAPSHOT,
draggingSessionId: 's1',
}),
false,
);
assert.equal(
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, { ...EMPTY_SESSION_SNAPSHOT, logViews: [] }),
false,
);
});
test('session actions register and unregister', () => {
assert.equal(getSessionSnapshotActions(), null);
const calls: string[] = [];
const noop = (() => {}) as never;
registerSessionSnapshotActions({
setActiveTabId: ((id: string) => calls.push(id)) as never,
closeSession: noop,
closeSessions: noop,
closeWorkspace: noop,
openLogView: noop,
closeLogView: noop,
setDraggingSessionId: noop,
startSessionRename: noop,
renameSessionInline: noop,
submitSessionRename: noop,
resetSessionRename: noop,
startWorkspaceRename: noop,
submitWorkspaceRename: noop,
resetWorkspaceRename: noop,
removeSessionFromWorkspace: noop,
setWorkspaceFocusedSession: noop,
toggleWorkspaceViewMode: noop,
createLocalTerminal: noop,
createSerialSession: noop,
connectToHost: noop,
updateSessionStatus: noop,
updateSessionFontSize: noop,
clearSessionFontSizeOverride: noop,
createWorkspaceWithHosts: noop,
createWorkspaceFromSessions: noop,
addSessionToWorkspace: noop,
appendHostToWorkspace: noop,
appendLocalTerminalToWorkspace: noop,
createWorkspaceFromTargets: noop,
updateSplitSizes: noop,
splitSession: noop,
reorderWorkspaceSessions: noop,
moveFocusInWorkspace: noop,
runSnippet: noop,
getOrderedWorkTabs: noop,
reorderTabs: noop,
toggleBroadcast: noop,
isBroadcastEnabled: noop,
copySession: noop,
copyWorkspace: noop,
createSessionFromCloneSource: noop,
updateSessionRestoreCwd: noop,
getSessionRestoreCwd: noop,
updateSessionDynamicTitle: noop,
updateSessionCodingCliProvider: noop,
});
getSessionSnapshotActions()?.setActiveTabId('vault');
assert.deepEqual(calls, ['vault']);
registerSessionSnapshotActions(null);
assert.equal(getSessionSnapshotActions(), null);
});
+200
View File
@@ -0,0 +1,200 @@
import { useSyncExternalStore } from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import type { LogView } from './logViewState';
import type { useSessionState } from './useSessionState';
type SessionState = ReturnType<typeof useSessionState>;
type Listener = () => void;
/**
* Session/workspace catalog for shell surfaces. `activeTabId` is intentionally
* absent — it lives in `activeTabStore` so tab switches stay off this path.
*/
export type SessionSnapshot = {
sessions: readonly TerminalSession[];
/** Sessions with no workspace and not hidden from tabs; TopTabs renders these. */
orphanSessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
logViews: readonly LogView[];
draggingSessionId: string | null;
sessionRenameTarget: TerminalSession | null;
workspaceRenameTarget: Workspace | null;
};
/**
* Session mutators the shell needs. Derived from the hook return so the store
* contract cannot drift from `useSessionState`.
*/
export type SessionSnapshotActions = Pick<
SessionState,
| 'setActiveTabId'
| 'closeSession'
| 'closeSessions'
| 'closeWorkspace'
| 'openLogView'
| 'closeLogView'
| 'setDraggingSessionId'
| 'startSessionRename'
| 'renameSessionInline'
| 'submitSessionRename'
| 'resetSessionRename'
| 'startWorkspaceRename'
| 'submitWorkspaceRename'
| 'resetWorkspaceRename'
| 'removeSessionFromWorkspace'
| 'setWorkspaceFocusedSession'
| 'toggleWorkspaceViewMode'
// Terminal / chrome mutators previously read only via App session runtime.
| 'createLocalTerminal'
| 'createSerialSession'
| 'connectToHost'
| 'updateSessionStatus'
| 'updateSessionFontSize'
| 'clearSessionFontSizeOverride'
| 'createWorkspaceWithHosts'
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'appendHostToWorkspace'
| 'appendLocalTerminalToWorkspace'
| 'createWorkspaceFromTargets'
| 'updateSplitSizes'
| 'splitSession'
| 'reorderWorkspaceSessions'
| 'moveFocusInWorkspace'
| 'runSnippet'
| 'getOrderedWorkTabs'
| 'reorderTabs'
| 'toggleBroadcast'
| 'isBroadcastEnabled'
| 'copySession'
| 'copyWorkspace'
| 'createSessionFromCloneSource'
| 'updateSessionRestoreCwd'
| 'getSessionRestoreCwd'
| 'updateSessionDynamicTitle'
| 'updateSessionCodingCliProvider'
>;
const EMPTY_SESSIONS: readonly TerminalSession[] = Object.freeze([]);
const EMPTY_WORKSPACES: readonly Workspace[] = Object.freeze([]);
const EMPTY_LOG_VIEWS: readonly LogView[] = Object.freeze([]);
export const EMPTY_SESSION_SNAPSHOT: SessionSnapshot = Object.freeze({
sessions: EMPTY_SESSIONS,
orphanSessions: EMPTY_SESSIONS,
workspaces: EMPTY_WORKSPACES,
logViews: EMPTY_LOG_VIEWS,
draggingSessionId: null,
sessionRenameTarget: null,
workspaceRenameTarget: null,
});
const SNAPSHOT_KEYS = Object.keys(EMPTY_SESSION_SNAPSHOT) as (keyof SessionSnapshot)[];
export function sessionSnapshotsEqual(
prev: SessionSnapshot,
next: SessionSnapshot,
): boolean {
return SNAPSHOT_KEYS.every((key) => prev[key] === next[key]);
}
/**
* External store for the session catalog so TopTabs, the terminal layer and
* the host tree can subscribe directly instead of receiving sessions through
* the App domain bags.
*/
class SessionSnapshotStore {
private snapshot: SessionSnapshot = EMPTY_SESSION_SNAPSHOT;
private actions: SessionSnapshotActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): SessionSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: SessionSnapshot): void {
if (sessionSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): SessionSnapshotActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: SessionSnapshotActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const sessionSnapshotStore = new SessionSnapshotStore();
export function publishSessionSnapshot(snapshot: SessionSnapshot): void {
sessionSnapshotStore.setSnapshot(snapshot);
}
export function getSessionSnapshot(): SessionSnapshot {
return sessionSnapshotStore.getSnapshot();
}
export function subscribeSessionSnapshot(listener: Listener): () => void {
return sessionSnapshotStore.subscribe(listener);
}
export function registerSessionSnapshotActions(
actions: SessionSnapshotActions | null,
): void {
sessionSnapshotStore.setActions(actions);
}
export function getSessionSnapshotActions(): SessionSnapshotActions | null {
return sessionSnapshotStore.getActions();
}
export function subscribeSessionSnapshotActions(listener: Listener): () => void {
return sessionSnapshotStore.subscribeActions(listener);
}
/** Subscribe to the whole session catalog. Prefer a narrower selector hook. */
export function useSessionSnapshot(): SessionSnapshot {
return useSyncExternalStore(
subscribeSessionSnapshot,
getSessionSnapshot,
getSessionSnapshot,
);
}
/** Subscribe to one session field so unrelated session churn stays out. */
export function useSessionSnapshotField<K extends keyof SessionSnapshot>(
field: K,
): SessionSnapshot[K] {
const read = () => getSessionSnapshot()[field];
return useSyncExternalStore(subscribeSessionSnapshot, read, read);
}
export function useSessionSnapshotActions(): SessionSnapshotActions | null {
return useSyncExternalStore(
subscribeSessionSnapshotActions,
getSessionSnapshotActions,
getSessionSnapshotActions,
);
}
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
getSettingsChromeActions,
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
registerSettingsChromeActions,
settingsChromeSnapshotsEqual,
subscribeSettingsChrome,
subscribeSettingsChromeActions,
type SettingsChromeSnapshot,
} from './settingsChromeStore.ts';
type SnapshotKey = keyof SettingsChromeSnapshot;
/**
* Derive the probe value instead of hard-coding a field list: the chrome slice
* grows as surfaces move off the App settings bag, and this suite is about the
* dedupe contract, not about which fields happen to be in it today.
*/
function mutate(value: unknown): unknown {
if (typeof value === 'boolean') return !value;
if (typeof value === 'number') return value + 1;
if (typeof value === 'string') return `${value}-changed`;
throw new Error(`Unsupported snapshot field type: ${typeof value}`);
}
test('settingsChromeStore ignores structurally identical republishes', () => {
const base = getSettingsChromeSnapshot();
let events = 0;
const unsubscribe = subscribeSettingsChrome(() => {
events += 1;
});
// useSettingsState rebuilds this object literal on every render, so a fresh
// identity with unchanged fields must not wake chrome consumers.
publishSettingsChromeSnapshot({ ...base });
publishSettingsChromeSnapshot({ ...base });
assert.equal(events, 0);
assert.equal(getSettingsChromeSnapshot(), base);
unsubscribe();
});
test('settingsChromeStore notifies on any single chrome field change', () => {
const base = getSettingsChromeSnapshot();
const keys = Object.keys(base) as SnapshotKey[];
assert.ok(keys.length > 0, 'chrome snapshot must publish at least one field');
for (const key of keys) {
let events = 0;
const unsubscribe = subscribeSettingsChrome(() => {
events += 1;
});
const changed = { ...base, [key]: mutate(base[key]) } as SettingsChromeSnapshot;
publishSettingsChromeSnapshot(changed);
assert.equal(events, 1, `expected a notification for ${key}`);
assert.equal(getSettingsChromeSnapshot()[key], changed[key]);
publishSettingsChromeSnapshot({ ...changed });
assert.equal(events, 1, `expected no repeat notification for ${key}`);
publishSettingsChromeSnapshot(base);
assert.equal(events, 2, `expected a restore notification for ${key}`);
unsubscribe();
}
assert.equal(getSettingsChromeSnapshot(), base);
});
test('settingsChromeStore stops notifying after unsubscribe', () => {
const base = getSettingsChromeSnapshot();
const [firstKey] = Object.keys(base) as SnapshotKey[];
let events = 0;
const unsubscribe = subscribeSettingsChrome(() => {
events += 1;
});
unsubscribe();
publishSettingsChromeSnapshot({
...base,
[firstKey]: mutate(base[firstKey]),
} as SettingsChromeSnapshot);
assert.equal(events, 0);
publishSettingsChromeSnapshot(base);
});
test('chrome snapshot fields stay in sync with what useSettingsState publishes', () => {
// The whole point of the slice is that chrome leaves can stop reading the
// App settings bag. A field the publisher forgets would silently freeze at
// its default, so pin the two shapes together.
const source = readFileSync(new URL('./useSettingsState.ts', import.meta.url), 'utf8');
const callStart = source.indexOf('publishSettingsChromeSnapshot({');
assert.notEqual(callStart, -1, 'useSettingsState must publish the chrome slice');
const callEnd = source.indexOf('});', callStart);
assert.notEqual(callEnd, -1);
const published = source
.slice(callStart, callEnd)
.split('\n')
.slice(1)
.map((line) => line.trim().match(/^([A-Za-z0-9_]+)\s*[:,]/)?.[1])
.filter((name): name is string => Boolean(name));
assert.deepEqual(
published.slice().sort(),
Object.keys(DEFAULT_SETTINGS_CHROME_SNAPSHOT).sort(),
);
});
test('settingsChromeSnapshotsEqual compares by field, not object identity', () => {
const base = DEFAULT_SETTINGS_CHROME_SNAPSHOT;
assert.equal(settingsChromeSnapshotsEqual({ ...base }, { ...base }), true);
assert.equal(
settingsChromeSnapshotsEqual(base, { ...base, windowOpacity: 0.8 }),
false,
);
assert.equal(
settingsChromeSnapshotsEqual(base, { ...base, showSftpTab: !base.showSftpTab }),
false,
);
});
test('registerSettingsChromeActions exposes the theme and opacity setters', () => {
const calls: string[] = [];
let actionEvents = 0;
const unsubscribe = subscribeSettingsChromeActions(() => {
actionEvents += 1;
});
registerSettingsChromeActions({
setTheme: () => calls.push('theme'),
setWindowOpacity: () => calls.push('opacity'),
});
const actions = getSettingsChromeActions();
assert.ok(actions);
actions.setTheme('light');
actions.setWindowOpacity(0.5);
assert.deepEqual(calls, ['theme', 'opacity']);
assert.equal(actionEvents, 1);
registerSettingsChromeActions(null);
assert.equal(getSettingsChromeActions(), null);
assert.equal(actionEvents, 2);
unsubscribe();
});
test('setter churn never invalidates the chrome value snapshot', () => {
// useSettingsState re-registers on every setter identity change; chrome
// consumers of the value snapshot must not re-render for that.
let valueEvents = 0;
const unsubscribe = subscribeSettingsChrome(() => {
valueEvents += 1;
});
registerSettingsChromeActions({ setTheme: () => {}, setWindowOpacity: () => {} });
registerSettingsChromeActions({ setTheme: () => {}, setWindowOpacity: () => {} });
assert.equal(valueEvents, 0);
registerSettingsChromeActions(null);
unsubscribe();
});
+181
View File
@@ -0,0 +1,181 @@
import { useSyncExternalStore } from 'react';
import type { UILanguage } from '../../domain/models/connection';
import type { DynamicTabTitleMode } from '../../domain/models/terminal';
import type { HostClickBehavior } from '../../domain/hostClickBehavior';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
type Listener = () => void;
export type SettingsChromeTheme = 'dark' | 'light' | 'system';
/**
* Chrome-facing settings slice. Keep this aligned with what AppView / TopTabs /
* HostTree actually read. Unrelated settings edits must not rebuild App chrome
* domain bags — leaves subscribe here instead of receiving the whole settings
* object through domain props.
*/
export type SettingsChromeSnapshot = {
theme: SettingsChromeTheme;
resolvedTheme: 'light' | 'dark';
lightUiThemeId: string;
darkUiThemeId: string;
uiLanguage: UILanguage;
windowOpacity: number;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
showRecentHosts: boolean;
hostClickBehavior: HostClickBehavior;
showOnlyUngroupedHostsInRoot: boolean;
dynamicTabTitleMode: DynamicTabTitleMode;
disableTerminalFontZoom: boolean;
restoreTerminalCwd: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
};
/**
* Setters chrome leaves need. Kept on a separate slot from the value snapshot:
* `useSettingsState` rebuilds these callbacks on its own cadence, and that must
* never invalidate the value snapshot.
*/
export type SettingsChromeActions = {
setTheme: (theme: SettingsChromeTheme) => void;
setWindowOpacity: (opacity: number) => void;
};
export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.freeze({
theme: 'system',
resolvedTheme: 'dark',
lightUiThemeId: 'default',
darkUiThemeId: 'default',
uiLanguage: 'en',
windowOpacity: 1,
showSftpTab: true,
showHostTreeSidebar: true,
showRecentHosts: true,
hostClickBehavior: 'connect',
showOnlyUngroupedHostsInRoot: false,
dynamicTabTitleMode: 'off',
disableTerminalFontZoom: false,
restoreTerminalCwd: true,
terminalSidePanelAutoOpen: false,
terminalSidePanelAutoOpenTab: 'ai',
} satisfies SettingsChromeSnapshot);
export function settingsChromeSnapshotsEqual(
a: SettingsChromeSnapshot,
b: SettingsChromeSnapshot,
): boolean {
return a.theme === b.theme
&& a.resolvedTheme === b.resolvedTheme
&& a.lightUiThemeId === b.lightUiThemeId
&& a.darkUiThemeId === b.darkUiThemeId
&& a.uiLanguage === b.uiLanguage
&& a.windowOpacity === b.windowOpacity
&& a.showSftpTab === b.showSftpTab
&& a.showHostTreeSidebar === b.showHostTreeSidebar
&& a.showRecentHosts === b.showRecentHosts
&& a.hostClickBehavior === b.hostClickBehavior
&& a.showOnlyUngroupedHostsInRoot === b.showOnlyUngroupedHostsInRoot
&& a.dynamicTabTitleMode === b.dynamicTabTitleMode
&& a.disableTerminalFontZoom === b.disableTerminalFontZoom
&& a.restoreTerminalCwd === b.restoreTerminalCwd
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
}
class SettingsChromeStore {
private snapshot: SettingsChromeSnapshot = DEFAULT_SETTINGS_CHROME_SNAPSHOT;
private actions: SettingsChromeActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): SettingsChromeSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: SettingsChromeSnapshot): void {
if (settingsChromeSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): SettingsChromeActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: SettingsChromeActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const settingsChromeStore = new SettingsChromeStore();
export function publishSettingsChromeSnapshot(
snapshot: SettingsChromeSnapshot,
): void {
settingsChromeStore.setSnapshot(snapshot);
}
export function getSettingsChromeSnapshot(): SettingsChromeSnapshot {
return settingsChromeStore.getSnapshot();
}
export function subscribeSettingsChrome(listener: Listener): () => void {
return settingsChromeStore.subscribe(listener);
}
export function registerSettingsChromeActions(
actions: SettingsChromeActions | null,
): void {
settingsChromeStore.setActions(actions);
}
export function getSettingsChromeActions(): SettingsChromeActions | null {
return settingsChromeStore.getActions();
}
export function subscribeSettingsChromeActions(listener: Listener): () => void {
return settingsChromeStore.subscribeActions(listener);
}
export function useSettingsChromeStore(): SettingsChromeSnapshot {
return useSyncExternalStore(
subscribeSettingsChrome,
getSettingsChromeSnapshot,
getSettingsChromeSnapshot,
);
}
const noopSetTheme: SettingsChromeActions['setTheme'] = () => {};
const noopSetWindowOpacity: SettingsChromeActions['setWindowOpacity'] = () => {};
/** Chrome setters, safe to call before `useSettingsState` has registered. */
export function useSettingsChromeActions(): SettingsChromeActions {
const actions = useSyncExternalStore(
subscribeSettingsChromeActions,
getSettingsChromeActions,
getSettingsChromeActions,
);
return {
setTheme: actions?.setTheme ?? noopSetTheme,
setWindowOpacity: actions?.setWindowOpacity ?? noopSetWindowOpacity,
};
}
+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,
+201
View File
@@ -0,0 +1,201 @@
import { useSyncExternalStore } from 'react';
import type {
HotkeyScheme,
KeyBinding,
SessionLogFormat,
TerminalSettings,
} from '../../domain/models';
import { normalizeTerminalSettings } from '../../domain/models';
import type { useSettingsState } from './useSettingsState';
type SettingsState = ReturnType<typeof useSettingsState>;
type Listener = () => void;
/**
* Terminal-facing settings slice. Mirrors `settingsChromeStore`: values and
* actions live on separate slots so setter identity churn never invalidates
* the value snapshot TerminalHost / terminal domain bags subscribe to.
*/
export type TerminalSettingsSnapshot = {
terminalThemeId: string;
terminalThemeDarkId: string;
terminalThemeLightId: string;
followAppTerminalTheme: boolean;
terminalFontFamilyId: string;
terminalFontSize: number;
terminalSettings: TerminalSettings;
hotkeyScheme: HotkeyScheme;
keyBindings: readonly KeyBinding[];
isHotkeyRecording: boolean;
sftpDoubleClickBehavior: 'open' | 'transfer';
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
sessionLogsFormat: SessionLogFormat;
sessionLogsTimestampsEnabled: boolean;
sshDebugLogsEnabled: boolean;
};
export type TerminalSettingsActions = Pick<
SettingsState,
| 'setTerminalThemeId'
| 'setTerminalThemeDarkId'
| 'setTerminalThemeLightId'
| 'setFollowAppTerminalTheme'
| 'setTerminalFontFamilyId'
| 'setTerminalFontSize'
| 'updateTerminalSetting'
| 'setSftpFollowTerminalCwd'
| 'setEditorWordWrap'
| 'applyAppTheme'
>;
export const DEFAULT_TERMINAL_SETTINGS_SNAPSHOT: TerminalSettingsSnapshot = Object.freeze({
terminalThemeId: 'default',
terminalThemeDarkId: 'default',
terminalThemeLightId: 'default',
followAppTerminalTheme: true,
terminalFontFamilyId: 'default',
terminalFontSize: 14,
terminalSettings: normalizeTerminalSettings(),
hotkeyScheme: 'mac',
keyBindings: Object.freeze([]) as readonly KeyBinding[],
isHotkeyRecording: false,
sftpDoubleClickBehavior: 'open',
sftpAutoSync: false,
sftpShowHiddenFiles: false,
sftpUseCompressedUpload: false,
sftpAutoOpenSidebar: false,
sftpFollowTerminalCwd: false,
sftpDefaultViewMode: 'list',
editorWordWrap: true,
sessionLogsEnabled: false,
sessionLogsDir: '',
sessionLogsFormat: 'txt',
sessionLogsTimestampsEnabled: false,
sshDebugLogsEnabled: false,
});
export function terminalSettingsSnapshotsEqual(
a: TerminalSettingsSnapshot,
b: TerminalSettingsSnapshot,
): boolean {
return a.terminalThemeId === b.terminalThemeId
&& a.terminalThemeDarkId === b.terminalThemeDarkId
&& a.terminalThemeLightId === b.terminalThemeLightId
&& a.followAppTerminalTheme === b.followAppTerminalTheme
&& a.terminalFontFamilyId === b.terminalFontFamilyId
&& a.terminalFontSize === b.terminalFontSize
&& a.terminalSettings === b.terminalSettings
&& a.hotkeyScheme === b.hotkeyScheme
&& a.keyBindings === b.keyBindings
&& a.isHotkeyRecording === b.isHotkeyRecording
&& a.sftpDoubleClickBehavior === b.sftpDoubleClickBehavior
&& a.sftpAutoSync === b.sftpAutoSync
&& a.sftpShowHiddenFiles === b.sftpShowHiddenFiles
&& a.sftpUseCompressedUpload === b.sftpUseCompressedUpload
&& a.sftpAutoOpenSidebar === b.sftpAutoOpenSidebar
&& a.sftpFollowTerminalCwd === b.sftpFollowTerminalCwd
&& a.sftpDefaultViewMode === b.sftpDefaultViewMode
&& a.editorWordWrap === b.editorWordWrap
&& a.sessionLogsEnabled === b.sessionLogsEnabled
&& a.sessionLogsDir === b.sessionLogsDir
&& a.sessionLogsFormat === b.sessionLogsFormat
&& a.sessionLogsTimestampsEnabled === b.sessionLogsTimestampsEnabled
&& a.sshDebugLogsEnabled === b.sshDebugLogsEnabled;
}
class TerminalSettingsStore {
private snapshot: TerminalSettingsSnapshot = DEFAULT_TERMINAL_SETTINGS_SNAPSHOT;
private actions: TerminalSettingsActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): TerminalSettingsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: TerminalSettingsSnapshot): void {
if (terminalSettingsSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): TerminalSettingsActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: TerminalSettingsActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const terminalSettingsStore = new TerminalSettingsStore();
export function publishTerminalSettingsSnapshot(
snapshot: TerminalSettingsSnapshot,
): void {
terminalSettingsStore.setSnapshot(snapshot);
}
export function getTerminalSettingsSnapshot(): TerminalSettingsSnapshot {
return terminalSettingsStore.getSnapshot();
}
export function subscribeTerminalSettings(listener: Listener): () => void {
return terminalSettingsStore.subscribe(listener);
}
export function registerTerminalSettingsActions(
actions: TerminalSettingsActions | null,
): void {
terminalSettingsStore.setActions(actions);
}
export function getTerminalSettingsActions(): TerminalSettingsActions | null {
return terminalSettingsStore.getActions();
}
export function subscribeTerminalSettingsActions(listener: Listener): () => void {
return terminalSettingsStore.subscribeActions(listener);
}
export function useTerminalSettingsStore(): TerminalSettingsSnapshot {
return useSyncExternalStore(
subscribeTerminalSettings,
getTerminalSettingsSnapshot,
getTerminalSettingsSnapshot,
);
}
export function useTerminalSettingsActions(): TerminalSettingsActions | null {
return useSyncExternalStore(
subscribeTerminalSettingsActions,
getTerminalSettingsActions,
getTerminalSettingsActions,
);
}
+15 -9
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
@@ -50,6 +50,7 @@ import {
} from './aiDraftState';
import { convertFilesToUploads } from './useFileUpload';
import { removeProviderReferences } from './aiProviderCleanup';
import { publishAISessionsSnapshot } from './aiSessionsStore';
import {
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
@@ -1004,6 +1005,17 @@ export function useAIState() {
// ── Computed ──
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
// Stream/message updates publish here so AIConfig Context consumers do not
// re-render on every token. AIChatPanelsHost reads aiSessionsStore instead.
useLayoutEffect(() => {
publishAISessionsSnapshot({
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
});
}, [sessions, activeSessionIdMap, draftsByScope, panelViewByScope]);
return useMemo(() => ({
providers,
setProviders,
@@ -1039,10 +1051,8 @@ export function useAIState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
// sessions / activeSessionIdMap / draftsByScope / panelViewByScope live in
// aiSessionsStore so streaming updates do not rebuild this config object.
setActiveSessionId,
ensureDraftForScope,
updateDraft,
@@ -1096,10 +1106,6 @@ export function useAIState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
setActiveSessionId,
ensureDraftForScope,
updateDraft,
+11 -3
View File
@@ -7,7 +7,7 @@
* - Debounced sync to avoid too frequent API calls
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
@@ -49,6 +49,7 @@ import {
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
import { getNotesSnapshot, subscribeNotes } from './notesStore';
interface AutoSyncConfig {
enabled?: boolean;
@@ -163,6 +164,9 @@ interface RemoteVersionCheckOptions {
export const useAutoSync = (config: AutoSyncConfig) => {
const enabled = config.enabled !== false;
// Subscribe so note edits still trigger getSyncSnapshot rebuilds even when
// App omits notes props (notes live in notesStore, not App render).
const notesSnapshot = useSyncExternalStore(subscribeNotes, getNotesSnapshot, getNotesSnapshot);
const { t } = useI18n();
const tRef = useRef(t);
useEffect(() => {
@@ -254,8 +258,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
notes: config.notes,
noteGroups: config.noteGroups,
// Prefer explicit overrides (tests); otherwise read the live notes store so
// App does not have to re-render on every note edit to keep sync current.
notes: config.notes ?? (notesSnapshot.notes as SyncPayload['notes']),
noteGroups: config.noteGroups ?? (notesSnapshot.noteGroups as SyncPayload['noteGroups']),
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
groupConfigs: config.groupConfigs,
};
@@ -271,6 +277,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.noteGroups,
config.portForwardingRules,
config.groupConfigs,
notesSnapshot.notes,
notesSnapshot.noteGroups,
]);
// Build sync payload (includes plugin sidecars when host is available)
@@ -15,9 +15,11 @@ import {
readExternalMcpSilentSessions,
readExternalMcpStoredEnabled,
resetExternalMcpStartupReadyForTests,
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpSilentSessions,
@@ -178,17 +180,17 @@ describe('useExternalMcpToggleState startup ready gate', () => {
it('wires App startup reconcile to release the gate after enable settles', async () => {
const appSource = await import('node:fs').then((fs) =>
fs.readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8'),
fs.readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8'),
);
const hookSource = await import('node:fs').then((fs) =>
fs.readFileSync(new URL('./useExternalMcpToggleState.ts', import.meta.url), 'utf8'),
);
assert.match(appSource, /await syncExternalMcpStartupState\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /await syncExternalMcpStartupStateOnce\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /markExternalMcpStartupReady\(\)/);
assert.ok(
appSource.indexOf('await syncExternalMcpStartupState(netcattyBridge.get())')
appSource.indexOf('await syncExternalMcpStartupStateOnce(netcattyBridge.get())')
< appSource.indexOf('markExternalMcpStartupReady()'),
'startup ready must be marked only after await syncExternalMcpStartupState',
'startup ready must be marked only after await syncExternalMcpStartupStateOnce',
);
assert.match(hookSource, /export async function syncExternalMcpStartupState/);
assert.match(hookSource, /await Promise\.resolve\(bridge\?\.externalMcpSetEnabled\?\.\(plan\.runtimeEnabled\)\)/);
@@ -247,6 +249,51 @@ describe('useExternalMcpToggleState startup ready gate', () => {
});
});
describe('syncExternalMcpStartupStateOnce single-flight', () => {
it('reaches the bridge once when the startup effect is invoked twice', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
resetExternalMcpStartupSyncOnceForTests();
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
const configCalls: unknown[] = [];
const enabledCalls: boolean[] = [];
const bridge = {
externalMcpSetConfig: async (config: unknown) => {
configCalls.push(config);
return { ok: true };
},
externalMcpSetEnabled: async (enabled: boolean) => {
enabledCalls.push(enabled);
return { ok: true, enabled };
},
};
// Mount, cleanup, mount again: both effect runs share one reconcile.
const first = syncExternalMcpStartupStateOnce(bridge);
const second = syncExternalMcpStartupStateOnce(bridge);
assert.equal(first, second);
const [firstPlan, secondPlan] = await Promise.all([first, second]);
assert.equal(firstPlan, secondPlan);
assert.equal(firstPlan.runtimeEnabled, true);
assert.equal(configCalls.length, 1);
assert.deepEqual(enabledCalls, [true]);
// A later remount awaits the settled promise instead of re-sending IPC.
await syncExternalMcpStartupStateOnce(bridge);
assert.equal(configCalls.length, 1);
assert.deepEqual(enabledCalls, [true]);
} finally {
resetExternalMcpStartupSyncOnceForTests();
restore();
}
});
});
describe('syncExternalMcpStartupState generation guard', () => {
it('skips stale startup enable after concurrent top-bar toggle generation bump', async () => {
const restore = installMemoryLocalStorage();
@@ -64,6 +64,7 @@ export function resetExternalMcpStartupReadyForTests(): void {
externalMcpStartupReadyWaitPromise = null;
externalMcpStartupReadyWaitResolve = null;
externalMcpEnableGeneration = 0;
externalMcpStartupSyncPromise = null;
}
export function getExternalMcpEnableGenerationForTests(): number {
@@ -310,6 +311,26 @@ export async function syncExternalMcpStartupState(
return plan;
}
let externalMcpStartupSyncPromise: Promise<ExternalMcpStartupSyncPlan> | null = null;
/**
* Startup reconcile runs exactly once per renderer load. A remounted App must
* await the same settled promise instead of pushing a second enable/disable to
* the main process, which would fight the user's live toggle.
*/
export function syncExternalMcpStartupStateOnce(
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
): Promise<ExternalMcpStartupSyncPlan> {
if (!externalMcpStartupSyncPromise) {
externalMcpStartupSyncPromise = syncExternalMcpStartupState(bridge);
}
return externalMcpStartupSyncPromise;
}
export function resetExternalMcpStartupSyncOnceForTests(): void {
externalMcpStartupSyncPromise = null;
}
export function useExternalMcpToggleState() {
// UI mirrors the stored switch. Startup reconcile (App mount, main window only)
// decides whether temporary mode should clear/persist and start the runtime.
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
const appSource = [
readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('../app/hosts/TerminalHost.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('../app/hosts/ChromeHost.tsx', import.meta.url), 'utf8'),
].join('\n');
const hookSource = readFileSync(new URL('./useManualTerminalChromeSurfaceInjection.ts', import.meta.url), 'utf8');
const bridgeSource = readFileSync(new URL('../../components/terminalLayer/TerminalLayerTabBridge.tsx', import.meta.url), 'utf8');
const varsSource = readFileSync(new URL('../../infrastructure/theme/terminalAppearanceVars.ts', import.meta.url), 'utf8');
@@ -12,7 +16,9 @@ test('manual mode injects chrome surfaces from focused session theme', () => {
assert.match(bridgeSource, /useManualTerminalChromeSurfaceInjection/);
assert.match(bridgeSource, /!s\.followAppTerminalTheme && isTerminalLayerVisible/);
assert.match(readFileSync(new URL('../app/AppHostTreeLayer.tsx', import.meta.url), 'utf8'), /useManualTerminalChromeSurfaceInjection/);
assert.match(appSource, /resolveSessionAppearance=\{resolveFocusedAppearance\}/);
// App feeds the focused-session resolver to AppActiveTabChrome through the
// memoized shell chrome bag (AppShell spreads it onto the chrome component).
assert.match(appSource, /resolveSessionAppearance: resolveFocusedAppearance/);
assert.match(hookSource, /applyTopTabsChromeThemeVars\(theme\)/);
assert.match(hookSource, /injectTerminalLayerChromeSurfaceVars\(theme\)/);
assert.match(hookSource, /if \(wasEnabled\) \{[\s\S]*clearTerminalLayerChromeSurfaceVars\(\)/);
+153 -8
View File
@@ -2,6 +2,14 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
import { publishAppearanceChromeSnapshot } from './appearanceChromeStore';
import {
publishSettingsChromeSnapshot,
registerSettingsChromeActions,
} from './settingsChromeStore';
import {
publishTerminalSettingsSnapshot,
registerTerminalSettingsActions,
} from './terminalSettingsStore';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
DEFAULT_HTTP_NETWORK_PROXY,
@@ -748,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]);
@@ -1634,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)
@@ -1709,6 +1722,142 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const setEditorWordWrap = useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]);
// Main-window chrome subscribes to this slice instead of receiving the whole
// settings object through the App domain bags. The store dedupes field by
// field, so unrelated settings churn never notifies chrome consumers.
useLayoutEffect(() => {
publishSettingsChromeSnapshot({
theme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
uiLanguage,
windowOpacity,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
hostClickBehavior,
showOnlyUngroupedHostsInRoot,
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
disableTerminalFontZoom,
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
});
}, [
darkUiThemeId,
disableTerminalFontZoom,
hostClickBehavior,
lightUiThemeId,
resolvedTheme,
restoreTerminalCwd,
showHostTreeSidebar,
showOnlyUngroupedHostsInRoot,
showRecentHosts,
showSftpTab,
terminalSettings.dynamicTabTitleMode,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
theme,
uiLanguage,
windowOpacity,
]);
useLayoutEffect(() => {
registerSettingsChromeActions({ setTheme, setWindowOpacity });
return () => {
registerSettingsChromeActions(null);
};
}, [setTheme, setWindowOpacity]);
// TerminalHost / terminal domain bags subscribe here instead of receiving
// settings through the App mega-subscriber.
useLayoutEffect(() => {
publishTerminalSettingsSnapshot({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
terminalFontFamilyId,
terminalFontSize,
terminalSettings,
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
sftpDefaultViewMode,
editorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
});
}, [
editorWordWrap,
followAppTerminalTheme,
hotkeyScheme,
isHotkeyRecording,
keyBindings,
sessionLogsDir,
sessionLogsEnabled,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sftpAutoOpenSidebar,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sshDebugLogsEnabled,
terminalFontFamilyId,
terminalFontSize,
terminalSettings,
terminalThemeDarkId,
terminalThemeId,
terminalThemeLightId,
]);
useLayoutEffect(() => {
registerTerminalSettingsActions({
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
setTerminalFontFamilyId,
setTerminalFontSize,
updateTerminalSetting,
setSftpFollowTerminalCwd,
setEditorWordWrap,
applyAppTheme,
});
return () => {
registerTerminalSettingsActions(null);
};
}, [
applyAppTheme,
setEditorWordWrap,
setSftpFollowTerminalCwd,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,
setTerminalThemeId,
setTerminalThemeLightId,
updateTerminalSetting,
]);
return {
theme,
setTheme,
@@ -1798,11 +1947,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshTransportIdleTtlMs,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]),
setEditorWordWrap,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
@@ -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,
+8
View File
@@ -667,7 +667,9 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; enabled?
hasCheckedOnStartupRef.current = true;
debugLog('Starting delayed update check for version:', updateState.currentVersion);
let checkArmed = true;
startupCheckTimeoutRef.current = setTimeout(async () => {
checkArmed = false;
// Re-check the toggle at fire time — the user may have toggled it
// after the timer was scheduled.
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
@@ -711,6 +713,12 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; enabled?
return () => {
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// A remount clears the timer before it fires — allow the next effect to
// schedule again instead of permanently skipping the check.
if (checkArmed) {
hasCheckedOnStartupRef.current = false;
}
};
}, [enabled, updateState.currentVersion, autoUpdateEnabled, performCheck]);
+16 -1
View File
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { haveSameVaultAgentSnapshot, resolveVaultAgentEffectiveHost } from './useVaultAgentBridge';
import {
haveSameVaultAgentSnapshot,
resolveVaultAgentEffectiveHost,
resolveVaultAgentNotes,
} from './useVaultAgentBridge';
import { publishNotesSnapshot } from './notesStore';
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
@@ -34,3 +39,13 @@ describe('resolveVaultAgentEffectiveHost', () => {
);
});
});
describe('resolveVaultAgentNotes', () => {
it('prefers the live notes store when App omitted the notes prop', () => {
const stale: Snapshot['notes'] = [];
const live = [{ id: 'n1', title: 'Live', content: 'x', updatedAt: 1 }] as Snapshot['notes'];
publishNotesSnapshot({ notes: live, noteGroups: [] });
assert.equal(resolveVaultAgentNotes(undefined, stale as never), live);
assert.equal(resolveVaultAgentNotes(stale as never, stale as never), stale);
});
});
+40 -5
View File
@@ -15,6 +15,7 @@ import {
rememberKeyPassphrase,
resolveDefaultKeyPassphraseAliases,
} from '../defaultKeyPassphrases';
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
export interface UseVaultAgentBridgeInput {
hosts: Host[];
@@ -35,8 +36,10 @@ export interface UseVaultAgentBridgeInput {
updateGroupConfigs: (configs: GroupConfig[]) => void;
updateManagedSources: (sources: ManagedSource[]) => void;
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
notes: VaultNote[];
updateNotes: (notes: VaultNote[]) => void;
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
notes?: VaultNote[];
/** Optional override; defaults to notesStore actions. */
updateNotes?: (notes: VaultNote[]) => void;
startTunnel: VaultAgentApiDeps['startTunnel'];
stopTunnel: VaultAgentApiDeps['stopTunnel'];
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
@@ -59,7 +62,7 @@ type VaultAgentSnapshot = {
const selectVaultAgentSnapshot = (input: UseVaultAgentBridgeInput): VaultAgentSnapshot => ({
hosts: input.hosts,
keys: input.keys,
notes: input.notes,
notes: input.notes ?? (getNotesSnapshot().notes as VaultNote[]),
snippets: input.snippets,
customGroups: input.customGroups,
groupConfigs: input.groupConfigs,
@@ -67,6 +70,16 @@ const selectVaultAgentSnapshot = (input: UseVaultAgentBridgeInput): VaultAgentSn
managedSources: input.managedSources,
});
/** Resolve notes for agent ops: live store wins when App omitted the prop. */
export function resolveVaultAgentNotes(
notesOverride: VaultNote[] | undefined,
snapshotNotes: VaultNote[],
): VaultNote[] {
return notesOverride !== undefined
? snapshotNotes
: (getNotesSnapshot().notes as VaultNote[]);
}
export const haveSameVaultAgentSnapshot = (
left: VaultAgentSnapshot,
right: VaultAgentSnapshot,
@@ -102,12 +115,34 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
lastSyncedVaultInputRef.current = selectedSnapshot;
}
// Keep notes fresh without forcing App to re-render on every note edit.
// Write in place: this object is also `lastSyncedVaultInputRef.current`, and
// the agent op handlers below rely on that aliasing so their writes survive
// the render-time comparison above. Replacing the object would break it.
useEffect(() => {
if (input.notes !== undefined) return;
const syncNotes = () => {
vaultSnapshotRef.current.notes = getNotesSnapshot().notes as VaultNote[];
};
syncNotes();
return subscribeNotes(syncNotes);
}, [input.notes]);
useEffect(() => {
registerVaultAgentHandler(async (op, params) => {
const current = inputRef.current;
const applyUpdateNotes = current.updateNotes
?? getNotesActions()?.updateNotes
?? ((notes: VaultNote[]) => {
console.warn('[useVaultAgentBridge] updateNotes unavailable');
void notes;
});
return handleVaultAgentOp(op, params, {
getHosts: () => vaultSnapshotRef.current.hosts,
getNotes: () => vaultSnapshotRef.current.notes,
getNotes: () => resolveVaultAgentNotes(
current.notes,
vaultSnapshotRef.current.notes,
),
getCustomGroups: () => vaultSnapshotRef.current.customGroups,
getGroupConfigs: () => vaultSnapshotRef.current.groupConfigs,
getPortForwardingRules: () => vaultSnapshotRef.current.portForwardingRules,
@@ -185,7 +220,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
}),
updateNotes: (notes) => {
vaultSnapshotRef.current.notes = notes;
current.updateNotes(notes);
applyUpdateNotes(notes);
},
updateSnippets: (nextSnippets) => {
vaultSnapshotRef.current.snippets = nextSnippets;
+45 -3
View File
@@ -65,6 +65,10 @@ import {
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishConnectionLogsSnapshot,
registerConnectionLogsActions,
} from "./connectionLogsStore";
import {
publishNotesSnapshot,
registerNotesActions,
@@ -968,6 +972,28 @@ export const useVaultState = () => {
});
}, [persistConnectionLogState]);
// Connection logs for Vault logs section — keep App domain bags off session churn.
useLayoutEffect(() => {
publishConnectionLogsSnapshot({ connectionLogs });
}, [connectionLogs]);
useLayoutEffect(() => {
registerConnectionLogsActions({
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
});
return () => {
registerConnectionLogsActions(null);
};
}, [
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
]);
// Convert a known host to a managed host
const convertKnownHostToHost = useCallback((knownHost: KnownHost): Host => {
const newHost: Host = {
@@ -1013,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) {
@@ -1026,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(
@@ -1072,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);
@@ -1092,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);
@@ -1107,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);
@@ -1117,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 =
@@ -1193,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);
@@ -1203,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(() => {
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Host } from '../../domain/models.ts';
import {
EMPTY_VAULT_SNAPSHOT,
getVaultSnapshot,
getVaultSnapshotActions,
publishVaultSnapshot,
registerVaultSnapshotActions,
subscribeVaultSnapshot,
vaultSnapshotsEqual,
type VaultSnapshotActions,
} from './vaultSnapshotStore.ts';
function makeHost(id: string): Host {
return { id, label: id, hostname: `${id}.example`, username: 'root' } as Host;
}
test('vaultSnapshotStore notifies only when a field identity changes', () => {
const seen: number[] = [];
const unsubscribe = subscribeVaultSnapshot(() => {
seen.push(getVaultSnapshot().hosts.length);
});
const hosts = [makeHost('h1')];
const snapshot = { ...EMPTY_VAULT_SNAPSHOT, isVaultInitialized: true, hosts };
publishVaultSnapshot(snapshot);
assert.deepEqual(seen, [1]);
assert.equal(getVaultSnapshot().hosts, hosts);
assert.equal(getVaultSnapshot().isVaultInitialized, true);
// Same field identities in a fresh object must not wake subscribers.
publishVaultSnapshot({ ...snapshot });
assert.deepEqual(seen, [1]);
const moreHosts = [...hosts, makeHost('h2')];
publishVaultSnapshot({ ...snapshot, hosts: moreHosts });
assert.deepEqual(seen, [1, 2]);
unsubscribe();
publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT);
assert.deepEqual(seen, [1, 2]);
});
test('vaultSnapshotsEqual compares every published field', () => {
assert.equal(vaultSnapshotsEqual(EMPTY_VAULT_SNAPSHOT, { ...EMPTY_VAULT_SNAPSHOT }), true);
assert.equal(
vaultSnapshotsEqual(EMPTY_VAULT_SNAPSHOT, { ...EMPTY_VAULT_SNAPSHOT, groupConfigs: [] }),
false,
);
assert.equal(
vaultSnapshotsEqual(EMPTY_VAULT_SNAPSHOT, {
...EMPTY_VAULT_SNAPSHOT,
isVaultInitialized: true,
}),
false,
);
});
test('vault actions register and unregister', () => {
assert.equal(getVaultSnapshotActions(), null);
const calls: string[] = [];
const noop = (() => {}) as never;
registerVaultSnapshotActions({
updateHosts: ((() => {
calls.push('hosts');
}) as unknown) as VaultSnapshotActions['updateHosts'],
updateKeys: noop,
importOrReuseKey: noop,
updateIdentities: noop,
updateProxyProfiles: noop,
updateSnippets: noop,
updateSnippetPackages: noop,
updateCustomGroups: noop,
updateKnownHosts: noop,
updateManagedSources: noop,
updateGroupConfigs: noop,
convertKnownHostToHost: noop,
readPersistedHosts: noop,
readPersistedManagedSources: noop,
commitPluginImporterData: noop,
commitVaultImportTransaction: noop,
updateHostDistro: noop,
updateHostLastConnected: noop,
addShellHistoryEntry: noop,
removeShellHistoryEntry: noop,
});
getVaultSnapshotActions()?.updateHosts([]);
assert.deepEqual(calls, ['hosts']);
registerVaultSnapshotActions(null);
assert.equal(getVaultSnapshotActions(), null);
});
+193
View File
@@ -0,0 +1,193 @@
import { useSyncExternalStore } from 'react';
import type {
GroupConfig,
Host,
Identity,
KnownHost,
ManagedSource,
ProxyProfile,
Snippet,
SSHKey,
} from '../../domain/models';
import type { useVaultState } from './useVaultState';
type VaultState = ReturnType<typeof useVaultState>;
type Listener = () => void;
/**
* The vault catalog every shell surface reads. Notes, note groups, connection
* logs and shell history are deliberately absent: they have their own stores
* (`notesStore`, `connectionLogsStore`, `shellHistoryStore`) because they churn
* on a different cadence than the vault catalog.
*/
export type VaultSnapshot = {
isVaultInitialized: boolean;
hosts: readonly Host[];
keys: readonly SSHKey[];
identities: readonly Identity[];
proxyProfiles: readonly ProxyProfile[];
snippets: readonly Snippet[];
snippetPackages: readonly string[];
customGroups: readonly string[];
knownHosts: readonly KnownHost[];
managedSources: readonly ManagedSource[];
groupConfigs: readonly GroupConfig[];
};
/**
* Vault mutators the shell needs. Derived from the hook return so the store
* contract cannot drift from `useVaultState`.
*/
export type VaultSnapshotActions = Pick<
VaultState,
| 'updateHosts'
| 'updateKeys'
| 'importOrReuseKey'
| 'updateIdentities'
| 'updateProxyProfiles'
| 'updateSnippets'
| 'updateSnippetPackages'
| 'updateCustomGroups'
| 'updateKnownHosts'
| 'updateManagedSources'
| 'updateGroupConfigs'
| 'convertKnownHostToHost'
| 'readPersistedHosts'
| 'readPersistedManagedSources'
| 'commitPluginImporterData'
| 'commitVaultImportTransaction'
| 'updateHostDistro'
| 'updateHostLastConnected'
| 'addShellHistoryEntry'
| 'removeShellHistoryEntry'
>;
const EMPTY_HOSTS: readonly Host[] = Object.freeze([]);
const EMPTY_KEYS: readonly SSHKey[] = Object.freeze([]);
const EMPTY_IDENTITIES: readonly Identity[] = Object.freeze([]);
const EMPTY_PROXY_PROFILES: readonly ProxyProfile[] = Object.freeze([]);
const EMPTY_SNIPPETS: readonly Snippet[] = Object.freeze([]);
const EMPTY_STRINGS: readonly string[] = Object.freeze([]);
const EMPTY_KNOWN_HOSTS: readonly KnownHost[] = Object.freeze([]);
const EMPTY_MANAGED_SOURCES: readonly ManagedSource[] = Object.freeze([]);
const EMPTY_GROUP_CONFIGS: readonly GroupConfig[] = Object.freeze([]);
export const EMPTY_VAULT_SNAPSHOT: VaultSnapshot = Object.freeze({
isVaultInitialized: false,
hosts: EMPTY_HOSTS,
keys: EMPTY_KEYS,
identities: EMPTY_IDENTITIES,
proxyProfiles: EMPTY_PROXY_PROFILES,
snippets: EMPTY_SNIPPETS,
snippetPackages: EMPTY_STRINGS,
customGroups: EMPTY_STRINGS,
knownHosts: EMPTY_KNOWN_HOSTS,
managedSources: EMPTY_MANAGED_SOURCES,
groupConfigs: EMPTY_GROUP_CONFIGS,
});
const SNAPSHOT_KEYS = Object.keys(EMPTY_VAULT_SNAPSHOT) as (keyof VaultSnapshot)[];
export function vaultSnapshotsEqual(prev: VaultSnapshot, next: VaultSnapshot): boolean {
return SNAPSHOT_KEYS.every((key) => prev[key] === next[key]);
}
/**
* External store for the vault catalog so hosts, vault surfaces and the
* terminal layer can subscribe to exactly the slice they render instead of
* receiving it through the App domain bags. Values and actions live in
* separate slots (same shape as `notesStore`) so an unstable setter identity
* cannot invalidate the value snapshot.
*/
class VaultSnapshotStore {
private snapshot: VaultSnapshot = EMPTY_VAULT_SNAPSHOT;
private actions: VaultSnapshotActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): VaultSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: VaultSnapshot): void {
if (vaultSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): VaultSnapshotActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: VaultSnapshotActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const vaultSnapshotStore = new VaultSnapshotStore();
export function publishVaultSnapshot(snapshot: VaultSnapshot): void {
vaultSnapshotStore.setSnapshot(snapshot);
}
export function getVaultSnapshot(): VaultSnapshot {
return vaultSnapshotStore.getSnapshot();
}
export function subscribeVaultSnapshot(listener: Listener): () => void {
return vaultSnapshotStore.subscribe(listener);
}
export function registerVaultSnapshotActions(actions: VaultSnapshotActions | null): void {
vaultSnapshotStore.setActions(actions);
}
export function getVaultSnapshotActions(): VaultSnapshotActions | null {
return vaultSnapshotStore.getActions();
}
export function subscribeVaultSnapshotActions(listener: Listener): () => void {
return vaultSnapshotStore.subscribeActions(listener);
}
/** Subscribe to the whole vault catalog. Prefer a narrower selector hook. */
export function useVaultSnapshot(): VaultSnapshot {
return useSyncExternalStore(subscribeVaultSnapshot, getVaultSnapshot, getVaultSnapshot);
}
/**
* Subscribe to a single vault field. Consumers that only render hosts must not
* re-render when unrelated vault slices settle.
*/
export function useVaultSnapshotField<K extends keyof VaultSnapshot>(
field: K,
): VaultSnapshot[K] {
const read = () => getVaultSnapshot()[field];
return useSyncExternalStore(subscribeVaultSnapshot, read, read);
}
export function useVaultSnapshotActions(): VaultSnapshotActions | null {
return useSyncExternalStore(
subscribeVaultSnapshotActions,
getVaultSnapshotActions,
getVaultSnapshotActions,
);
}
+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;
+12 -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);
@@ -3167,6 +3171,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
retryTokenRef.current = null;
reconnectPreparationTokenRef.current = null;
restoreCwdIntentRef.current = null;
// Cancel must invalidate the boot the same way Disconnect does. Unmount is
// no longer the only path that flips boot-active: under StrictMode the pane
// can survive this click, so a late startSSH/startMosh attach could revive
// the aborted attempt. Bump the epoch here and let closeSession target the
// pre-bump epoch.
invalidateBootEpochForClose();
isBootActiveRef.current = false;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
@@ -4007,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 (
<>
+9 -4
View File
@@ -45,7 +45,12 @@ const topTabsQuickControlsSource = readFileSync(new URL("./TopTabsQuickControls.
const syncStatusButtonSource = readFileSync(new URL("./SyncStatusButton.tsx", import.meta.url), "utf8");
const switchSource = readFileSync(new URL("./ui/switch.tsx", import.meta.url), "utf8");
const appViewSource = readFileSync(new URL("../application/app/AppView.tsx", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
const appSource = [
readFileSync(new URL("../App.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../application/app/AppSideEffects.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../application/app/hosts/TerminalHost.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../application/app/hosts/ChromeHost.tsx", import.meta.url), "utf8"),
].join("\n");
const externalMcpToggleSource = readFileSync(new URL("../application/state/useExternalMcpToggleState.ts", import.meta.url), "utf8");
const zhTwAiSource = readFileSync(new URL("../application/i18n/locales/zh-TW/ai.ts", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
@@ -185,12 +190,12 @@ test("AppView hides External MCP toggle in peer session windows", () => {
});
test("External MCP top-bar status sync waits for App startup reconcile", () => {
assert.match(appSource, /await syncExternalMcpStartupState\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /await syncExternalMcpStartupStateOnce\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /markExternalMcpStartupReady\(\)/);
assert.ok(
appSource.indexOf("await syncExternalMcpStartupState(netcattyBridge.get())")
appSource.indexOf("await syncExternalMcpStartupStateOnce(netcattyBridge.get())")
< appSource.indexOf("markExternalMcpStartupReady()"),
"startup ready must be marked only after await syncExternalMcpStartupState",
"startup ready must be marked only after await syncExternalMcpStartupStateOnce",
);
assert.match(externalMcpToggleSource, /shouldWaitForExternalMcpStartupReady/);
assert.match(externalMcpToggleSource, /waitForExternalMcpStartupReady\(/);
+59 -26
View File
@@ -1,7 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { syncVaultViewMemoSection, vaultViewAreEqual } from "./VaultView.tsx";
import { vaultViewAreEqual } from "./VaultView.tsx";
const vaultViewSource = readFileSync(
new URL("./VaultView.tsx", import.meta.url),
"utf8",
);
const vaultViewLayoutSource = readFileSync(
new URL("./vault/VaultViewLayout.tsx", import.meta.url),
"utf8",
);
const baseProps = {
hosts: [],
@@ -10,11 +20,8 @@ const baseProps = {
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
@@ -79,34 +86,60 @@ test("VaultView re-renders when host-key verification setting changes", () => {
);
});
test("VaultView ignores connectionLogs identity when not on logs section", () => {
syncVaultViewMemoSection("hosts");
const nextLogs = [{ id: "log-1" }];
assert.equal(
vaultViewAreEqual(
baseProps as never,
{ ...baseProps, connectionLogs: nextLogs } as never,
),
true,
test("notes are not VaultView props — the notes section reads notesStore", () => {
// Note edits must not churn the App vault domain bag, so VaultView neither
// receives notes nor compares them.
for (const removed of [
"notes:",
"noteGroups:",
"onUpdateNotes:",
"onUpdateNoteGroups:",
]) {
assert.equal(
vaultViewSource.includes(removed),
false,
`VaultView must not declare ${removed}`,
);
}
assert.doesNotMatch(vaultViewSource, /prev\.notes === next\.notes/);
assert.match(
vaultViewLayoutSource,
/useNotesStore\(\{\s*enabled:\s*isActive,?\s*\}\)/,
);
assert.match(vaultViewLayoutSource, /function VaultNotesSection/);
});
test("VaultView compares connectionLogs identity when on logs section", () => {
syncVaultViewMemoSection("logs");
const nextLogs = [{ id: "log-2" }];
test("connectionLogs are not VaultView props — the logs section reads connectionLogsStore", () => {
for (const removed of [
"connectionLogs:",
"onToggleConnectionLogSaved:",
"onDeleteConnectionLog:",
"onClearUnsavedConnectionLogs:",
]) {
assert.equal(
vaultViewSource.includes(removed),
false,
`VaultView must not declare ${removed}`,
);
}
// The section-gated connectionLogs memo escape hatch is gone with the prop.
assert.equal(vaultViewSource.includes("syncVaultViewMemoSection"), false);
assert.equal(vaultViewSource.includes("vaultViewLatestConnectionLogs"), false);
assert.match(vaultViewLayoutSource, /useConnectionLogsStore\(\)/);
assert.match(vaultViewLayoutSource, /function VaultConnectionLogsSection/);
});
test("notes and connectionLogs churn cannot re-render VaultView", () => {
// Even if a caller smuggles them in, areEqual ignores both.
assert.equal(
vaultViewAreEqual(
baseProps as never,
{ ...baseProps, connectionLogs: nextLogs } as never,
),
false,
);
assert.equal(
vaultViewAreEqual(
{ ...baseProps, connectionLogs: nextLogs } as never,
{ ...baseProps, connectionLogs: nextLogs } as never,
{ ...baseProps, notes: [], connectionLogs: [] } as never,
{
...baseProps,
notes: [{ id: "n1" }],
connectionLogs: [{ id: "log-1" }],
} as never,
),
true,
);
syncVaultViewMemoSection("hosts");
});
@@ -71,12 +71,8 @@ const renderVault = (sortMode: string | null, hosts: Host[]) => {
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
shellHistory: [],
connectionLogs: [],
managedSources: [],
sessionCount: 0,
hotkeyScheme: "mac",
@@ -95,15 +91,10 @@ const renderVault = (sortMode: string | null, hosts: Host[]) => {
onUpdateProxyProfiles: noop,
onUpdateSnippets: noop,
onUpdateSnippetPackages: noop,
onUpdateNotes: noop,
onUpdateNoteGroups: noop,
onUpdateCustomGroups: noop,
onUpdateKnownHosts: noop,
onUpdateManagedSources: noop,
onConvertKnownHost: noop,
onToggleConnectionLogSaved: noop,
onDeleteConnectionLog: noop,
onClearUnsavedConnectionLogs: noop,
onOpenLogView: noop,
groupConfigs: [],
onUpdateGroupConfigs: noop,
+6 -47
View File
@@ -93,7 +93,6 @@ import {
SerialConfig,
SSHKey,
Snippet,
VaultNote,
} from "../types";
import { AppLogo } from "./AppLogo";
import { connectHostsStaggered } from "./connectHostsStaggered";
@@ -167,16 +166,6 @@ export type VaultSection =
| "knownhosts"
| "logs";
/** Memo section tracker: areEqual ignores connectionLogs unless on logs. */
let vaultViewMemoSection: VaultSection = "hosts";
/** Always stashed from areEqual so skipped updates stay available when opening logs. */
let vaultViewLatestConnectionLogs: ConnectionLog[] | null = null;
/** Test/helper: sync the section used by vaultViewAreEqual. */
export function syncVaultViewMemoSection(section: VaultSection): void {
vaultViewMemoSection = section;
}
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
if (previous.length !== next.length) return false;
return next.every((host, index) => {
@@ -220,11 +209,8 @@ interface VaultViewProps {
proxyProfiles: ProxyProfile[];
snippets: Snippet[];
snippetPackages: string[];
notes: VaultNote[];
noteGroups: string[];
customGroups: string[];
knownHosts: KnownHost[];
connectionLogs: ConnectionLog[];
managedSources: ManagedSource[];
sessionCount: number;
hotkeyScheme: HotkeyScheme;
@@ -249,8 +235,6 @@ interface VaultViewProps {
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateSnippetPackages: (pkgs: string[]) => void;
onUpdateNotes: (notes: VaultNote[]) => void;
onUpdateNoteGroups: (groups: string[]) => void;
onUpdateCustomGroups: (
groups: string[] | ((current: string[]) => string[]),
) => void;
@@ -280,9 +264,6 @@ interface VaultViewProps {
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
onUnmanageSource?: (sourceId: string) => void;
onConvertKnownHost: (knownHost: KnownHost) => void;
onToggleConnectionLogSaved: (id: string) => void;
onDeleteConnectionLog: (id: string) => void;
onClearUnsavedConnectionLogs: () => void;
onOpenLogView: (log: ConnectionLog) => void;
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
groupConfigs: GroupConfig[];
@@ -314,11 +295,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
proxyProfiles,
snippets,
snippetPackages,
notes,
noteGroups,
customGroups,
knownHosts,
connectionLogs,
managedSources,
sessionCount,
hotkeyScheme,
@@ -340,8 +318,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateProxyProfiles,
onUpdateSnippets,
onUpdateSnippetPackages,
onUpdateNotes,
onUpdateNoteGroups,
onUpdateCustomGroups,
onCommitPluginImporterData,
onUpdateKnownHosts,
@@ -352,9 +328,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onClearAndRemoveManagedSources,
onUnmanageSource,
onConvertKnownHost,
onToggleConnectionLogSaved,
onDeleteConnectionLog,
onClearUnsavedConnectionLogs,
onOpenLogView,
onRunSnippet,
groupConfigs,
@@ -375,8 +348,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
vaultViewMemoSection = currentSection;
const resolvedConnectionLogs = vaultViewLatestConnectionLogs ?? connectionLogs;
const [search, setSearch] = useState("");
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
null,
@@ -474,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);
@@ -1427,7 +1401,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Clock,
cn,
commitInlineGroupRename,
connectionLogs: resolvedConnectionLogs,
connectSelectedHosts,
ContextMenu,
ContextMenuContent,
@@ -1525,34 +1498,27 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Network,
newFolderName,
newHostGroupPath,
noteGroups,
NotebookText,
notes,
NotesManager,
onCommitPluginImporterData,
onClearUnsavedConnectionLogs,
onConnectSerial,
onCreateLocalTerminal,
onDeleteConnectionLog,
onDeleteHost,
onImportOrReuseKey,
onOpenHostFromNote,
onOpenLogView,
onOpenSettings,
onRunSnippet,
onToggleConnectionLogSaved,
onUpdateCustomGroups,
onUpdateGroupConfigs,
onUpdateHosts,
onUpdateIdentities,
onUpdateKeys,
onUpdateNoteGroups,
onUpdateNotes,
onUpdateProxyProfiles,
onUpdateSnippetPackages,
onUpdateSnippets,
openNoteId: pendingOpenNoteId,
onOpenNoteIdHandled: () => setPendingOpenNoteId(null),
onOpenNoteIdHandled: handleOpenNoteIdHandled,
openSnippetId: pendingOpenSnippetId,
onOpenSnippetIdHandled: () => setPendingOpenSnippetId(null),
Pin,
@@ -1680,11 +1646,6 @@ export const vaultViewAreEqual = (
prev: VaultViewProps,
next: VaultViewProps,
): boolean => {
vaultViewLatestConnectionLogs = next.connectionLogs;
const connectionLogsEqual =
vaultViewMemoSection === "logs"
? prev.connectionLogs === next.connectionLogs
: true;
const isEqual =
prev.hosts === next.hosts &&
prev.keys === next.keys &&
@@ -1692,12 +1653,10 @@ export const vaultViewAreEqual = (
prev.proxyProfiles === next.proxyProfiles &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.notes === next.notes &&
prev.noteGroups === next.noteGroups &&
prev.customGroups === next.customGroups &&
prev.knownHosts === next.knownHosts &&
// shellHistory is not a VaultView prop; SnippetsManager reads shellHistoryStore.
connectionLogsEqual &&
// shellHistory / notes / connectionLogs are not VaultView props; those
// sections subscribe to shellHistoryStore / notesStore / connectionLogsStore.
prev.sessionCount === next.sessionCount &&
prev.managedSources === next.managedSources &&
prev.groupConfigs === next.groupConfigs &&
+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);
}
+5 -3
View File
@@ -22,11 +22,13 @@ function functionBody(source: string, functionName: string): string {
}
test("host and AI provider deletion use in-app confirmation dialogs", () => {
const appSource = readProjectFile("App.tsx");
const appSource = readProjectFile("application/app/AppSideEffects.tsx");
// App owns the confirm state; AppShell renders the dialog.
const appShellSource = readProjectFile("application/app/AppShell.tsx");
const aiSettingsSource = readProjectFile("components/settings/tabs/SettingsAITab.tsx");
assert.match(appSource, /import \{ ConfirmDialog \} from '\.\/components\/ui\/confirm-dialog';/);
assert.match(appSource, /<ConfirmDialog[\s\S]*confirm\.deleteHost/);
assert.match(appShellSource, /import \{ ConfirmDialog \} from '\.\.\/\.\.\/components\/ui\/confirm-dialog';/);
assert.match(appShellSource, /<ConfirmDialog[\s\S]*confirm\.deleteHost/);
assert.doesNotMatch(functionBody(appSource, "handleDeleteHost"), /window\.confirm|globalThis\.confirm|\bconfirm\(/);
assert.match(aiSettingsSource, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
+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)}
+52 -18
View File
@@ -110,7 +110,7 @@ export const useActiveTabId = (side: "left" | "right"): string | null => {
);
};
export interface SftpContextValue {
export interface SftpHostsContextValue {
// Hosts list for connection picker
hosts: Host[];
// Live terminal sessions that can be reused for SFTP (shown in picker).
@@ -119,31 +119,44 @@ export interface SftpContextValue {
writableHosts: Host[];
// Host updater for bookmark persistence
updateHosts: (hosts: Host[]) => void;
}
// Callbacks for each side
export interface SftpPaneCallbacksContextValue {
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
}
/** @deprecated Prefer useSftpHosts / useSftpPaneCallbacks to avoid cross-churn. */
export type SftpContextValue = SftpHostsContextValue & SftpPaneCallbacksContextValue;
export interface SftpDragContextValue {
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
}
const SftpContext = createContext<SftpContextValue | null>(null);
const SftpHostsContext = createContext<SftpHostsContextValue | null>(null);
const SftpPaneCallbacksContext = createContext<SftpPaneCallbacksContextValue | null>(null);
const SftpDragContext = createContext<SftpDragContextValue | null>(null);
export const useSftpContext = () => {
const context = useContext(SftpContext);
if (!context) {
/** @deprecated Prefer selective hooks; this re-renders on hosts OR callbacks churn. */
export const useSftpContext = (): SftpContextValue => {
const hosts = useContext(SftpHostsContext);
const callbacks = useContext(SftpPaneCallbacksContext);
if (!hosts || !callbacks) {
throw new Error("useSftpContext must be used within SftpContextProvider");
}
return context;
return useMemo(
() => ({ ...hosts, ...callbacks }),
[hosts, callbacks],
);
};
// Hook to get callbacks for a specific side
export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks => {
const context = useSftpContext();
const context = useContext(SftpPaneCallbacksContext);
if (!context) {
throw new Error("useSftpPaneCallbacks must be used within SftpContextProvider");
}
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
};
@@ -164,25 +177,37 @@ export const useSftpDrag = () => {
// Hook to get hosts
export const useSftpHosts = () => {
const context = useSftpContext();
const context = useContext(SftpHostsContext);
if (!context) {
throw new Error("useSftpHosts must be used within SftpContextProvider");
}
return context.hosts;
};
// Hook to get currently connected terminal hosts for the picker
export const useSftpConnectedHosts = () => {
const context = useSftpContext();
const context = useContext(SftpHostsContext);
if (!context) {
throw new Error("useSftpConnectedHosts must be used within SftpContextProvider");
}
return context.connectedHosts;
};
// Hook to get raw hosts for writeback
export const useSftpWritableHosts = () => {
const context = useSftpContext();
const context = useContext(SftpHostsContext);
if (!context) {
throw new Error("useSftpWritableHosts must be used within SftpContextProvider");
}
return context.writableHosts;
};
// Hook to get host updater
export const useSftpUpdateHosts = () => {
const context = useSftpContext();
const context = useContext(SftpHostsContext);
if (!context) {
throw new Error("useSftpUpdateHosts must be used within SftpContextProvider");
}
return context.updateHosts;
};
@@ -209,17 +234,24 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
rightCallbacks,
children,
}) => {
// Memoize the main context value (no drag state, so drag changes won't cause re-renders here)
const value = useMemo<SftpContextValue>(
// Hosts and pane callbacks are separate so hosts churn does not invalidate
// callback consumers (and callback identity churn does not invalidate hosts).
const hostsValue = useMemo<SftpHostsContextValue>(
() => ({
hosts,
connectedHosts,
writableHosts: writableHosts ?? hosts,
updateHosts,
}),
[hosts, connectedHosts, writableHosts, updateHosts],
);
const callbacksValue = useMemo<SftpPaneCallbacksContextValue>(
() => ({
leftCallbacks,
rightCallbacks,
}),
[hosts, connectedHosts, writableHosts, updateHosts, leftCallbacks, rightCallbacks],
[leftCallbacks, rightCallbacks],
);
// Memoize drag context separately so only drag consumers re-render on drag state changes
@@ -232,8 +264,10 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
);
return (
<SftpContext.Provider value={value}>
<SftpDragContext.Provider value={dragValue}>{children}</SftpDragContext.Provider>
</SftpContext.Provider>
<SftpHostsContext.Provider value={hostsValue}>
<SftpPaneCallbacksContext.Provider value={callbacksValue}>
<SftpDragContext.Provider value={dragValue}>{children}</SftpDragContext.Provider>
</SftpPaneCallbacksContext.Provider>
</SftpHostsContext.Provider>
);
};
@@ -27,3 +27,18 @@ test("gpu tab passes null utilization to ResourceBar instead of zero", () => {
assert.doesNotMatch(source, /value=\{util \?\? 0\}/);
assert.doesNotMatch(source, /value=\{memPct \?\? 0\}/);
});
test("gpu tab keeps a compact sparkline history like overview/nvtop charts", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /HISTORY_LIMIT/);
assert.match(source, /GpuSparkline/);
assert.match(source, /historyByDevice/);
assert.match(source, /setHistoryByDevice/);
});
test("resource bar animates width and uses load-aware tones", () => {
const source = readFileSync(new URL("./ResourceBar.tsx", import.meta.url), "utf8");
assert.match(source, /transition-\[width,background-color\]/);
assert.match(source, /bg-amber-500/);
assert.match(source, /bg-destructive/);
});
+116 -16
View File
@@ -7,6 +7,7 @@ import type {
AcceleratorProcessInfo,
AcceleratorSnapshot,
} from '../../domain/systemManager/types';
import { cn } from '../../lib/utils';
import { ResourceBar } from './ResourceBar';
import {
SystemPanelEmpty,
@@ -31,6 +32,13 @@ interface GpuManagerTabProps {
refreshIntervalSec: number;
}
const HISTORY_LIMIT = 24;
interface DeviceHistorySample {
util: number;
memory: number;
}
function formatMb(mb: number | null | undefined): string {
if (!Number.isFinite(mb)) return '--';
const value = Number(mb);
@@ -53,14 +61,63 @@ function vendorLabel(
: t('systemManager.gpu.vendor.ascend');
}
function deviceKey(device: AcceleratorDeviceInfo): string {
return `${device.vendor}-${device.index}-${device.uuid || device.name}`;
}
/** Compact nvtop-style sparkline (area + stroke), reusing the overview chart feel. */
function GpuSparkline({
values,
className,
}: {
values: number[];
className?: string;
}) {
const width = 120;
const height = 28;
const safeValues = values.length > 1 ? values : [values[0] ?? 0, values[0] ?? 0];
const points = safeValues.map((value, index) => {
const x = safeValues.length === 1 ? width : (index / (safeValues.length - 1)) * width;
const clamped = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
const y = height - (clamped / 100) * (height - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const area = `M0,${height} L${points.join(' L')} L${width},${height} Z`;
return (
<svg
className={cn('h-7 w-full overflow-visible', className)}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-hidden
>
<path d={area} fill="currentColor" opacity="0.12" />
<polyline
points={points.join(' ')}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-opacity duration-300"
/>
</svg>
);
}
const DeviceCard = memo(function DeviceCard({
device,
utilHistory,
memHistory,
}: {
device: AcceleratorDeviceInfo;
utilHistory: number[];
memHistory: number[];
}) {
const { t } = useI18n();
const memPct = memoryPercent(device);
const util = Number.isFinite(device.utilizationPercent) ? Number(device.utilizationPercent) : null;
const tone = device.vendor === 'ascend' ? 'text-orange-500' : 'text-emerald-500';
return (
<div className="border-b border-border/30 px-3 py-2.5 space-y-2">
@@ -102,20 +159,35 @@ const DeviceCard = memo(function DeviceCard({
) : null}
</div>
</div>
<ResourceBar
label={t('systemManager.gpu.util')}
value={util}
/>
<div className="flex items-center gap-2 min-w-0">
<ResourceBar
label={device.vendor === 'ascend' ? t('systemManager.gpu.hbm') : t('systemManager.gpu.memory')}
value={memPct}
className="flex-1"
/>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{formatMb(device.memoryUsedMb)} / {formatMb(device.memoryTotalMb)}
</span>
<div className="grid grid-cols-[minmax(0,1fr)_72px] gap-2 items-end">
<div className="min-w-0 space-y-1.5">
<ResourceBar
label={t('systemManager.gpu.util')}
value={util}
size="md"
/>
<div className="flex items-center gap-2 min-w-0">
<ResourceBar
label={device.vendor === 'ascend' ? t('systemManager.gpu.hbm') : t('systemManager.gpu.memory')}
value={memPct}
className="flex-1"
size="md"
/>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{formatMb(device.memoryUsedMb)} / {formatMb(device.memoryTotalMb)}
</span>
</div>
</div>
<div className={cn('min-w-0', tone)} title={t('systemManager.gpu.util')}>
<GpuSparkline values={utilHistory.length ? utilHistory : [util ?? 0]} />
<GpuSparkline
values={memHistory.length ? memHistory : [memPct ?? 0]}
className="opacity-80"
/>
</div>
</div>
{Number.isFinite(device.fanPercent) ? (
<div className="text-[10px] text-muted-foreground">
{t('systemManager.gpu.fan', { value: Math.round(Number(device.fanPercent)) })}
@@ -155,9 +227,11 @@ export const GpuManagerTab = memo(function GpuManagerTab({
const stableT = useStableTranslate();
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
const [gpuListPending, setGpuListPending] = useState(false);
const [historyByDevice, setHistoryByDevice] = useState<Record<string, DeviceHistorySample[]>>({});
useEffect(() => {
setGpuListPending(false);
setHistoryByDevice({});
}, [sessionId]);
const fetcher = useCallback(async (): Promise<AcceleratorSnapshot | null> => {
@@ -190,6 +264,23 @@ export const GpuManagerTab = memo(function GpuManagerTab({
const processes = useMemo(() => data?.processes ?? [], [data?.processes]);
const isRefreshActive = loading || gpuListPending;
useEffect(() => {
if (!isVisible || !devices.length) return;
setHistoryByDevice((prev) => {
const next: Record<string, DeviceHistorySample[]> = { ...prev };
for (const device of devices) {
const key = deviceKey(device);
const sample: DeviceHistorySample = {
util: Number.isFinite(device.utilizationPercent) ? Number(device.utilizationPercent) : 0,
memory: memoryPercent(device) ?? 0,
};
const series = [...(next[key] || []), sample].slice(-HISTORY_LIMIT);
next[key] = series;
}
return next;
});
}, [devices, isVisible, data?.probedAt]);
const meta = useMemo(() => {
const nvidiaCount = devices.filter((d) => d.vendor === 'nvidia').length;
const ascendCount = devices.filter((d) => d.vendor === 'ascend').length;
@@ -269,9 +360,18 @@ export const GpuManagerTab = memo(function GpuManagerTab({
{t('systemManager.gpu.empty')}
</div>
) : (
devices.map((device) => (
<DeviceCard key={`${device.vendor}-${device.index}-${device.uuid || device.name}`} device={device} />
))
devices.map((device) => {
const key = deviceKey(device);
const series = historyByDevice[key] || [];
return (
<DeviceCard
key={key}
device={device}
utilHistory={series.map((sample) => sample.util)}
memHistory={series.map((sample) => sample.memory)}
/>
);
})
)}
<div className="px-3 pt-3 pb-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('systemManager.gpu.processes')}
+25 -6
View File
@@ -5,20 +5,39 @@ interface ResourceBarProps {
label: string;
value: number | null | undefined;
className?: string;
/** Slightly taller track for GPU cards. */
size?: 'sm' | 'md';
}
export const ResourceBar = memo(function ResourceBar({ label, value, className }: ResourceBarProps) {
function barTone(clamped: number): string {
if (clamped > 85) return 'bg-destructive/80';
if (clamped > 60) return 'bg-amber-500/80';
return 'bg-primary/75';
}
export const ResourceBar = memo(function ResourceBar({
label,
value,
className,
size = 'sm',
}: ResourceBarProps) {
const finite = typeof value === 'number' && Number.isFinite(value);
const clamped = finite ? Math.max(0, Math.min(100, value)) : 0;
return (
<div className={cn('flex items-center gap-2 min-w-0', className)}>
<span className="text-[10px] text-muted-foreground w-7 shrink-0">{label}</span>
<div className="flex-1 h-1 rounded-full bg-muted/50 overflow-hidden min-w-[48px]">
{label ? (
<span className="text-[10px] text-muted-foreground w-7 shrink-0">{label}</span>
) : null}
<div
className={cn(
'flex-1 rounded-full bg-muted/50 overflow-hidden min-w-[48px]',
size === 'md' ? 'h-1.5' : 'h-1',
)}
>
<div
className={cn(
'h-full rounded-full',
finite && clamped > 85 ? 'bg-destructive/70' : 'bg-primary/70',
!finite && 'opacity-0',
'h-full rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none',
finite ? barTone(clamped) : 'opacity-0',
)}
style={{ width: `${clamped}%` }}
/>
+111
View File
@@ -425,6 +425,117 @@ test("manual disconnect keeps the session pane for reconnect", () => {
assert.match(startersSource, /bootEpoch,/);
});
test("cancel connect invalidates the boot epoch like disconnect", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const cancelStart = source.indexOf("const handleCancelConnect = () => {");
const cancelEnd = source.indexOf("const handleDisconnect = () => {", cancelStart);
assert.notEqual(cancelStart, -1);
assert.notEqual(cancelEnd, -1);
const body = source.slice(cancelStart, cancelEnd);
assert.match(body, /invalidateBootEpochForClose\(\)/);
assert.match(body, /isBootActiveRef\.current = false/);
// Both must land before cleanupSession so the close targets the pre-bump epoch.
assert.ok(
body.indexOf("invalidateBootEpochForClose()") < body.indexOf("void cleanupSession()"),
"cancel must invalidate the boot epoch before cleanupSession",
);
assert.ok(
body.indexOf("isBootActiveRef.current = false") < body.indexOf("void cleanupSession()"),
"cancel must clear boot-active before cleanupSession",
);
});
test("terminal boot is cancelable and closes eagerly on cleanup", () => {
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const startersSource = readFileSync(
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
"utf8",
);
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",
"startLocal",
"startTelnet",
"startMosh",
"startEt",
"startSSH",
]) {
assert.match(
effectsSource,
new RegExp(`sessionStarters\\.${starter}\\(term, bootStartOptions\\)`),
`${starter} must receive the boot abort signal`,
);
}
// 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 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.
assert.match(
startersSource,
/options\?\.signal\?\.aborted !== true && isBootEpochCurrent\(\)/,
);
for (const starter of [
"startSSH",
"startTelnet",
"startMosh",
"startEt",
"startPluginConnection",
"startLocal",
"startSerial",
]) {
assert.match(
startersSource,
new RegExp(
`const ${starter} = async \\(term: XTerm, options\\?: TerminalSessionStartOptions\\)`,
),
`${starter} must accept an abort signal`,
);
}
// The plugin path holds its own in-flight request controller; the boot abort
// has to reach it or the extension request outlives the pane.
assert.match(startersSource, /options\?\.signal\?\.addEventListener\("abort", onBootAborted/);
assert.match(startersSource, /options\?\.signal\?\.removeEventListener\("abort", onBootAborted\)/);
});
test("hidden host information reveals actions without permanently covering terminal content", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
@@ -14,6 +14,12 @@ interface TerminalAutocompleteInputContext {
typedInputBufferRef: MutableRefObject<string>;
typedBufferReliableRef: MutableRefObject<boolean>;
previewActiveRef: MutableRefObject<boolean>;
/**
* Drop the popup highlight (and cascade panels) without hiding suggestions.
* Used when the user keeps typing so Enter cannot accept a stale default
* selection during the debounce window.
*/
clearPopupSelection: () => void;
termRef: RefObject<XTerm | null>;
hostIdRef: MutableRefObject<string>;
hostOsRef: MutableRefObject<"linux" | "windows" | "macos">;
@@ -35,6 +41,7 @@ export function handleTerminalAutocompleteInput(
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
clearPopupSelection,
termRef,
hostIdRef,
hostOsRef,
@@ -205,6 +212,9 @@ export function handleTerminalAutocompleteInput(
// text. Drop preview-active so Escape dismisses the popup without
// reverting these edits back to the stale baseline (#1005).
previewActiveRef.current = false;
// Clear the default/nav highlight immediately so Enter during the
// suggestion debounce cannot accept a stale never-previewed row.
clearPopupSelection();
// Re-align any visible ghost text to the freshly-updated buffer
// immediately. Without this the ghost keeps the tail it captured at
@@ -2,6 +2,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type { GhostTextAddon } from "./GhostTextAddon";
import type { AutocompleteSettings, AutocompleteState, SubDirEntry } from "./useTerminalAutocomplete";
import type { Snippet } from "../../../domain/models";
import { isPreviewlessSelectionCurrentForLine } from "./terminalAutocompleteLayout";
interface TerminalAutocompleteKeyEventContext {
settingsRef: MutableRefObject<AutocompleteSettings>;
@@ -10,14 +11,27 @@ interface TerminalAutocompleteKeyEventContext {
typedInputBufferRef: MutableRefObject<string>;
typedBufferReliableRef: MutableRefObject<boolean>;
previewActiveRef: MutableRefObject<boolean>;
/**
* True once a live preview has rewritten the line for the current popup
* cycle. Survives edits that clear `previewActiveRef` so Enter does not
* treat an edited preview as a never-previewed default selection.
*/
previewCommittedRef: MutableRefObject<boolean>;
/** Typed input that produced the current popup suggestions. */
previewBaselineRef: MutableRefObject<string>;
lastAcceptedCommandRef: MutableRefObject<string | null>;
setState: Dispatch<SetStateAction<AutocompleteState>>;
expandSubDir: (level: number, entry: SubDirEntry, moveFocus?: boolean) => void;
writeToTerminal: (text: string) => void;
clearState: () => void;
/**
* Clear cascade panels (and optionally retreat focus) while invalidating
* in-flight directory fetches so a late response cannot reinstall them.
* Omit `toFocusLevel` to dismiss the whole cascade back to the main list.
*/
dismissSubDirCascade: (toFocusLevel?: number) => void;
renderSubDirPath: (level: number, entry: SubDirEntry) => void;
handleSubDirSelect: (level: number, entry: SubDirEntry) => void;
fetchSubDirForIndex: (index: number) => void;
renderPreviewSelection: (index: number) => void;
acceptPreviewlessSelection: (index: number) => boolean;
acceptSnippet: (snippet: Snippet) => boolean;
@@ -45,14 +59,16 @@ export function handleTerminalAutocompleteKeyEvent(
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
previewCommittedRef,
previewBaselineRef,
lastAcceptedCommandRef,
setState,
expandSubDir,
writeToTerminal,
clearState,
dismissSubDirCascade,
renderSubDirPath,
handleSubDirSelect,
fetchSubDirForIndex,
renderPreviewSelection,
acceptPreviewlessSelection,
acceptSnippet,
@@ -185,6 +201,7 @@ export function handleTerminalAutocompleteKeyEvent(
// the line and let Tab reach the shell for native completion.
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
@@ -250,14 +267,13 @@ export function handleTerminalAutocompleteKeyEvent(
}
if (e.key === "Escape") {
e.preventDefault();
// Invalidate outstanding listings: Escape keeps the main-row
// selection, so an index-only fetch guard would otherwise reinstall
// the panels the user just dismissed.
if (focusLevel > 0) {
setState((prev) => ({
...prev,
subDirPanels: prev.subDirPanels.slice(0, focusLevel),
subDirFocusLevel: focusLevel - 1,
}));
dismissSubDirCascade(focusLevel - 1);
} else {
setState((prev) => ({ ...prev, subDirPanels: [], subDirFocusLevel: -1 }));
dismissSubDirCascade();
}
return false;
}
@@ -290,40 +306,79 @@ export function handleTerminalAutocompleteKeyEvent(
subDirPanels: [], subDirFocusLevel: -1,
}));
if (settingsRef.current.livePreview) renderPreviewSelection(next);
if (next >= 0) fetchSubDirForIndex(next);
// Sub-dir cascade prefetch runs from useTerminalAutocomplete after
// selectedIndex commits (also covers default-select without ArrowDown).
return false;
}
// Enter on popup. The selected candidate is already rendered into the
// line by live-preview, so let Enter reach the shell. Don't record here:
// handleInput's Enter path records the *actual* line — it uses
// lastAcceptedCommandRef (set on select) but falls back to the live
// buffer when the user edited the previewed command (typing nulls that
// ref), so recording stays accurate in both cases.
// Enter on popup. When live-preview has already rewritten the line —
// or the user edited that preview (previewActive cleared, committed
// still set) — let Enter reach the shell. When a row is selected but
// never previewed (default first-row highlight, #2821), insert+execute
// like the previewless path. Don't record here: handleInput's Enter
// path records the *actual* line — it uses lastAcceptedCommandRef
// (set on select) but falls back to the live buffer when the user
// edited the previewed command (typing nulls that ref).
if (isAutocompleteConfirmEnter(e, settingsRef.current)) {
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
// Never-previewed default/nav selection: typing during the debounce
// window leaves selectedIndex on a stale row while both preview refs
// stay false. Refuse accept unless the row still matches the fetch
// baseline or prefix-completes the edited line.
if (
selected &&
!previewActiveRef.current &&
!previewCommittedRef.current &&
typedBufferReliableRef.current &&
!isPreviewlessSelectionCurrentForLine({
currentLine: typedInputBufferRef.current,
suggestionBaseline: previewBaselineRef.current,
suggestionText: selected.text,
suggestionSource: selected.source,
})
) {
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
if (selected?.source === "snippet" && selected.snippet) {
if (!acceptSnippet(selected.snippet)) {
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
e.preventDefault();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return false; // consume — run the snippet, not the typed text
}
if (!settingsRef.current.livePreview && selected) {
if (
selected &&
settingsRef.current.livePreview &&
(previewActiveRef.current || previewCommittedRef.current)
) {
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
if (selected) {
if (acceptPreviewlessSelection(s.selectedIndex)) {
e.preventDefault();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return false;
}
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
}
@@ -339,6 +394,7 @@ export function handleTerminalAutocompleteKeyEvent(
ghost?.hide();
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return false;
}
@@ -112,6 +112,89 @@ export function areSuggestionsEqual(
return true;
}
/**
* Whether a suggestion refresh should preserve the current popup highlight.
*
* Equal suggestion rows alone are not enough: typing clears the highlight to
* -1 for the debounce window, and a narrowed prefix often yields the same
* rows. Preserve only when the input baseline is unchanged (spurious refetch /
* ↑↓ baseline slot); otherwise restore the first-row default.
*/
export function shouldPreserveAutocompletePopupSelection(options: {
suggestionsUnchanged: boolean;
previousInputBaseline: string;
nextInputBaseline: string;
}): boolean {
return (
options.suggestionsUnchanged &&
options.previousInputBaseline === options.nextInputBaseline
);
}
/**
* Initial / refreshed popup selection (#2821).
* New suggestion lists default to the first row so Enter can confirm without
* an ArrowDown. Unchanged lists keep the user's current index (including the
* -1 "typed baseline" slot from ↑/↓ wrapping) when
* `shouldPreserveAutocompletePopupSelection` is true.
*/
export function resolveAutocompletePopupSelectedIndex(options: {
suggestionCount: number;
previousSelectedIndex: number;
keepPreviousSelection: boolean;
}): number {
if (options.suggestionCount <= 0) return -1;
if (
options.keepPreviousSelection &&
options.previousSelectedIndex >= -1 &&
options.previousSelectedIndex < options.suggestionCount
) {
return options.previousSelectedIndex;
}
return 0;
}
/**
* Whether a never-previewed popup selection is still safe to accept on Enter.
*
* Default first-row highlight (#2821) stays selected during the suggestion
* debounce window. If the user typed further, accepting would replace the
* edited line with a stale row when allowLineReplacement is on. Keep accept
* when the line still matches the fetch baseline (incl. fuzzy), or when the
* candidate still prefix-completes the edited line.
*/
export function isPreviewlessSelectionCurrentForLine(options: {
currentLine: string;
suggestionBaseline: string;
suggestionText: string;
suggestionSource: string;
}): boolean {
if (options.currentLine === options.suggestionBaseline) return true;
if (options.suggestionSource === "snippet") return false;
return options.suggestionText.startsWith(options.currentLine);
}
/**
* Whether a level-0 directory prefetch may install its panel.
*
* Index-only checks are not enough after typing replaces the first row:
* an older in-flight listing can share index 0 with a newer suggestion.
* Require matching fetch generation and suggestion identity too.
*/
export function shouldApplySubDirPrefetchResult(options: {
requestVersion: number;
currentVersion: number;
requestIndex: number;
selectedIndex: number;
requestSuggestionText: string;
selectedSuggestionText: string | undefined;
}): boolean {
if (options.requestVersion !== options.currentVersion) return false;
if (options.selectedIndex !== options.requestIndex) return false;
if (options.selectedSuggestionText === undefined) return false;
return options.selectedSuggestionText === options.requestSuggestionText;
}
export function areSubDirPanelsEqual(left: SubDirPanel[], right: SubDirPanel[]): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i++) {
@@ -34,6 +34,9 @@ import {
resolveAutocompleteAnchorInViewport,
resolveAutocompleteCursorColumn,
resolveAutocompleteCwdWithSource,
resolveAutocompletePopupSelectedIndex,
shouldApplySubDirPrefetchResult,
shouldPreserveAutocompletePopupSelection,
} from "./terminalAutocompleteLayout";
import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
@@ -229,6 +232,12 @@ export function useTerminalAutocomplete(
const previewBaselineRef = useRef<string>("");
/** Whether a popup candidate is currently rendered into the command line (#1005). */
const previewActiveRef = useRef(false);
/**
* Whether a live preview has rewritten the line for the current popup cycle.
* Stays true after the user edits the previewed text (which clears
* previewActiveRef) so Enter does not accept the stale selected row.
*/
const previewCommittedRef = useRef(false);
/** Monotonic counter to invalidate stale async sub-dir fetches */
const subDirFetchVersionRef = useRef(0);
/**
@@ -352,11 +361,64 @@ export function useTerminalAutocomplete(
// Bump version to invalidate any in-flight async completions
fetchVersionRef.current++;
subDirFetchVersionRef.current++;
previewActiveRef.current = false;
previewCommittedRef.current = false;
setState((prev) =>
prev.popupVisible || prev.suggestions.length > 0 ? { ...EMPTY_STATE } : prev,
);
}, []);
/**
* Clear the popup highlight (and cascade) while keeping suggestions visible.
* Syncs stateRef immediately so a same-tick Enter cannot accept a stale row.
*/
const clearPopupSelection = useCallback(() => {
const prev = stateRef.current;
if (
!prev.popupVisible ||
(prev.selectedIndex < 0 && prev.subDirPanels.length === 0 && prev.subDirFocusLevel < 0)
) {
return;
}
// Drop in-flight level-0 fetches so a stale directory cannot install after
// the next refresh defaults back to index 0 for a different first row.
subDirFetchVersionRef.current++;
const next = {
...prev,
selectedIndex: -1,
subDirPanels: [] as typeof prev.subDirPanels,
subDirFocusLevel: -1,
};
stateRef.current = next;
setState(next);
}, []);
/**
* Dismiss cascade panels (Escape) while keeping the main popup selection.
* Always bumps the fetch generation so a late listing cannot reopen panels.
*/
const dismissSubDirCascade = useCallback((toFocusLevel?: number) => {
subDirFetchVersionRef.current++;
setState((prev) => {
if (toFocusLevel !== undefined && toFocusLevel >= 0) {
const nextPanels = prev.subDirPanels.slice(0, toFocusLevel + 1);
if (
prev.subDirFocusLevel === toFocusLevel &&
areSubDirPanelsEqual(prev.subDirPanels, nextPanels)
) {
return prev;
}
return {
...prev,
subDirPanels: nextPanels,
subDirFocusLevel: toFocusLevel,
};
}
if (prev.subDirPanels.length === 0 && prev.subDirFocusLevel < 0) return prev;
return { ...prev, subDirPanels: [], subDirFocusLevel: -1 };
});
}, []);
const repositionPopup = useCallback(() => {
const term = termRef.current;
if (!term) return;
@@ -439,11 +501,23 @@ export function useTerminalAutocomplete(
if (!dirPath) return;
const requestVersion = ++subDirFetchVersionRef.current;
const requestSuggestionText = item.text;
fetchDirEntries(dirPath).then((entries) => {
if (requestVersion !== subDirFetchVersionRef.current) return;
startTransition(() => {
setState((prev) => {
if (prev.selectedIndex !== index) return prev;
if (
!shouldApplySubDirPrefetchResult({
requestVersion,
currentVersion: subDirFetchVersionRef.current,
requestIndex: index,
selectedIndex: prev.selectedIndex,
requestSuggestionText,
selectedSuggestionText: prev.suggestions[index]?.text,
})
) {
return prev;
}
const nextPanels = entries.length > 0 ? [{ entries, selectedIndex: -1, dirPath }] : [];
if (
prev.subDirFocusLevel === -1 &&
@@ -580,6 +654,7 @@ export function useTerminalAutocomplete(
typedInputBufferRef.current = newCommand;
typedBufferReliableRef.current = true;
previewActiveRef.current = true;
previewCommittedRef.current = true;
lastAcceptedCommandRef.current = newCommand;
}, [termRef, writeToTerminal]);
@@ -764,8 +839,12 @@ export function useTerminalAutocomplete(
// Popup
if (settingsRef.current.showPopupMenu && completions.length > 0) {
// Live-preview baseline: the typed input these suggestions completed.
// Capture the prior baseline first — equal suggestion rows after typing
// must not preserve the debounce clear's programmatic -1.
const previousInputBaseline = previewBaselineRef.current;
previewBaselineRef.current = input;
previewActiveRef.current = false;
previewCommittedRef.current = false;
const cursorColumn = resolveAutocompleteCursorColumn(term, currentPrompt);
const anchor = resolveAutocompleteAnchorInViewport(
term,
@@ -777,9 +856,23 @@ export function useTerminalAutocomplete(
setState((prev) => {
if (version !== fetchVersionRef.current) return prev;
const suggestionsUnchanged =
prev.popupVisible && areSuggestionsEqual(prev.suggestions, completions);
const keepPreviousSelection = shouldPreserveAutocompletePopupSelection({
suggestionsUnchanged,
previousInputBaseline,
nextInputBaseline: input,
});
const selectedIndex = resolveAutocompletePopupSelectedIndex({
suggestionCount: completions.length,
previousSelectedIndex: prev.selectedIndex,
keepPreviousSelection,
});
const keepSubDirPanels =
suggestionsUnchanged && selectedIndex === prev.selectedIndex;
const nextState: AutocompleteState = {
suggestions: completions,
selectedIndex: -1,
selectedIndex,
popupVisible: true,
popupAnchorViewport: {
left: anchor.anchorLeft,
@@ -787,8 +880,11 @@ export function useTerminalAutocomplete(
bottom: anchor.anchorBottom,
},
expandUpward: anchor.expandUpward,
subDirPanels: [],
subDirFocusLevel: -1,
// Fresh lists (or a selection change) drop cascade panels.
// Unchanged selection keeps them so a spurious refetch does not
// collapse an open directory tree.
subDirPanels: keepSubDirPanels ? prev.subDirPanels : [],
subDirFocusLevel: keepSubDirPanels ? prev.subDirFocusLevel : -1,
};
if (
@@ -798,9 +894,9 @@ export function useTerminalAutocomplete(
prev.popupAnchorViewport.left === nextState.popupAnchorViewport.left &&
prev.popupAnchorViewport.top === nextState.popupAnchorViewport.top &&
prev.popupAnchorViewport.bottom === nextState.popupAnchorViewport.bottom &&
prev.subDirFocusLevel === -1 &&
prev.subDirPanels.length === 0 &&
areSuggestionsEqual(prev.suggestions, nextState.suggestions)
prev.subDirFocusLevel === nextState.subDirFocusLevel &&
areSubDirPanelsEqual(prev.subDirPanels, nextState.subDirPanels) &&
suggestionsUnchanged
) {
return prev;
}
@@ -835,6 +931,7 @@ export function useTerminalAutocomplete(
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
clearPopupSelection,
termRef,
hostIdRef,
hostOsRef,
@@ -844,7 +941,7 @@ export function useTerminalAutocomplete(
fetchSuggestions,
});
},
[fetchSuggestions, termRef, clearState],
[fetchSuggestions, termRef, clearState, clearPopupSelection],
);
/**
@@ -859,14 +956,16 @@ export function useTerminalAutocomplete(
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
previewCommittedRef,
previewBaselineRef,
lastAcceptedCommandRef,
setState,
expandSubDir,
writeToTerminal,
clearState,
dismissSubDirCascade,
renderSubDirPath,
handleSubDirSelect,
fetchSubDirForIndex,
renderPreviewSelection,
acceptPreviewlessSelection,
acceptSnippet,
@@ -875,6 +974,28 @@ export function useTerminalAutocomplete(
[writeToTerminal],
);
// Prefetch directory cascade panels after selectedIndex commits. Covers both
// default-select (#2821) and ↑/↓ navigation. Must run post-commit: calling
// from the startTransition refresh path races when fetchDirEntries resolves
// before selectedIndex has flushed, and the result handler drops the panels.
// Depend only on selection/list identity — not cascade emptiness — so Escape
// dismissing level-0 panels does not immediately re-prefetch and reopen them.
useEffect(() => {
if (!state.popupVisible || state.selectedIndex < 0) return;
if (state.selectedIndex >= state.suggestions.length) return;
// Skip when cascade state already exists so an unchanged suggestion
// refresh (new array identity, same rows) does not wipe nested panels.
if (state.subDirPanels.length > 0 || state.subDirFocusLevel >= 0) return;
fetchSubDirForIndex(state.selectedIndex);
// Cascade emptiness is a skip guard only — omit from deps so Escape dismissal sticks.
// eslint-disable-next-line react-hooks/exhaustive-deps -- selection/list only
}, [
state.popupVisible,
state.selectedIndex,
state.suggestions,
fetchSubDirForIndex,
]);
/**
* Render the suggestion at `index` straight into the command line (Termius
* live-preview, #1005). `index < 0` restores the user's typed baseline.
@@ -906,6 +1027,7 @@ export function useTerminalAutocomplete(
typedBufferReliableRef.current = true;
const isPreview = index >= 0 && candidate !== baseline;
previewActiveRef.current = isPreview;
if (isPreview) previewCommittedRef.current = true;
lastAcceptedCommandRef.current = isPreview ? candidate : null;
// Live-preview can move/wrap the cursor. Recompute the anchor after xterm
// has processed the write so the popup doesn't drift or flip into a stale
@@ -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,
@@ -104,6 +104,15 @@ export const getMissingChainHostIds = (
return requestedIds.filter((hostId) => !resolvedIds.has(hostId));
};
/**
* Cancellation handle for one boot attempt. The mount effect owns the
* controller and aborts it in cleanup, so a start that is still awaiting the
* bridge stops counting as the current attempt and closes any session it wins.
*/
export type TerminalSessionStartOptions = {
signal?: AbortSignal;
};
export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => {
const globalTerminalSettings = {
verifyHostKeys: true,
@@ -125,9 +134,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setChainProgress(null);
};
const createAttemptGuards = () => {
const createAttemptGuards = (options?: TerminalSessionStartOptions) => {
const bootEpoch = ctx.bootEpochRef?.current ?? 0;
const isCurrentAttempt = createBootAttemptGuard(ctx);
const isBootEpochCurrent = createBootAttemptGuard(ctx);
// An aborted boot is never the current attempt, so every existing guard
// (orphan close, attach refusal, UI suppression) also covers cancellation.
const isCurrentAttempt = () => options?.signal?.aborted !== true && isBootEpochCurrent();
return {
bootEpoch,
isCurrentAttempt,
@@ -196,8 +208,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startSSH = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startSSH = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
// Correlate host-key prompts with this boot so a superseded start cannot
// reopen approval UI after disconnect → reconnect.
setTerminalBootEpoch(ctx.sessionId, bootEpoch);
@@ -807,8 +820,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startTelnet = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startTelnet = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.telnetAvailable()) {
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[Telnet bridge unavailable. Please run the desktop build.]");
@@ -1001,8 +1015,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startMosh = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startMosh = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.moshAvailable()) {
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[Mosh bridge unavailable. Please run the desktop build.]");
@@ -1238,8 +1253,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startEt = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startEt = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.etAvailable()) {
ctx.setError("EternalTerminal bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[EternalTerminal bridge unavailable. Please run the desktop build.]");
@@ -1546,8 +1562,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startPluginConnection = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startPluginConnection = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.pluginConnectionAvailable()) {
ctx.setError("Plugin connection bridge unavailable. Please run the desktop build with Plugin Development enabled.");
writeTerminalLine(ctx, term, "\r\n[Plugin connection bridge unavailable.]");
@@ -1608,7 +1625,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
scheduleBootMonitor();
}, 50);
};
// The mount effect aborts its boot controller before the async teardown
// runs, so forward that straight to the in-flight extension request.
const onBootAborted = () => cancelPendingStart();
const releasePendingStartCancellation = () => {
options?.signal?.removeEventListener("abort", onBootAborted);
if (ctx.disposeExitRef.current === cancelPendingStart) {
ctx.disposeExitRef.current = previousDisposeExit;
}
@@ -1616,6 +1637,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
clearBootMonitor();
};
ctx.disposeExitRef.current = cancelPendingStart;
options?.signal?.addEventListener("abort", onBootAborted, { once: true });
scheduleBootMonitor();
try {
const startPromise = ctx.terminalBackend.startPluginConnection({
@@ -1692,8 +1714,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startLocal = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startLocal = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
writeTerminalLine(
@@ -1814,8 +1837,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
// Start Serial session
const startSerial = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
const startSerial = async (term: XTerm, options?: TerminalSessionStartOptions) => {
if (options?.signal?.aborted) return;
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
if (!ctx.serialConfig) {
ctx.setError("No serial configuration provided");
writeTerminalLine(ctx, term, "\r\n[Error: No serial configuration provided]");
@@ -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;
}
@@ -46,6 +46,7 @@ function createContext(overrides: Record<string, unknown> = {}) {
const previews: number[] = [];
const accepted: number[] = [];
const clears: number[] = [];
const dismissals: Array<number | undefined> = [];
const stateRef = { current: state };
return {
@@ -53,6 +54,8 @@ function createContext(overrides: Record<string, unknown> = {}) {
previews,
accepted,
clears,
dismissals,
getState: () => state,
context: {
settingsRef: {
current: {
@@ -72,6 +75,8 @@ function createContext(overrides: Record<string, unknown> = {}) {
typedInputBufferRef: { current: "sh" },
typedBufferReliableRef: { current: true },
previewActiveRef: { current: false },
previewCommittedRef: { current: false },
previewBaselineRef: { current: "sh" },
lastAcceptedCommandRef: { current: null },
setState(update: typeof state | ((prev: typeof state) => typeof state)) {
state = typeof update === "function" ? update(state) : update;
@@ -80,9 +85,9 @@ function createContext(overrides: Record<string, unknown> = {}) {
expandSubDir() {},
writeToTerminal(text: string) { writes.push(text); },
clearState() { clears.push(1); },
dismissSubDirCascade(toFocusLevel?: number) { dismissals.push(toFocusLevel); },
renderSubDirPath() {},
handleSubDirSelect() {},
fetchSubDirForIndex() {},
renderPreviewSelection(index: number) { previews.push(index); },
acceptSnippet() { return true; },
acceptPreviewlessSelection(index: number) { accepted.push(index); return true; },
@@ -202,3 +207,225 @@ test("serial-style popup Shift+Enter confirms candidate when terminal shortcut i
assert.deepEqual(accepted, [0]);
assert.deepEqual(clears, []);
});
test("live-preview Enter accepts the default-selected candidate when it is not previewed yet", () => {
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("show version")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.previewActiveRef.current = false;
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, false);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(accepted, [0]);
assert.deepEqual(clears, []);
});
test("live-preview Enter passes through a stale default selection after further typing", () => {
// Default first-row highlight is never previewed. Typing during the
// debounce window leaves selectedIndex on the old row while both preview
// refs stay false — Enter must not wipe the edited line via line replacement.
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("git status")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.settingsRef.current.allowLineReplacement = true;
context.previewActiveRef.current = false;
context.previewCommittedRef.current = false;
context.previewBaselineRef.current = "gst";
context.typedInputBufferRef.current = "gstx";
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, true);
assert.equal(event.defaultPrevented, false);
assert.deepEqual(accepted, []);
assert.deepEqual(clears, [1]);
});
test("live-preview Enter still accepts default selection that completes edited prefix", () => {
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("show version")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.previewActiveRef.current = false;
context.previewCommittedRef.current = false;
context.previewBaselineRef.current = "sh";
context.typedInputBufferRef.current = "sho";
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, false);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(accepted, [0]);
assert.deepEqual(clears, []);
});
test("live-preview Enter still passes through when the candidate is already on the line", () => {
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("show version")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.previewActiveRef.current = true;
context.previewCommittedRef.current = true;
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, true);
assert.equal(event.defaultPrevented, false);
assert.deepEqual(accepted, []);
assert.deepEqual(clears, [1]);
});
test("live-preview Enter passes through an edited preview instead of accepting the old row", () => {
// Arrow navigation live-previewed a row, then the user typed (clears
// previewActive) before debounce refresh. Enter must keep the edited
// line — not wipe it via acceptPreviewlessSelection.
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("show version")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.previewActiveRef.current = false;
context.previewCommittedRef.current = true;
context.typedInputBufferRef.current = "show version --short";
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, true);
assert.equal(event.defaultPrevented, false);
assert.deepEqual(accepted, []);
assert.deepEqual(clears, [1]);
});
test("Escape at level 0 dismisses cascade via dismissSubDirCascade so in-flight fetches cannot reinstall", () => {
// Escape clears panels while keeping the main-row selection. Without
// invalidating outstanding directory fetches, a late response would
// reinstall the panel and undo the dismissal.
const { context, dismissals } = createContext({
stateRef: {
current: {
suggestions: [{
text: "cd docs/",
displayText: "docs/",
source: "path" as const,
score: 1,
fileType: "directory" as const,
}],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [{
entries: [{ name: "src", type: "directory" as const }],
selectedIndex: -1,
dirPath: "/home/docs",
}],
subDirFocusLevel: 0,
},
},
});
const event = keyEvent("Escape");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, false);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(dismissals, [undefined]);
});
test("Escape at nested level dismisses via dismissSubDirCascade with prior focus level", () => {
const { context, dismissals } = createContext({
stateRef: {
current: {
suggestions: [{
text: "cd docs/",
displayText: "docs/",
source: "path" as const,
score: 1,
fileType: "directory" as const,
}],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [
{
entries: [{ name: "src", type: "directory" as const }],
selectedIndex: 0,
dirPath: "/home/docs",
},
{
entries: [{ name: "main.ts", type: "file" as const }],
selectedIndex: 0,
dirPath: "/home/docs/src",
},
],
subDirFocusLevel: 1,
},
},
});
const event = keyEvent("Escape");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, false);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(dismissals, [0]);
});
@@ -1,12 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
clampAutocompletePopupGeometry,
computeAutocompletePopupPlacement,
isPreviewlessSelectionCurrentForLine,
resolveAutocompleteAnchorInViewport,
resolveAutocompleteClampViewport,
resolveAutocompleteCursorColumn,
resolveAutocompletePopupSelectedIndex,
shouldApplySubDirPrefetchResult,
shouldPreserveAutocompletePopupSelection,
type PopupPlacementInput,
} from "./autocomplete/terminalAutocompleteLayout.ts";
@@ -501,3 +506,207 @@ test("resolveAutocompleteAnchorInViewport ignores the helper textarea horizontal
assert.equal(anchor.anchorLeft, 640 + cellWidth * cursorColumn);
assert.notEqual(anchor.anchorLeft, textarea.getBoundingClientRect().left);
});
test("resolveAutocompletePopupSelectedIndex defaults to the first suggestion", () => {
assert.equal(
resolveAutocompletePopupSelectedIndex({
suggestionCount: 3,
previousSelectedIndex: -1,
keepPreviousSelection: false,
}),
0,
);
});
test("shouldPreserveAutocompletePopupSelection requires an unchanged input baseline", () => {
assert.equal(
shouldPreserveAutocompletePopupSelection({
suggestionsUnchanged: true,
previousInputBaseline: "cd do",
nextInputBaseline: "cd do",
}),
true,
);
// Typing clears selection to -1; equal rows after a narrower prefix must
// restore the first-row default instead of preserving that programmatic -1.
assert.equal(
shouldPreserveAutocompletePopupSelection({
suggestionsUnchanged: true,
previousInputBaseline: "cd do",
nextInputBaseline: "cd doc",
}),
false,
);
assert.equal(
shouldPreserveAutocompletePopupSelection({
suggestionsUnchanged: false,
previousInputBaseline: "cd do",
nextInputBaseline: "cd do",
}),
false,
);
});
test("resolveAutocompletePopupSelectedIndex keeps navigation when suggestions are unchanged", () => {
assert.equal(
resolveAutocompletePopupSelectedIndex({
suggestionCount: 3,
previousSelectedIndex: 2,
keepPreviousSelection: true,
}),
2,
);
assert.equal(
resolveAutocompletePopupSelectedIndex({
suggestionCount: 3,
previousSelectedIndex: -1,
keepPreviousSelection: true,
}),
-1,
);
});
test("resolveAutocompletePopupSelectedIndex clamps a stale selection when the list shrinks", () => {
assert.equal(
resolveAutocompletePopupSelectedIndex({
suggestionCount: 1,
previousSelectedIndex: 4,
keepPreviousSelection: true,
}),
0,
);
});
test("isPreviewlessSelectionCurrentForLine keeps fetch-baseline fuzzy rows", () => {
assert.equal(
isPreviewlessSelectionCurrentForLine({
currentLine: "gst",
suggestionBaseline: "gst",
suggestionText: "git status",
suggestionSource: "history",
}),
true,
);
});
test("isPreviewlessSelectionCurrentForLine rejects edits that no longer complete", () => {
assert.equal(
isPreviewlessSelectionCurrentForLine({
currentLine: "gstx",
suggestionBaseline: "gst",
suggestionText: "git status",
suggestionSource: "history",
}),
false,
);
});
test("isPreviewlessSelectionCurrentForLine keeps prefix progress during debounce", () => {
assert.equal(
isPreviewlessSelectionCurrentForLine({
currentLine: "sho",
suggestionBaseline: "sh",
suggestionText: "show version",
suggestionSource: "history",
}),
true,
);
});
test("selected popup row prefetches sub-dir panels after selectedIndex commits", () => {
// Default-select (#2821) and ↑/↓ must prefetch via a post-commit effect.
// Prefetching inside the startTransition refresh path races: fetchDirEntries
// can resolve before selectedIndex flushes, and the result handler drops the
// panels when prev.selectedIndex still mismatches.
const source = readFileSync(
new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url),
"utf8",
);
assert.match(
source,
/useEffect\(\(\) => \{\s*if \(!state\.popupVisible \|\| state\.selectedIndex < 0\) return;[\s\S]*if \(state\.subDirPanels\.length > 0 \|\| state\.subDirFocusLevel >= 0\) return;[\s\S]*fetchSubDirForIndex\(state\.selectedIndex\);/,
);
// Escape clears subDirPanels without changing selection; if cascade emptiness
// is a dependency, the effect re-prefetches and the dismissal never sticks.
const effectMatch = source.match(
/useEffect\(\(\) => \{\s*if \(!state\.popupVisible \|\| state\.selectedIndex < 0\) return;[\s\S]*?\},\s*\[([\s\S]*?)\]\);/,
);
assert.ok(effectMatch, "expected sub-dir prefetch useEffect deps");
const deps = effectMatch[1] ?? "";
assert.match(deps, /state\.popupVisible/);
assert.match(deps, /state\.selectedIndex/);
assert.match(deps, /state\.suggestions/);
assert.match(deps, /fetchSubDirForIndex/);
assert.doesNotMatch(deps, /state\.subDirPanels/);
assert.doesNotMatch(deps, /state\.subDirFocusLevel/);
});
test("typing clear invalidates in-flight sub-dir fetches and restores default on baseline change", () => {
const source = readFileSync(
new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url),
"utf8",
);
assert.match(
source,
/const clearPopupSelection = useCallback\(\(\) => \{[\s\S]*?subDirFetchVersionRef\.current\+\+;[\s\S]*?selectedIndex: -1,/,
);
assert.match(
source,
/shouldPreserveAutocompletePopupSelection\(\{\s*suggestionsUnchanged,\s*previousInputBaseline,\s*nextInputBaseline: input,/,
);
assert.match(
source,
/const dismissSubDirCascade = useCallback\(\([\s\S]*?subDirFetchVersionRef\.current\+\+;/,
);
assert.match(
source,
/shouldApplySubDirPrefetchResult\(\{/,
);
});
test("shouldApplySubDirPrefetchResult rejects stale version, index, or suggestion identity", () => {
assert.equal(
shouldApplySubDirPrefetchResult({
requestVersion: 3,
currentVersion: 3,
requestIndex: 0,
selectedIndex: 0,
requestSuggestionText: "cd docs/",
selectedSuggestionText: "cd docs/",
}),
true,
);
assert.equal(
shouldApplySubDirPrefetchResult({
requestVersion: 3,
currentVersion: 4,
requestIndex: 0,
selectedIndex: 0,
requestSuggestionText: "cd docs/",
selectedSuggestionText: "cd docs/",
}),
false,
);
assert.equal(
shouldApplySubDirPrefetchResult({
requestVersion: 3,
currentVersion: 3,
requestIndex: 0,
selectedIndex: 1,
requestSuggestionText: "cd docs/",
selectedSuggestionText: "cd docs/",
}),
false,
);
assert.equal(
shouldApplySubDirPrefetchResult({
requestVersion: 3,
currentVersion: 3,
requestIndex: 0,
selectedIndex: 0,
requestSuggestionText: "cd docs/",
selectedSuggestionText: "cd src/",
}),
false,
);
});
@@ -126,7 +126,7 @@ test('session launch paths use the same effective protocol as Provider snapshots
);
assert.match(
effectsSource,
/else if \(effectiveTerminalProtocol === "mosh"\)[\s\S]*?sessionStarters\.startMosh\(term\)[\s\S]*?else if \(effectiveTerminalProtocol === "et"\)/,
/else if \(effectiveTerminalProtocol === "mosh"\)[\s\S]*?sessionStarters\.startMosh\(term, bootStartOptions\)[\s\S]*?else if \(effectiveTerminalProtocol === "et"\)/,
);
assert.match(
terminalSource,
+98 -10
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,20 @@ 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();
const bootStartOptions = { signal: bootAbort.signal };
const closeGeneration = ++terminalBootCloseGenerationRef.current;
if (bootEpochRef) {
bootEpochRef.current += 1;
@@ -421,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;
@@ -510,6 +541,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
return;
}
ownedRuntime = runtime;
xtermRuntimeRef.current = runtime;
termRef.current = runtime.term;
fitAddonRef.current = runtime.fitAddon;
@@ -713,32 +745,32 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (effectiveTerminalProtocol.startsWith("plugin:")) {
setBackendConnectingStatus();
setProgressLogs(["Initializing plugin connection..."]);
await sessionStarters.startPluginConnection(term);
await sessionStarters.startPluginConnection(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "serial") {
setBackendConnectingStatus();
setProgressLogs(["Initializing serial connection..."]);
await sessionStarters.startSerial(term);
await sessionStarters.startSerial(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "local") {
setBackendConnectingStatus();
setProgressLogs(["Initializing local shell..."]);
await sessionStarters.startLocal(term);
await sessionStarters.startLocal(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "telnet") {
setBackendConnectingStatus();
setProgressLogs(["Initializing Telnet connection..."]);
await sessionStarters.startTelnet(term);
await sessionStarters.startTelnet(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "mosh") {
setBackendConnectingStatus();
setProgressLogs(["Initializing Mosh connection..."]);
await sessionStarters.startMosh(term);
await sessionStarters.startMosh(term, bootStartOptions);
if (!bootStillActive()) return;
} else if (effectiveTerminalProtocol === "et") {
setBackendConnectingStatus();
setProgressLogs(["Initializing EternalTerminal connection..."]);
await sessionStarters.startEt(term);
await sessionStarters.startEt(term, bootStartOptions);
if (!bootStillActive()) return;
} else {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
@@ -759,7 +791,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
setBackendConnectingStatus();
setProgressLogs(["Initializing secure channel..."]);
await sessionStarters.startSSH(term);
await sessionStarters.startSSH(term, bootStartOptions);
if (!bootStillActive()) return;
}
} catch (err) {
@@ -770,14 +802,45 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
}
};
boot();
queueMicrotask(() => {
if (disposed) return;
void boot();
});
return () => {
disposed = true;
if (initialFitTimer !== undefined) clearTimeout(initialFitTimer);
// Abort first: a start still awaiting the bridge must stop counting as
// the current attempt before anything below yields to a microtask.
bootAbort.abort();
isBootActiveRef.current = false;
if (hibernatedRef?.current) {
forceCloseHibernatedSession?.();
disposeOwnedRuntime();
return;
}
// A boot that never reached "connected" has nothing worth capturing, and
// the teardown below awaits terminal drain plus serialization before it
// 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 (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);
}
}
disposeOwnedRuntime();
return;
}
@@ -801,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;
@@ -819,6 +894,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
connectionLogPayload.source,
connectionLogPayload.data.length,
);
ownedRuntime = null;
scheduleTerminalCloseTeardown(teardown);
return;
}
@@ -832,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
@@ -10,6 +10,7 @@ import type { TerminalSessionExitEvent } from '../../application/state/resolveTe
import { createTerminalSelectionAttachment } from '../../application/state/terminalSelectionAttachment';
import { getTopTabInsertionTarget, isPointInsideRect, WORKSPACE_SESSION_DRAG_TYPE } from '../../application/state/terminalDragData';
import { useAIState } from '../../application/state/useAIState';
import { useAISessionsStore } from '../../application/state/aiSessionsStore';
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
import { isSavedVaultHost } from '../../domain/ephemeralHosts';
import {
@@ -33,6 +34,7 @@ import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
import type { DropEntry } from '../../lib/sftpFileUtils';
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, VaultNote, Workspace } from '../../types';
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
import type { AISession } from '../../infrastructure/ai/types';
import Terminal from '../Terminal';
import { removePaneVisible, setPaneVisible } from '../terminal/paneVisibilityStore';
import type { TerminalBroadcastInputOptions } from '../terminal/terminalHelpers';
@@ -242,9 +244,17 @@ export const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>)
export { ChunkedEscapeFilter, hasNotifiableTerminalOutput } from './activityEscapeFilter';
type AIStateValue = ReturnType<typeof useAIState>;
/**
* Providers, permissions, agent config and the session mutators — everything
* except `sessions` / `activeSessionIdMap` / `draftsByScope` /
* `panelViewByScope`, which `useAIState` deliberately keeps out of its return
* and publishes to `aiSessionsStore` instead. Because the hot slices are absent,
* a landing token cannot change this Context's identity, so provider and
* permission consumers stay put while a turn streams.
*/
export type AIConfigValue = ReturnType<typeof useAIState>;
const AIStateContext = createContext<AIStateValue | null>(null);
const AIConfigContext = createContext<AIConfigValue | null>(null);
interface AIChatPanelsHostProps {
mountedTabIds: string[];
@@ -272,11 +282,11 @@ interface AIStateMaintenanceHostProps {
}
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const aiState = useAIState();
const aiConfig = useAIState();
return (
<AIStateContext.Provider value={aiState}>
<AIConfigContext.Provider value={aiConfig}>
{children}
</AIStateContext.Provider>
</AIConfigContext.Provider>
);
};
@@ -286,13 +296,13 @@ AIStateProvider.displayName = 'AIStateProvider';
const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
validAIScopeTargetIds,
}) => {
const aiState = useContext(AIStateContext);
const aiConfig = useContext(AIConfigContext);
if (!aiState) {
if (!aiConfig) {
throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider');
}
const { cleanupOrphanedSessions } = aiState;
const { cleanupOrphanedSessions } = aiConfig;
useEffect(() => {
cleanupOrphanedSessions(validAIScopeTargetIds);
@@ -350,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,
@@ -366,30 +388,36 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
onOpenVaultSectionFromChat,
onOpenVaultSnippetFromChat,
}) => {
const aiState = useContext(AIStateContext);
const aiConfig = useContext(AIConfigContext);
if (!aiState) {
if (!aiConfig) {
throw new Error('AIChatPanelsHost must be rendered inside AIStateProvider');
}
const {
sessions,
activeSessionIdMap,
defaultAgentId,
draftsByScope,
panelViewByScope,
} = useAISessionsStore();
const {
defaultAgentId,
showDraftView,
updateDraft,
} = aiState;
} = aiConfig;
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 =
@@ -402,7 +430,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
...draft,
attachments: [...draft.attachments, attachment],
}));
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
}, [
activeSessionIdMap,
contextsByTabId,
@@ -432,44 +459,44 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
<LazyAIChatSidePanel
// Full list keeps fuzzy history ranking; panel areEqual only
// compares exact-scope session object refs for stream isolation.
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
draftsByScope={aiState.draftsByScope}
panelViewByScope={aiState.panelViewByScope}
setActiveSessionId={aiState.setActiveSessionId}
ensureDraftForScope={aiState.ensureDraftForScope}
updateDraft={aiState.updateDraft}
showDraftView={aiState.showDraftView}
showSessionView={aiState.showSessionView}
clearDraftForScope={aiState.clearDraftForScope}
addDraftFiles={aiState.addDraftFiles}
removeDraftFile={aiState.removeDraftFile}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
persistContextCompaction={aiState.persistContextCompaction}
providers={aiState.providers}
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
toolIntegrationMode={aiState.toolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
setAgentModel={aiState.setAgentModel}
agentProviderMap={aiState.agentProviderMap}
setAgentProvider={aiState.setAgentProvider}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
commandBlocklist={aiState.commandBlocklist}
commandTimeout={aiState.commandTimeout}
maxIterations={aiState.maxIterations}
webSearchConfig={aiState.webSearchConfig}
quickMessages={aiState.quickMessages}
sessions={sessions as AISession[]}
activeSessionIdMap={activeSessionIdMap as Record<string, string | null>}
draftsByScope={draftsByScope}
panelViewByScope={panelViewByScope}
setActiveSessionId={aiConfig.setActiveSessionId}
ensureDraftForScope={aiConfig.ensureDraftForScope}
updateDraft={aiConfig.updateDraft}
showDraftView={aiConfig.showDraftView}
showSessionView={aiConfig.showSessionView}
clearDraftForScope={aiConfig.clearDraftForScope}
addDraftFiles={aiConfig.addDraftFiles}
removeDraftFile={aiConfig.removeDraftFile}
createSession={aiConfig.createSession}
deleteSession={aiConfig.deleteSession}
updateSessionTitle={aiConfig.updateSessionTitle}
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
addMessageToSession={aiConfig.addMessageToSession}
updateLastMessage={aiConfig.updateLastMessage}
updateMessageById={aiConfig.updateMessageById}
persistContextCompaction={aiConfig.persistContextCompaction}
providers={aiConfig.providers}
activeProviderId={aiConfig.activeProviderId}
activeModelId={aiConfig.activeModelId}
defaultAgentId={aiConfig.defaultAgentId}
toolIntegrationMode={aiConfig.toolIntegrationMode}
externalAgents={aiConfig.externalAgents}
setExternalAgents={aiConfig.setExternalAgents}
agentModelMap={aiConfig.agentModelMap}
setAgentModel={aiConfig.setAgentModel}
agentProviderMap={aiConfig.agentProviderMap}
setAgentProvider={aiConfig.setAgentProvider}
globalPermissionMode={aiConfig.globalPermissionMode}
setGlobalPermissionMode={aiConfig.setGlobalPermissionMode}
commandBlocklist={aiConfig.commandBlocklist}
commandTimeout={aiConfig.commandTimeout}
maxIterations={aiConfig.maxIterations}
webSearchConfig={aiConfig.webSearchConfig}
quickMessages={aiConfig.quickMessages}
scopeType={context.scopeType}
scopeTargetId={context.scopeTargetId}
scopeHostIds={context.scopeHostIds}
@@ -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,
+49 -18
View File
@@ -1,5 +1,5 @@
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { setNotify } from '../../application/notification';
import { cn } from '../../lib/utils';
@@ -15,22 +15,37 @@ export interface Toast {
actionLabel?: string;
}
interface ToastContextValue {
toasts: Toast[];
interface ToastActionsValue {
showToast: (toast: Omit<Toast, 'id'>) => void;
dismissToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
interface ToastStateValue {
toasts: Toast[];
}
export const useToast = () => {
const context = useContext(ToastContext);
const ToastActionsContext = createContext<ToastActionsValue | null>(null);
const ToastStateContext = createContext<ToastStateValue | null>(null);
/** Actions-only subscription — does not re-render when the toast list changes. */
export const useToastActions = () => {
const context = useContext(ToastActionsContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
throw new Error('useToastActions must be used within a ToastProvider');
}
return context;
};
/** Full toast API. Prefer useToastActions when you do not need the list. */
export const useToast = () => {
const actions = useContext(ToastActionsContext);
const state = useContext(ToastStateContext);
if (!actions || !state) {
throw new Error('useToast must be used within a ToastProvider');
}
return { ...state, ...actions };
};
// Simple hook for components that may not be inside ToastProvider
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
@@ -77,16 +92,16 @@ const TOAST_STYLES: Record<ToastType, string> = {
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
const showToast = useCallback((nextToast: Omit<Toast, 'id'>) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newToast: Toast = { ...toast, id };
setToasts(prev => [...prev, newToast]);
const created: Toast = { ...nextToast, id };
setToasts(prev => [...prev, created]);
// Auto dismiss
if (toast.duration !== 0) {
if (nextToast.duration !== 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, toast.duration || 4000);
}, nextToast.duration || 4000);
}
}, []);
@@ -94,6 +109,16 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const actionsValue = useMemo<ToastActionsValue>(
() => ({ showToast, dismissToast }),
[showToast, dismissToast],
);
const stateValue = useMemo<ToastStateValue>(
() => ({ toasts }),
[toasts],
);
// Register global toast function
useEffect(() => {
globalShowToast = showToast;
@@ -104,15 +129,21 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}, [showToast]);
return (
<ToastContext.Provider value={{ toasts, showToast, dismissToast }}>
{children}
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
</ToastContext.Provider>
<ToastActionsContext.Provider value={actionsValue}>
<ToastStateContext.Provider value={stateValue}>
{children}
<ToastContainer />
</ToastStateContext.Provider>
</ToastActionsContext.Provider>
);
};
const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => void }> = ({ toasts, onDismiss }) => {
if (toasts.length === 0) return null;
const ToastContainer: React.FC = () => {
const { toasts } = useContext(ToastStateContext) ?? { toasts: [] as Toast[] };
const actions = useContext(ToastActionsContext);
const onDismiss = actions?.dismissToast;
if (toasts.length === 0 || !onDismiss) return null;
const handleToastClick = (t: Toast) => {
if (t.onClick) {
+104 -26
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";
@@ -18,6 +18,8 @@ import {
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from "./VaultPageHeader";
import { useConnectionLogsStore } from "../../application/state/connectionLogsStore";
import { useNotesStore } from "../../application/state/notesStore";
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
import { toast } from "../ui/toast";
import { AppWordmark } from "../AppWordmark";
@@ -31,6 +33,95 @@ 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,
hosts,
isActive,
openNoteId,
onOpenNoteIdHandled,
onOpenHost,
}: {
NotesManager: React.ComponentType<any>;
hosts: any[];
isActive: boolean;
openNoteId: string | null;
onOpenNoteIdHandled: () => void;
onOpenHost: (host: any, source?: { noteId?: string }) => void;
}) {
const { notes, noteGroups, updateNotes, updateNoteGroups } = useNotesStore({
enabled: isActive,
});
return (
<NotesManager
notes={notes}
noteGroups={noteGroups}
hosts={hosts}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
isActive={isActive}
openNoteId={openNoteId}
onOpenNoteIdHandled={onOpenNoteIdHandled}
onOpenHost={onOpenHost}
/>
);
}
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.
*/
function VaultConnectionLogsSection({
ConnectionLogsManager,
hosts,
onOpenLogView,
}: {
ConnectionLogsManager: React.ComponentType<any>;
hosts: any[];
onOpenLogView: (log: any) => void;
}) {
const {
connectionLogs,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
} = useConnectionLogsStore();
return (
<ConnectionLogsManager
logs={connectionLogs}
hosts={hosts}
onToggleSaved={toggleConnectionLogSaved}
onDelete={deleteConnectionLog}
onClearUnsaved={clearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
);
}
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
const {
Activity,
@@ -52,7 +143,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
Clock,
cn,
commitInlineGroupRename,
connectionLogs,
connectSelectedHosts,
ContextMenu,
ContextMenuContent,
@@ -148,16 +238,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
Network,
newFolderName,
newHostGroupPath,
onClearUnsavedConnectionLogs,
onConnectSerial,
onCreateLocalTerminal,
onDeleteConnectionLog,
onDeleteHost,
onImportOrReuseKey,
onOpenLogView,
onOpenSettings,
onRunSnippet,
onToggleConnectionLogSaved,
onUpdateCustomGroups,
onUpdateGroupConfigs,
onUpdateHosts,
@@ -280,15 +367,11 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
} = ctx;
const {
knownHosts,
noteGroups,
NotebookText,
notes,
NotesManager,
onOpenHostFromNote,
onOpenNoteIdHandled,
onOpenSnippetIdHandled,
onUpdateNoteGroups,
onUpdateNotes,
openNoteId,
openSnippetId,
} = ctx;
@@ -306,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],
@@ -1181,22 +1271,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
)}
data-section="vault-notes-retained"
>
<NotesManager
notes={notes}
noteGroups={noteGroups}
<MemoVaultNotesSection
NotesManager={NotesManager}
hosts={hosts}
onUpdateNotes={onUpdateNotes}
onUpdateNoteGroups={onUpdateNoteGroups}
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" && (
@@ -1311,12 +1392,9 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
resetKey="connection-logs"
>
<Suspense fallback={<VaultSectionLoading />}>
<LazyConnectionLogsManager
logs={connectionLogs}
<VaultConnectionLogsSection
ConnectionLogsManager={LazyConnectionLogsManager}
hosts={hosts}
onToggleSaved={onToggleConnectionLogSaved}
onDelete={onDeleteConnectionLog}
onClearUnsaved={onClearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
</Suspense>
@@ -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`.
+252 -49
View File
@@ -51,7 +51,11 @@ const ACCELERATOR_COLLECT_INNER = [
"fi; ",
'if command -v npu-smi >/dev/null 2>&1; then ',
'printf "%s\\n" "__NC_NPU_BEGIN__"; ',
// Prefer info -l; fall back to info -m (SwanLab / Ascend mapping table).
"ids=$(npu-smi info -l 2>/dev/null | sed -n 's/^[[:space:]]*NPU ID[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p'); ",
'if [ -z "$ids" ]; then ',
"ids=$(npu-smi info -m 2>/dev/null | awk 'NR>1 && $1 ~ /^[0-9]+$/ {print $1}' | sort -nu); ",
"fi; ",
'if [ -n "$ids" ]; then ',
'for id in $ids; do ',
'printf "%s\\n" "__NC_NPU_DEVICE__=$id"; ',
@@ -60,10 +64,11 @@ const ACCELERATOR_COLLECT_INNER = [
'npu-smi info -t usages -i "$id" 2>/dev/null || true; ',
'npu-smi info -t memory -i "$id" 2>/dev/null || true; ',
"done; ",
"else ",
"fi; ",
// Always keep the summary table; typed queries can be empty on ModelArts /
// containers, and Windows collectors only emit this dump.
'printf "%s\\n" "__NC_NPU_INFO__"; ',
"npu-smi info 2>/dev/null || true; ",
"fi; ",
'printf "%s\\n" "__NC_NPU_PROCS__"; ',
"npu-smi info -t proc-mem 2>/dev/null || true; ",
'printf "%s\\n" "__NC_NPU_END__"; ',
@@ -75,11 +80,51 @@ const ACCELERATOR_COLLECT_SCRIPT = `exec sh -c ${JSON.stringify(ACCELERATOR_COLL
function parseCsvNumber(raw) {
const text = String(raw ?? "").trim();
if (!text || /^\[?n\/?a\]?$/i.test(text) || /^not\s+supported$/i.test(text)) return null;
if (
!text
|| text === "-"
|| /^\[?n\/?a\]?$/i.test(text)
|| /^not\s+supported$/i.test(text)
) {
return null;
}
const n = Number.parseFloat(text.replace(/[,%]/g, ""));
return Number.isFinite(n) ? n : null;
}
function extractAscendDriverVersion(text) {
const match = String(text || "").match(
/\|\s*npu-smi\s+(\S+)\s+.*?Version:\s*(\S+)/i,
);
if (!match) return null;
const version = (match[2] || match[1] || "").replace(/\|/g, "").trim();
return version || null;
}
function applyMemPair(device, used, total, { aggregate = false } = {}) {
const usedN = parseCsvNumber(used);
const totalN = parseCsvNumber(total);
if (!Number.isFinite(usedN) && !Number.isFinite(totalN)) return;
if (
aggregate
&& Number.isFinite(device.memoryUsedMb)
&& Number.isFinite(usedN)
) {
device.memoryUsedMb = Number(device.memoryUsedMb) + usedN;
device.memoryTotalMb = Number(device.memoryTotalMb || 0)
+ (Number.isFinite(totalN) ? totalN : 0);
return;
}
if (Number.isFinite(usedN)) device.memoryUsedMb = usedN;
if (Number.isFinite(totalN)) device.memoryTotalMb = totalN;
}
function maxFinite(current, next) {
if (!Number.isFinite(next)) return current;
if (!Number.isFinite(current)) return next;
return Math.max(current, next);
}
function parseCsvFields(line) {
// nvidia-smi csv is simple (no embedded commas in queried fields with nounits)
return String(line || "")
@@ -243,48 +288,137 @@ function parseAscendDeviceBlock(index, block) {
};
}
/**
* Parse `npu-smi info` summary tables.
*
* Modern CANN (24.x+) rows look like nputop's fixture:
* | 6 910B1 | OK | 100.8 33 0 / 0 |
* | 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
* Chip-ID on the second row is NOT the NPU ID; attach metrics to the
* preceding NPU summary row (same approach as youyve/nputop libascend).
*
* Multi-chip cards (Atlas A3 / 310P) repeat the NPU summary once per chip.
* Sidebar shows one row per NPU ID and aggregates chip memory / util.
*/
function parseAscendInfoTable(sectionText) {
const devices = [];
const lines = String(sectionText || "").split("\n");
for (const line of lines) {
// | 0 910B3 | OK | 71.8 42 |
const match = line.match(
/^\|\s*(\d+)\s+(\S+(?:\s+\S+)*?)\s+\|\s*(\S+)\s+\|\s*([0-9.]+)\s+([0-9.]+)\s*\|/,
);
if (!match) continue;
const index = Number.parseInt(match[1], 10);
if (!Number.isFinite(index)) continue;
if (devices.some((d) => d.index === index)) continue;
devices.push({
vendor: "ascend",
index,
uuid: "",
name: match[2].trim(),
utilizationPercent: null,
memoryUsedMb: null,
memoryTotalMb: null,
temperatureC: parseCsvNumber(match[5]),
powerDrawW: parseCsvNumber(match[4]),
powerLimitW: null,
fanPercent: null,
driverVersion: null,
health: match[3].trim(),
});
const driverVersion = extractAscendDriverVersion(sectionText);
// Summary: NPU ID, Name, Health, Power, Temp; tolerate Hugepages column after Temp.
// Power may be "NA" / "-"; Name is a single token like 910B1 / Ascend910.
const summaryRe =
/^\|\s*(\d+)\s+(\S+)\s+\|\s*(\S+)\s+\|\s*(\S+)\s+(\d+(?:\.\d+)?)\b/;
// Bus-Id chip row (CANN 24.x): | ChipID [PhyID] | Bus-Id | AICore(%) ... mem pairs ... |
const busChipRe =
/^\|\s*(\d+)\s*(\d*)\s*\|\s*([0-9A-Fa-f:.]+|NA)\s*\|\s*(\d+(?:\.\d+)?)\b/;
// Legacy whitespace chip row: | NPU Chip Logic AICore Mem/Tot HBM/Tot |
const legacyChipRe =
/^\|\s*(\d+)\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/;
let lastDevice = null;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i].trim();
if (!line.startsWith("|")) continue;
const summary = line.match(summaryRe);
if (summary) {
const index = Number.parseInt(summary[1], 10);
if (!Number.isFinite(index)) continue;
const name = summary[2].trim();
if (!name || /^(?:NPU|Chip|Name)$/i.test(name)) continue;
let device = devices.find((d) => d.index === index);
const isNew = !device;
if (!device) {
device = {
vendor: "ascend",
index,
uuid: "",
name,
utilizationPercent: null,
memoryUsedMb: null,
memoryTotalMb: null,
temperatureC: parseCsvNumber(summary[5]),
powerDrawW: parseCsvNumber(summary[4]),
powerLimitW: null,
fanPercent: null,
driverVersion,
health: summary[3].trim(),
_chipCount: 0,
};
devices.push(device);
} else {
if (!device.name) device.name = name;
device.temperatureC = maxFinite(device.temperatureC, parseCsvNumber(summary[5]));
if (device.powerDrawW == null) device.powerDrawW = parseCsvNumber(summary[4]);
if (!device.health || /^ok$/i.test(device.health)) {
const health = summary[3].trim();
if (health) device.health = health;
}
if (!device.driverVersion && driverVersion) device.driverVersion = driverVersion;
}
lastDevice = device;
const next = (lines[i + 1] || "").trim();
const busChip = next.match(busChipRe);
if (busChip) {
i += 1;
const util = parseCsvNumber(busChip[4]);
device.utilizationPercent = maxFinite(device.utilizationPercent, util);
const pairs = [...next.matchAll(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/g)];
if (pairs.length > 0) {
const hbm = pairs[pairs.length - 1];
applyMemPair(device, hbm[1], hbm[2], { aggregate: !isNew && device._chipCount > 0 });
}
device._chipCount = (device._chipCount || 0) + 1;
continue;
}
const legacyNext = next.match(legacyChipRe);
if (legacyNext) {
i += 1;
device.utilizationPercent = maxFinite(
device.utilizationPercent,
parseCsvNumber(legacyNext[2]),
);
applyMemPair(device, legacyNext[5], legacyNext[6], {
aggregate: !isNew && device._chipCount > 0,
});
device._chipCount = (device._chipCount || 0) + 1;
}
continue;
}
const legacy = line.match(legacyChipRe);
if (legacy) {
const index = Number.parseInt(legacy[1], 10);
const device = devices.find((d) => d.index === index) || lastDevice;
if (!device) continue;
device.utilizationPercent = maxFinite(
device.utilizationPercent,
parseCsvNumber(legacy[2]),
);
applyMemPair(device, legacy[5], legacy[6], { aggregate: (device._chipCount || 0) > 0 });
device._chipCount = (device._chipCount || 0) + 1;
continue;
}
const busChip = line.match(busChipRe);
if (busChip && lastDevice) {
const util = parseCsvNumber(busChip[4]);
lastDevice.utilizationPercent = maxFinite(lastDevice.utilizationPercent, util);
const pairs = [...line.matchAll(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/g)];
if (pairs.length > 0) {
const hbm = pairs[pairs.length - 1];
applyMemPair(lastDevice, hbm[1], hbm[2], {
aggregate: (lastDevice._chipCount || 0) > 0,
});
}
lastDevice._chipCount = (lastDevice._chipCount || 0) + 1;
}
}
// Chip rows: | 0 0 0 12 1234 / 32768 0 / 32768 |
for (const line of lines) {
const chip = line.match(
/^\|\s*(\d+)\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/,
);
if (!chip) continue;
const index = Number.parseInt(chip[1], 10);
const device = devices.find((d) => d.index === index);
if (!device) continue;
device.utilizationPercent = parseCsvNumber(chip[2]);
// Prefer HBM pair (last) when present
device.memoryUsedMb = parseCsvNumber(chip[5]);
device.memoryTotalMb = parseCsvNumber(chip[6]);
for (const device of devices) {
delete device._chipCount;
}
return devices;
}
@@ -308,8 +442,11 @@ function parseAscendTypedSections(sectionText) {
function parseAscendProcesses(sectionText) {
const processes = [];
for (const line of String(sectionText || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed || /no running processes/i.test(trimmed)) continue;
// Common proc-mem lines include NPU/Chip/Pid/Name/Memory
const match = line.match(
const match = trimmed.match(
/(?:NPU|Device)?\s*I?D?\s*[:=]?\s*(\d+).*?\b(?:PID|Pid)\s*[:=]?\s*(\d+).*?\b(?:Name|Process)\s*[:=]?\s*(\S+).*?(?:Memory|Mem)\s*[:=]?\s*(\d+(?:\.\d+)?)/i,
);
if (match) {
@@ -323,8 +460,24 @@ function parseAscendProcesses(sectionText) {
continue;
}
// nputop / npu-smi info process table:
// | 0 0 | 124528 | python3.8 | 17400 |
const infoProc = trimmed.match(
/^\|\s*(\d+)\s+(\d+)\s+\|\s+(\d+)\s+\|\s*([^|]+?)\s*\|\s*(\d+(?:\.\d+)?)/,
);
if (infoProc) {
processes.push({
vendor: "ascend",
gpuIndex: Number.parseInt(infoProc[1], 10) || 0,
pid: Number.parseInt(infoProc[3], 10),
processName: infoProc[4].trim(),
memoryUsedMb: parseCsvNumber(infoProc[5]),
});
continue;
}
// Pipe-separated: | 0 | 0 | 12345 | python | 1024 |
const pipeTable = line.match(
const pipeTable = trimmed.match(
/^\|\s*(\d+)\s*\|\s*\d+\s*\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*(\d+(?:\.\d+)?)/,
);
if (pipeTable) {
@@ -339,7 +492,7 @@ function parseAscendProcesses(sectionText) {
}
// Whitespace-delimited inside one outer |: | 0 0 12345 python 1024 |
const wsTable = line.match(
const wsTable = trimmed.match(
/^\|\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\d+(?:\.\d+)?)\s*\|?\s*$/,
);
if (!wsTable) continue;
@@ -354,6 +507,18 @@ function parseAscendProcesses(sectionText) {
return processes.filter((p) => Number.isFinite(p.pid) && p.pid > 0);
}
function dedupeAcceleratorProcesses(processes) {
const seen = new Set();
const out = [];
for (const processInfo of processes) {
const key = `${processInfo.vendor}:${processInfo.gpuIndex}:${processInfo.pid}:${processInfo.processName}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(processInfo);
}
return out;
}
function sliceMarkedSection(text, beginMarker, endMarkers) {
const start = text.indexOf(beginMarker);
if (start === -1) return "";
@@ -383,17 +548,55 @@ function parseAcceleratorSnapshot(stdout) {
const nvidiaProcesses = parseNvidiaProcesses(nvidiaProcessesText, nvidiaDevices);
let ascendDevices = parseAscendTypedSections(npuSection);
const infoDump = sliceMarkedSection(npuSection, "__NC_NPU_INFO__", [
"__NC_NPU_PROCS__",
"__NC_NPU_END__",
]);
const tableDevices = parseAscendInfoTable(infoDump || npuSection);
if (ascendDevices.length === 0) {
const infoDump = sliceMarkedSection(npuSection, "__NC_NPU_INFO__", [
"__NC_NPU_PROCS__",
"__NC_NPU_END__",
]);
ascendDevices = parseAscendInfoTable(infoDump || npuSection);
ascendDevices = tableDevices;
} else if (tableDevices.length > 0) {
// Fill gaps when typed -t queries returned stubs but the summary table is rich.
const byIndex = new Map(ascendDevices.map((d) => [d.index, d]));
for (const tableDevice of tableDevices) {
const existing = byIndex.get(tableDevice.index);
if (!existing) {
ascendDevices.push(tableDevice);
byIndex.set(tableDevice.index, tableDevice);
continue;
}
if (!existing.name || /^Ascend NPU\b/i.test(existing.name)) {
existing.name = tableDevice.name;
}
for (const key of [
"utilizationPercent",
"memoryUsedMb",
"memoryTotalMb",
"temperatureC",
"powerDrawW",
"health",
]) {
if (existing[key] == null && tableDevice[key] != null) {
existing[key] = tableDevice[key];
}
}
}
}
const ascendProcText = sliceMarkedSection(npuSection, "__NC_NPU_PROCS__", [
"__NC_NPU_END__",
]);
const ascendProcesses = parseAscendProcesses(ascendProcText);
// Processes often live in the `npu-smi info` dump (nputop fixtures), not only -t proc-mem.
const ascendProcesses = dedupeAcceleratorProcesses([
...parseAscendProcesses(ascendProcText),
...parseAscendProcesses(infoDump || npuSection),
]);
const ascendDriverVersion = extractAscendDriverVersion(infoDump || npuSection);
if (ascendDriverVersion) {
for (const device of ascendDevices) {
if (!device.driverVersion) device.driverVersion = ascendDriverVersion;
}
}
const devices = [...nvidiaDevices, ...ascendDevices].sort((a, b) => {
if (a.vendor !== b.vendor) return a.vendor.localeCompare(b.vendor);
@@ -97,6 +97,10 @@ test("POSIX accelerator collector keeps sed quotes inside JSON-wrapped sh -c", (
assert.match(ACCELERATOR_COLLECT_SCRIPT, /sed -n '/);
// Outer wrapper must not use raw single quotes around the whole script body.
assert.doesNotMatch(ACCELERATOR_COLLECT_SCRIPT, /^exec sh -c '/);
// Always dump npu-smi info; discover IDs via info -l then info -m.
assert.match(ACCELERATOR_COLLECT_SCRIPT, /__NC_NPU_INFO__/);
assert.match(ACCELERATOR_COLLECT_SCRIPT, /npu-smi info -m/);
assert.match(ACCELERATOR_COLLECT_SCRIPT, /npu-smi info 2>\/dev\/null/);
});
test("POSIX accelerator collector is syntactically valid for sh -c", () => {
@@ -152,6 +156,52 @@ test("parseAscendInfoTable reads summary and chip rows", () => {
assert.equal(devices[0].temperatureC, 42);
});
test("parseAscendInfoTable reads CANN 24.x Bus-Id table with Hugepages column", () => {
// Fixture from GitHub issue #2811 (ModelArts / Ascend 910B1, npu-smi 24.1.rc2).
// Chip row Chip-ID is 0 while NPU ID is 6; metrics must attach to the NPU row.
const devices = parseAscendInfoTable(`
+------------------------------------------------------------------------------------------------+
| npu-smi 24.1.rc2 Version: 24.1.rc2 |
+---------------------------+---------------+----------------------------------------------------+
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
+===========================+===============+====================================================+
| 6 910B1 | OK | 100.8 33 0 / 0 |
| 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
+===========================+===============+====================================================+
| No running processes found in NPU 6 |
+===========================+===============+====================================================+
`);
assert.equal(devices.length, 1);
assert.equal(devices[0].index, 6);
assert.equal(devices[0].name, "910B1");
assert.equal(devices[0].health, "OK");
assert.equal(devices[0].powerDrawW, 100.8);
assert.equal(devices[0].temperatureC, 33);
assert.equal(devices[0].utilizationPercent, 0);
assert.equal(devices[0].memoryUsedMb, 3384);
assert.equal(devices[0].memoryTotalMb, 65536);
});
test("parseAcceleratorSnapshot falls back to modern npu-smi info table", () => {
const snapshot = parseAcceleratorSnapshot(`
__NC_ACCEL_BEGIN__
__NC_NPU_BEGIN__
__NC_NPU_INFO__
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
| 6 910B1 | OK | 100.8 33 0 / 0 |
| 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
__NC_NPU_PROCS__
__NC_NPU_END__
__NC_ACCEL_END__
`);
assert.equal(snapshot.devices.length, 1);
assert.equal(snapshot.devices[0].vendor, "ascend");
assert.equal(snapshot.devices[0].index, 6);
assert.equal(snapshot.devices[0].memoryTotalMb, 65536);
});
test("parseAcceleratorSnapshot merges nvidia and ascend marked sections", () => {
const snapshot = parseAcceleratorSnapshot(`
__NC_ACCEL_BEGIN__
@@ -177,3 +227,130 @@ __NC_ACCEL_END__
assert.equal(snapshot.processes.length, 1);
assert.equal(snapshot.nvidiaDriverVersion, "550.54");
});
test("parseAcceleratorSnapshot enriches typed Ascend stubs from info table", () => {
const snapshot = parseAcceleratorSnapshot(`
__NC_ACCEL_BEGIN__
__NC_NPU_BEGIN__
__NC_NPU_DEVICE__=6
__NC_NPU_INFO__
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
| 6 910B1 | OK | 100.8 33 0 / 0 |
| 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
__NC_NPU_PROCS__
__NC_NPU_END__
__NC_ACCEL_END__
`);
assert.equal(snapshot.devices.length, 1);
assert.equal(snapshot.devices[0].index, 6);
assert.equal(snapshot.devices[0].name, "910B1");
assert.equal(snapshot.devices[0].memoryUsedMb, 3384);
assert.equal(snapshot.devices[0].memoryTotalMb, 65536);
assert.equal(snapshot.devices[0].temperatureC, 33);
assert.equal(snapshot.devices[0].powerDrawW, 100.8);
});
// Fixtures adapted from youyve/nputop tests/test_libascend.py (Apache-2.0).
// Valuable because we do not have Ascend hardware in CI.
test("parseAscendInfoTable reads nputop 910B2C dual-NPU fixture", () => {
const devices = parseAscendInfoTable(`
+------------------------------------------------------------------------------------------------+
| npu-smi 23.0.2.1 Version: 23.0.2.1 |
+---------------------------+---------------+----------------------------------------------------+
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
+===========================+===============+====================================================+
| 0 910B2C | OK | 88.6 51 0 / 0 |
| 0 | 0000:5A:00.0 | 0 0 / 0 20701/ 65536 |
+===========================+===============+====================================================+
| 1 910B2C | OK | 99.6 50 0 / 0 |
| 0 | 0000:19:00.0 | 0 0 / 0 20687/ 65536 |
+===========================+===============+====================================================+
`);
assert.equal(devices.length, 2);
assert.equal(devices[0].name, "910B2C");
assert.equal(devices[0].memoryUsedMb, 20701);
assert.equal(devices[0].memoryTotalMb, 65536);
assert.equal(devices[0].powerDrawW, 88.6);
assert.equal(devices[0].driverVersion, "23.0.2.1");
assert.equal(devices[1].memoryUsedMb, 20687);
});
test("parseAscendInfoTable reads nputop 310B4 no-HBM column fixture", () => {
const devices = parseAscendInfoTable(`
| npu-smi 23.0.0 Version: 23.0.0 |
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
| 0 310B4 | Alarm | 0.0 65 15 / 15 |
| 0 0 | NA | 0 3628 / 15609 |
`);
assert.equal(devices.length, 1);
assert.equal(devices[0].name, "310B4");
assert.equal(devices[0].health, "Alarm");
assert.equal(devices[0].memoryUsedMb, 3628);
assert.equal(devices[0].memoryTotalMb, 15609);
assert.equal(devices[0].temperatureC, 65);
});
test("parseAscendInfoTable aggregates Atlas A3 multi-chip NPU rows", () => {
const devices = parseAscendInfoTable(`
| npu-smi 25.2.0 Version: 25.2.0 |
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
| Chip Phy-ID | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
| 0 Ascend910 | OK | 162.8 37 0 / 0 |
| 0 0 | 0000:9C:00.0 | 0 0 / 0 3133 / 65536 |
| 0 Ascend910 | OK | - 37 0 / 0 |
| 1 1 | 0000:9E:00.0 | 0 0 / 0 2876 / 65536 |
| 1 Ascend910 | OK | 167.1 38 0 / 0 |
| 0 2 | 0000:37:00.0 | 0 0 / 0 3116 / 65536 |
| 1 Ascend910 | OK | - 38 0 / 0 |
| 1 3 | 0000:39:00.0 | 0 0 / 0 10568/ 65536 |
`);
assert.equal(devices.length, 2);
assert.equal(devices[0].name, "Ascend910");
assert.equal(devices[0].powerDrawW, 162.8);
assert.equal(devices[0].memoryUsedMb, 3133 + 2876);
assert.equal(devices[0].memoryTotalMb, 65536 + 65536);
assert.equal(devices[1].memoryUsedMb, 3116 + 10568);
assert.equal(devices[1].powerDrawW, 167.1);
});
test("parseAcceleratorSnapshot reads nputop 310P3 processes from info dump", () => {
const snapshot = parseAcceleratorSnapshot(`
__NC_ACCEL_BEGIN__
__NC_NPU_BEGIN__
__NC_NPU_INFO__
| npu-smi 24.1.0.1 Version: 24.1.0.1 |
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
| 1 310P3 | OK | NA 62 7210 / 7210 |
| 0 0 | 0000:01:00.0 | 0 16302/ 44280 |
| 1 310P3 | OK | NA 62 7210 / 7210 |
| 1 1 | 0000:01:00.0 | 0 15543/ 43693 |
| 2 310P3 | OK | NA 61 17057 / 17057 |
| 0 2 | 0000:02:00.0 | 0 35563/ 44280 |
| 2 310P3 | OK | NA 61 16823 / 16823 |
| 1 3 | 0000:02:00.0 | 0 35204/ 43693 |
| NPU Chip | Process id | Process name | Process memory(MB) |
| 1 0 | 3277562 | mindie_llm_back | 14513 |
| 1 1 | 3277565 | mindie_llm_back | 14513 |
| 2 0 | 3034986 | mindie_llm_back | 34207 |
| 2 1 | 3034989 | mindie_llm_back | 33740 |
__NC_NPU_PROCS__
__NC_NPU_END__
__NC_ACCEL_END__
`);
assert.equal(snapshot.devices.length, 2);
assert.equal(snapshot.devices[0].index, 1);
assert.equal(snapshot.devices[0].name, "310P3");
assert.equal(snapshot.devices[0].memoryUsedMb, 16302 + 15543);
assert.equal(snapshot.devices[0].powerDrawW, null);
assert.equal(snapshot.devices[0].driverVersion, "24.1.0.1");
assert.equal(snapshot.processes.length, 4);
assert.equal(snapshot.processes[0].pid, 3277562);
assert.equal(snapshot.processes[0].processName, "mindie_llm_back");
assert.equal(snapshot.processes[0].memoryUsedMb, 14513);
assert.equal(snapshot.processes[0].gpuIndex, 1);
});
@@ -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 = [];
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more