[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,797 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useRef, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { activeTabStore } from '../../application/state/activeTabStore';
import { getSftpCurrentPathMemoryKey } from '../../application/state/sftp/sftpReopenLocation';
import {
getSidePanelLiveSnapshot,
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
subscribeSidePanelLiveSnapshot,
} from '../../application/state/sidePanelLiveStore';
import {
getEmptyNotesSnapshot,
getNotesSnapshot,
subscribeNotes,
subscribeNotesNoop,
useNotesStore,
} from '../../application/state/notesStore';
import {
getShellHistorySnapshot,
subscribeShellHistory,
} from '../../application/state/shellHistoryStore';
import { useScriptExecution } from '../../application/state/useScriptExecution';
import { useRemoteHistoryState } from '../../application/state/useRemoteHistoryState';
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
import { shouldKeepTerminalBackgroundWorkActive } from '../../domain/terminalHibernate';
import { resolveTerminalFontFamilyId } from '../../infrastructure/config/fonts';
import type { Host, TerminalSession, Workspace } from '../../types';
import { SystemManagerSidePanel } from '../systemManager/SystemManagerSidePanel';
import { resolveSftpFollowTerminalCwdTargetHost } from '../../domain/sftpFollowTerminalCwd';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
import type { SidePanelTab } from './TerminalLayerSupport';
import {
collectSidePanelPanes,
sidePanelLayoutHasTool,
type SidePanelLayout,
} from '../../domain/sidePanelLayout';
import { sidePanelHiddenNotesPanelClassName, sidePanelHiddenPanelClassName } from './terminalLayerSidePanelHiddenWrapper';
import { shouldKeepSftpBrowseSessionInteractive } from './sftpPanelLifecycle';
type SidePanelStableContext = Record<string, any> & {
sftpPaneClosedTabIdsRef: React.MutableRefObject<Set<string>>;
};
const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : '';
const EMPTY_VAULT_NOTES: never[] = [];
const EMPTY_VAULT_HOSTS: never[] = [];
const EMPTY_VAULT_SNIPPETS: never[] = [];
const EMPTY_SHELL_HISTORY = Object.freeze([]) as readonly never[];
const subscribeShellHistoryNoop = () => () => {};
const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY;
function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
const retainedSnapshot = useRef({ tabId, snapshot: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
const getSnapshot = useCallback(() => {
if (!subscribe) return SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
const snapshot = getSidePanelLiveSnapshot(true);
const snapshotTabId = snapshot.activeWorkspace?.id ?? snapshot.focusedSessionId;
// Tab visibility changes before the live publisher commits its next snapshot.
// Keep this panel's last context until its own terminal/workspace catches up.
if (snapshotTabId === tabId) retainedSnapshot.current = { tabId, snapshot };
return retainedSnapshot.current.tabId === tabId
? retainedSnapshot.current.snapshot
: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
}, [subscribe, tabId]);
return useSyncExternalStore(
(listener) => subscribeSidePanelLiveSnapshot(subscribe, listener),
getSnapshot,
getSnapshot,
);
}
function SidePanelSftpSlotInner({
tabId,
ctx,
isVisible,
ownerPanelOpen,
}: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
/** Side panel still open for this tab (may be showing another tool). */
ownerPanelOpen: boolean;
}) {
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
const {
SftpSidePanel,
effectiveHosts,
hosts,
sessions,
keys,
identities,
knownHosts,
updateHosts,
handleAddKnownHost,
sftpDefaultViewMode,
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
handleSftpInitialLocationApplied,
handleSftpCurrentPathChange,
handleSftpActiveTransfersChange,
handleSftpActiveExternalEditsChange,
handlePendingUploadHandled,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
getTerminalCwd,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
refocusActiveTerminalSession,
terminalSettings,
} = ctx;
const storedSftpHost = sftpHostForTab.get(tabId) ?? null;
const panelActiveHost = isVisible
? (live.sftpActiveHost ?? storedSftpHost)
: storedSftpHost;
const panelActiveSessionId = isVisible ? live.activeTerminalSessionIdForSftp : null;
const panelFocusedSessionId = isVisible ? live.focusedSessionId : null;
// Only the head of the per-tab pending upload queue is surfaced at a time;
// the panel advances the queue by reporting each request as handled.
const pendingUpload = sftpPendingUploadsForTab.get(tabId)?.[0] ?? null;
const handleFollowTerminalCwdChange = useCallback((enabled: boolean, visibleHost?: Host | null) => {
const isActive = activeTabStore.getActiveTabId() === tabId;
const stored = (sftpHostForTab as Map<string, Host>).get(tabId) ?? null;
const snapshot = getSidePanelLiveSnapshot(isActive);
const activeHost = isActive ? (snapshot.sftpActiveHost ?? stored) : stored;
const targetHost = resolveSftpFollowTerminalCwdTargetHost(visibleHost, activeHost);
if (!targetHost?.id) {
setSftpFollowTerminalCwd(enabled);
return;
}
let updated = false;
const nextHosts = (hosts as Host[]).map((host) => {
if (host.id !== targetHost.id) return host;
updated = true;
return { ...host, sftpFollowTerminalCwd: enabled };
});
if (updated) {
updateHosts(nextHosts);
} else {
setSftpFollowTerminalCwd(enabled);
}
}, [hosts, sftpHostForTab, setSftpFollowTerminalCwd, tabId, updateHosts]);
const handleInitialLocationApplied = useCallback(
(location: { hostId: string; path: string }) => {
handleSftpInitialLocationApplied(tabId, location);
},
[handleSftpInitialLocationApplied, tabId],
);
const handlePendingUploadHandledForTab = useCallback(
(requestId: string) => {
handlePendingUploadHandled(tabId, requestId);
},
[handlePendingUploadHandled, tabId],
);
const handleCurrentPathChange = useCallback(
(location: { hostId: string; connectionKey: string; path: string }) => {
handleSftpCurrentPathChange(
getSftpCurrentPathMemoryKey({
tabId,
activeTerminalSessionIdForSftp: panelActiveSessionId,
focusedSessionId: panelFocusedSessionId,
}),
location,
);
},
[handleSftpCurrentPathChange, panelActiveSessionId, panelFocusedSessionId, tabId],
);
const handleActiveTransfersChange = useCallback(
(count: number) => {
handleSftpActiveTransfersChange(tabId, count);
},
[handleSftpActiveTransfersChange, tabId],
);
const handleActiveExternalEditsChange = useCallback(
(count: number) => {
handleSftpActiveExternalEditsChange(tabId, count);
},
[handleSftpActiveExternalEditsChange, tabId],
);
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<SftpSidePanel
transferOwnerId={`terminal:${tabId}`}
hosts={effectiveHosts}
writableHosts={hosts}
sessions={sessions}
keys={keys}
identities={identities}
knownHosts={knownHosts}
updateHosts={updateHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={panelActiveHost}
activeSessionId={panelActiveSessionId}
focusedSessionId={panelFocusedSessionId}
initialLocation={isVisible ? (sftpInitialLocationForTab.get(tabId) ?? null) : null}
onInitialLocationApplied={handleInitialLocationApplied}
onCurrentPathChange={handleCurrentPathChange}
onActiveTransfersChange={handleActiveTransfersChange}
onActiveExternalEditsChange={handleActiveExternalEditsChange}
showWorkspaceHostHeader={isVisible && !!live.activeWorkspace}
isVisible={isVisible}
ownerPanelOpen={ownerPanelOpen}
renderOverlays={isVisible}
pendingUpload={pendingUpload}
onPendingUploadHandled={handlePendingUploadHandledForTab}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={isVisible ? sftpAutoSync : false}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
activeTerminalCwd={isVisible ? live.activeTerminalCwd : null}
activeTerminalCwdTrusted={isVisible ? live.activeTerminalCwdTrusted : false}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
onSftpFollowTerminalCwdChange={handleFollowTerminalCwdChange}
onRequestTerminalFocus={refocusActiveTerminalSession}
terminalSettings={terminalSettings}
/>
</div>
);
}
export const SidePanelSftpSlot = memo(SidePanelSftpSlotInner);
SidePanelSftpSlot.displayName = 'SidePanelSftpSlot';
function SidePanelSystemSlotInner({
tabId,
ctx,
isTabActive,
isVisible,
isSelected,
}: {
tabId: string;
ctx: SidePanelStableContext;
isTabActive: boolean;
isVisible: boolean;
isSelected: boolean;
}) {
// When this tab is active, prefer live store so focus changes do not require
// workspaceById identity churn through the stable side-panel ctx.
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
const sessions = ctx.sessions as TerminalSession[];
const sessionHostsMap = ctx.sessionHostsMap as Map<string, Host>;
const workspace = (ctx.workspaceById as Map<string, Workspace>).get(tabId);
const standaloneSession = sessions.find((session) => session.id === tabId);
const resolvedSession = resolveSystemSidebarSession(
sessions,
workspace,
workspace?.focusedSessionId,
standaloneSession,
);
const systemSession = (isVisible
? (live.activeTerminalSessionForSystem ?? resolvedSession)
: resolvedSession) ?? null;
const systemHost = (isVisible && live.activeSystemSessionHost)
? live.activeSystemSessionHost
: (systemSession ? sessionHostsMap.get(systemSession.id) ?? null : null);
const keepSystemWorkActive = isSelected
&& shouldKeepTerminalBackgroundWorkActive(
ctx.terminalSettings,
systemHost?.protocol,
isTabActive,
);
const {
refocusActiveTerminalSession,
snippets,
terminalSettings,
} = ctx;
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<SystemManagerSidePanel
key={systemSession?.id ?? 'system-none'}
session={systemSession ?? null}
sessionHost={systemHost}
showWorkspaceHostHeader={isVisible && !!workspace}
isVisible={keepSystemWorkActive}
terminalSettings={terminalSettings}
snippets={snippets}
onRequestTerminalFocus={refocusActiveTerminalSession}
/>
</div>
);
}
export const SidePanelSystemSlot = memo(SidePanelSystemSlotInner);
SidePanelSystemSlot.displayName = 'SidePanelSystemSlot';
function SidePanelScriptsSlotInner({
tabId,
ctx,
isVisible,
}: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
// Subscribe only while visible so retained scripts slots skip log thrash.
const { runs: scriptRuns } = useScriptExecution({ enabled: isVisible });
const {
ScriptsSidePanel,
snippets,
snippetPackages,
updateSnippets,
updateSnippetPackages,
handleSnippetFromPanel,
handleRunScriptFromPanel,
handleRunScriptOnWorkspace,
handleStartRecordingFromPanel,
handleStopScriptRun,
handlePauseScriptRun,
handleResumeScriptRun,
} = ctx;
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<ScriptsSidePanel
snippets={snippets}
packages={snippetPackages}
onSnippetsChange={updateSnippets}
onPackagesChange={updateSnippetPackages}
onSnippetClick={handleSnippetFromPanel}
onRunScript={handleRunScriptFromPanel}
onRunScriptOnWorkspace={handleRunScriptOnWorkspace}
onStartRecording={handleStartRecordingFromPanel}
runs={scriptRuns as import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun[]}
onStopRun={handleStopScriptRun}
onPauseRun={handlePauseScriptRun}
onResumeRun={handleResumeScriptRun}
focusedSessionId={live.focusedSessionId ?? undefined}
isVisible={isVisible}
/>
</div>
);
}
export const SidePanelScriptsSlot = memo(SidePanelScriptsSlotInner);
SidePanelScriptsSlot.displayName = 'SidePanelScriptsSlot';
function SidePanelThemeSlotInner({
tabId,
ctx,
isVisible,
}: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
// Only subscribe while the theme panel is visible — not merely tab-active —
// so cwd/focus live ticks do not thrash a retained ThemeSidePanel.
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
const {
ThemeSidePanel,
followAppTerminalTheme,
terminalTheme,
terminalThemeId,
terminalFontFamilyId,
handleThemeChangeForFocusedSession,
handleThemeResetForFocusedSession,
handleFontFamilyChangeForFocusedSession,
handleFontFamilyResetForFocusedSession,
handleFontSizeChangeForFocusedSession,
handleFontSizeResetForFocusedSession,
handleFontWeightChangeForFocusedSession,
handleFontWeightResetForFocusedSession,
} = ctx;
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<ThemeSidePanel
followAppTerminalTheme={followAppTerminalTheme}
currentThemeId={live.previewedOrVisibleThemeId}
globalThemeId={terminalThemeId ?? terminalTheme.id}
currentFontFamilyId={resolveTerminalFontFamilyId(live.focusedFontFamilyId, navigatorPlatform)}
globalFontFamilyId={resolveTerminalFontFamilyId(terminalFontFamilyId, navigatorPlatform)}
currentFontSize={live.focusedFontSize}
currentFontWeight={live.focusedFontWeight}
canResetTheme={followAppTerminalTheme ? false : live.focusedThemeOverridden}
canResetFontFamily={live.focusedFontFamilyOverridden}
canResetFontSize={live.focusedFontSizeOverridden}
canResetFontWeight={live.focusedFontWeightOverridden}
onThemeChange={handleThemeChangeForFocusedSession}
onThemeReset={handleThemeResetForFocusedSession}
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
onFontSizeChange={handleFontSizeChangeForFocusedSession}
onFontSizeReset={handleFontSizeResetForFocusedSession}
onFontWeightChange={handleFontWeightChangeForFocusedSession}
onFontWeightReset={handleFontWeightResetForFocusedSession}
isVisible={isVisible}
/>
</div>
);
}
export const SidePanelThemeSlot = memo(SidePanelThemeSlotInner);
SidePanelThemeSlot.displayName = 'SidePanelThemeSlot';
function SidePanelNotesSlotInner({
tabId,
ctx,
isVisible,
}: {
tabId: string;
ctx: SidePanelStableContext;
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({ enabled: isVisible });
const {
NotesManager,
hosts,
handleOpenHostFromNotes,
} = ctx;
return (
<div
className={sidePanelHiddenNotesPanelClassName(!isVisible)}
data-section={isVisible ? 'terminal-notes-panel' : undefined}
>
<NotesManager
notes={notes}
noteGroups={noteGroups}
hosts={hosts}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
onOpenHost={handleOpenHostFromNotes}
displayMode="sidebar"
isActive={isVisible}
openNoteId={openNoteRequest?.noteId ?? null}
openNoteRequestId={openNoteRequest?.requestId ?? null}
/>
</div>
);
}
export const SidePanelNotesSlot = memo(SidePanelNotesSlotInner);
SidePanelNotesSlot.displayName = 'SidePanelNotesSlot';
function SidePanelHistorySlotInner({
activeTabId,
ctx,
isVisible,
}: {
activeTabId: string | null;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
const live = useSidePanelLiveSnapshotForTab(activeTabId ?? '', isVisible);
// Own remote-history state here so fetch/loading does not re-render TerminalLayer.
const remoteHistory = useRemoteHistoryState();
// Gate store subscription while History is hidden so command appends do not
// re-render this retained mount (panel still mounts for fast reopen).
const shellHistory = useSyncExternalStore(
isVisible ? subscribeShellHistory : subscribeShellHistoryNoop,
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
);
const {
HistorySidePanel,
handleHistoryPaste,
handleHistoryDelete,
handleHistoryRun,
} = ctx;
if (!isVisible) return null;
return (
<div className="absolute inset-0 z-10">
<HistorySidePanel
focusedHost={live.focusedHost}
focusedSessionId={live.historySessionId}
state={remoteHistory.getState(live.focusedHost?.id, live.historySessionId)}
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
onFetch={remoteHistory.fetch}
onDeleteGlobalEntry={handleHistoryDelete}
onPasteToTerminal={handleHistoryPaste}
onRunInTerminal={handleHistoryRun}
isVisible
/>
</div>
);
}
export const SidePanelHistorySlot = memo(SidePanelHistorySlotInner);
SidePanelHistorySlot.displayName = 'SidePanelHistorySlot';
function SidePanelAiSlotInner({
activeTabId,
ctx,
isVisible,
}: {
activeTabId: string | null;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
const {
AIChatPanelsHost,
AISidePanelStateRoot,
mountedAiTabIds,
aiContextsByTabId,
resolveAIExecutorContext,
pendingTerminalSelectionForAI,
handlePendingTerminalSelectionConsumed,
hosts,
snippets,
onOpenVaultNoteFromChat,
onOpenVaultHostFromChat,
onOpenVaultSectionFromChat,
onOpenVaultSnippetFromChat,
validAIScopeTargetIds,
} = ctx;
const workspaces = ctx.workspaces as Workspace[];
// Gate notes subscription while AI is hidden so note edits do not re-render
// retained AI mounts (panel still mounts for fast reopen).
const notesSnapshot = useSyncExternalStore(
isVisible ? subscribeNotes : subscribeNotesNoop,
isVisible ? getNotesSnapshot : getEmptyNotesSnapshot,
isVisible ? getNotesSnapshot : getEmptyNotesSnapshot,
);
const activeLayout = activeTabId
? (ctx.sidePanelLayouts as Map<string, SidePanelLayout>).get(activeTabId)
: null;
const hideVisibleShell = (
AI_PANEL_FORCE_HIDE_SHELL
&& isVisible
&& (!activeLayout || collectSidePanelPanes(activeLayout.root).length <= 1)
);
// Keep the AI state root mounted even with zero panel hosts so workspace
// merge/detach can seed and hand off scoped chats before orphan cleanup.
if (mountedAiTabIds.length === 0 || hideVisibleShell) {
return (
<AISidePanelStateRoot
validAIScopeTargetIds={validAIScopeTargetIds}
workspaces={workspaces}
>
{null}
</AISidePanelStateRoot>
);
}
// Only the visible AI panel needs vault catalogs for artifact navigation.
// Hidden retained panels keep session state without re-binding huge hosts/notes.
const injectVaultCatalog = isVisible;
const notes = injectVaultCatalog
? (notesSnapshot.notes as import('../../domain/models').VaultNote[])
: EMPTY_VAULT_NOTES;
return (
<AISidePanelStateRoot
validAIScopeTargetIds={validAIScopeTargetIds}
workspaces={workspaces}
>
<AIChatPanelsHost
mountedTabIds={mountedAiTabIds}
activeTabId={activeTabId}
activeSidePanelTab={isVisible ? 'ai' : null}
contextsByTabId={aiContextsByTabId}
resolveExecutorContext={resolveAIExecutorContext}
pendingTerminalSelection={pendingTerminalSelectionForAI}
onPendingTerminalSelectionConsumed={handlePendingTerminalSelectionConsumed}
notes={notes}
hosts={injectVaultCatalog ? hosts : EMPTY_VAULT_HOSTS}
snippets={injectVaultCatalog ? snippets : EMPTY_VAULT_SNIPPETS}
onOpenVaultNoteFromChat={onOpenVaultNoteFromChat}
onOpenVaultHostFromChat={onOpenVaultHostFromChat}
onOpenVaultSectionFromChat={onOpenVaultSectionFromChat}
onOpenVaultSnippetFromChat={onOpenVaultSnippetFromChat}
/>
</AISidePanelStateRoot>
);
}
export const SidePanelAiSlot = memo(SidePanelAiSlotInner);
SidePanelAiSlot.displayName = 'SidePanelAiSlot';
export function getFocusedPortalDescendant(
mountNode: HTMLElement,
activeElement: Element | null,
): HTMLElement | null {
return activeElement instanceof HTMLElement && mountNode.contains(activeElement)
? activeElement
: null;
}
export function movePersistentPortalNode(
mountNode: HTMLElement,
target: HTMLElement,
focusToRestore: HTMLElement | null,
) {
target.appendChild(mountNode);
if (focusToRestore && document.activeElement !== focusToRestore) {
focusToRestore.focus({ preventScroll: true });
}
}
function PersistentSidePanelPortal({
portalKey,
target,
children,
}: {
portalKey: string;
target: HTMLElement | null;
children: React.ReactNode;
}) {
const [mountNode] = React.useState(() => {
const node = document.createElement('div');
node.className = 'absolute inset-0 overflow-hidden';
node.dataset.sidePanelPortal = portalKey;
return node;
});
const focusRestoreRef = React.useRef<HTMLElement | null>(null);
// The React portal always targets the same detached node. Moving that node
// between a pane host and the hidden parking host preserves the mounted
// subtree (including active SFTP/AI state) instead of remounting it whenever
// the focused pane changes.
React.useLayoutEffect(() => {
if (!target) return;
const activeElement = document.activeElement;
const focusedDescendant = focusRestoreRef.current
?? getFocusedPortalDescendant(mountNode, activeElement);
movePersistentPortalNode(mountNode, target, focusedDescendant);
focusRestoreRef.current = null;
return () => {
if (mountNode.parentNode !== target) return;
const currentActiveElement = document.activeElement;
focusRestoreRef.current = getFocusedPortalDescendant(mountNode, currentActiveElement);
mountNode.remove();
};
}, [mountNode, target]);
return createPortal(children, mountNode, portalKey);
}
export function resolveSidePanelPortalTarget<T>(
isVisible: boolean,
paneHost: T | null | undefined,
parkingHost: T | null,
): T | null {
return isVisible ? (paneHost ?? parkingHost) : parkingHost;
}
export function SidePanelMountedContent({
ctx,
paneHosts,
parkingHost,
}: {
ctx: SidePanelStableContext;
paneHosts: ReadonlyMap<SidePanelTab, HTMLElement>;
parkingHost: HTMLElement | null;
}) {
const {
mountedSftpTabIds,
systemMountedTabIds,
scriptsMountedTabIds,
themeMountedTabIds,
notesMountedTabIds,
} = ctx;
const activeTabId = useSyncExternalStore(
activeTabStore.subscribe,
activeTabStore.getActiveTabId,
activeTabStore.getActiveTabId,
);
const layouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
const openTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
const retainedAfterCloseTabIdsRef = ctx.sftpRetainedAfterCloseTabIdsRef as
| React.MutableRefObject<ReadonlySet<string>>
| undefined;
const sftpPaneClosedTabIdsRef = ctx.sftpPaneClosedTabIdsRef;
const isSftpOwnerPanelOpen = (tabId: string) => shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: openTabs.has(tabId),
retainedAfterClose: retainedAfterCloseTabIdsRef?.current.has(tabId) ?? false,
sftpPaneClosed: sftpPaneClosedTabIdsRef.current.has(tabId),
});
const isToolVisible = (tabId: string, tool: SidePanelTab) => (
activeTabId === tabId && sidePanelLayoutHasTool(layouts.get(tabId), tool)
);
const portalTarget = (tabId: string, tool: SidePanelTab) => (
resolveSidePanelPortalTarget(isToolVisible(tabId, tool), paneHosts.get(tool), parkingHost)
);
const activeLayout = activeTabId ? layouts.get(activeTabId) : undefined;
const historyVisible = !!activeTabId && sidePanelLayoutHasTool(activeLayout, 'history');
const aiVisible = !!activeTabId && sidePanelLayoutHasTool(activeLayout, 'ai');
return (
<>
{mountedSftpTabIds.map((tabId: string) => (
<PersistentSidePanelPortal
key={`sftp-${tabId}`}
portalKey={`sftp-${tabId}`}
target={portalTarget(tabId, 'sftp')}
>
<SidePanelSftpSlot
tabId={tabId}
ctx={ctx}
isVisible={isToolVisible(tabId, 'sftp')}
ownerPanelOpen={isSftpOwnerPanelOpen(tabId)}
/>
</PersistentSidePanelPortal>
))}
{systemMountedTabIds.map((tabId: string) => {
const isSelected = sidePanelLayoutHasTool(layouts.get(tabId), 'system');
const isVisible = activeTabId === tabId && isSelected;
return (
<PersistentSidePanelPortal
key={`system-${tabId}`}
portalKey={`system-${tabId}`}
target={resolveSidePanelPortalTarget(isVisible, paneHosts.get('system'), parkingHost)}
>
<SidePanelSystemSlot
tabId={tabId}
ctx={ctx}
isTabActive={activeTabId === tabId}
isVisible={isVisible}
isSelected={isSelected}
/>
</PersistentSidePanelPortal>
);
})}
{scriptsMountedTabIds.map((tabId: string) => (
<PersistentSidePanelPortal
key={`scripts-${tabId}`}
portalKey={`scripts-${tabId}`}
target={portalTarget(tabId, 'scripts')}
>
<SidePanelScriptsSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'scripts')} />
</PersistentSidePanelPortal>
))}
<PersistentSidePanelPortal
portalKey="history-active"
target={resolveSidePanelPortalTarget(historyVisible, paneHosts.get('history'), parkingHost)}
>
<SidePanelHistorySlot activeTabId={activeTabId} ctx={ctx} isVisible={historyVisible} />
</PersistentSidePanelPortal>
{themeMountedTabIds.map((tabId: string) => (
<PersistentSidePanelPortal
key={`theme-${tabId}`}
portalKey={`theme-${tabId}`}
target={portalTarget(tabId, 'theme')}
>
<SidePanelThemeSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'theme')} />
</PersistentSidePanelPortal>
))}
{notesMountedTabIds.map((tabId: string) => (
<PersistentSidePanelPortal
key={`notes-${tabId}`}
portalKey={`notes-${tabId}`}
target={portalTarget(tabId, 'notes')}
>
<SidePanelNotesSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'notes')} />
</PersistentSidePanelPortal>
))}
<PersistentSidePanelPortal
portalKey="ai-host"
target={resolveSidePanelPortalTarget(aiVisible, paneHosts.get('ai'), parkingHost)}
>
<SidePanelAiSlot activeTabId={activeTabId} ctx={ctx} isVisible={aiVisible} />
</PersistentSidePanelPortal>
</>
);
}