Compare commits

...

37 Commits

Author SHA1 Message Date
陈大猫
8a876fd67d Merge pull request #1372 from binaricat/fix/settings-remove-lazy-tabs
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
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 / bump homebrew tap (push) Has been cancelled
fix(settings): eager-load AI and sync tabs
2026-06-10 15:45:12 +08:00
bincxz
d39cd60863 fix(settings): eager-load AI and sync tabs
Remove React.lazy/Suspense for settings AI and sync tabs to avoid loading flashes when switching tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:44:34 +08:00
陈大猫
f413035295 fix(terminal): refocus input when switching work tabs on macOS (#1371)
Restore xterm keyboard focus after top-level tab changes so macOS users
can type immediately without an extra click (discussion #1339).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:27:14 +08:00
陈大猫
bfd3fb4dad feat(terminal): enhance compose bar with quick snippets and resizable input (#1370)
Add a persistent quick-snippet strip, draggable height, and terminal-matched UI to the compose bar, addressing quick-command and resize requests from community discussions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:18:37 +08:00
陈大猫
733e19a6f6 perf(settings): reduce Mac settings window input lag (#1347) (#1368)
* perf(settings): reduce Mac settings window input lag (#1347)

Debounce custom CSS commits, memoize heavy tabs, and replace Radix ScrollArea
with native scrolling so typing and navigation stay responsive on macOS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(settings): flush debounced textarea on unmount

Avoid losing custom CSS edits when the settings window closes before the
debounce timer fires.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:45:33 +08:00
陈大猫
85b552e1a6 fix(terminal): fix black block glyphs on Linux local terminal (#1364) (#1369)
* fix(terminal): resolve bold font weight without document.fonts.check false positives

Chromium reports unavailable bold weights as available, so xterm tried to rasterize weight 700 while the bundled JetBrains Mono fallback only ships 400/500/600. Bold glyphs then rendered as black blocks on Linux local terminals (fixes #1364).

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop unused primaryFontFamily from terminal effects context

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:39:36 +08:00
陈大猫
068730c53c fix(ui): improve host tree inline group rename interactions (#1367)
* fix(ui): improve host tree inline group rename interactions

Cancel rename when clicking another tree row and prevent parent drag from blocking text selection in the rename input.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): block group toggle keyboard while inline renaming

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): cancel host inline rename when clicking other tree rows

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:38:33 +08:00
陈奇
c9d84c7ce3 Merge commit 'refs/restore/pr-1365'
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
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 / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-06-10 04:22:46 +00:00
陈奇
d558aea7de Merge commit 'refs/restore/pr-1361' 2026-06-10 04:22:44 +00:00
陈奇
e211eec693 Merge commit 'refs/restore/pr-1360' 2026-06-10 04:22:41 +00:00
陈奇
6b1277d3e1 fix(packaging): refresh hicolor icon cache in FPM after-install to fix Arch pacman icon (#1358)
Root cause: FPM-generated .pacman packages copy icons directly to
/usr/share/icons/hicolor/*/apps/netcatty.png, bypassing Arch's alpm
hooks that normally run gtk-update-icon-cache. Without a refreshed
cache, KDE Plasma cannot resolve Icon=netcatty and falls back to a
generic document icon in the app menu.

Fix:
- Copy electron-builder's default after-install template to
  scripts/linux/after-install.tpl, append gtk-update-icon-cache call
- Create scripts/linux/after-remove.tpl with the same cache refresh
- Wire into pacman.afterInstall/pacman.afterRemove
  (NOT linux.afterInstall — the schema places these under target-level
  options like PacmanOptions/DebOptions, not LinuxConfiguration)
- Add test in electron-builder-config.test.cjs

The command is idempotent on systems without gtk-update-icon-cache
(hash guard) and uses || true to never break package installation.
2026-06-10 03:24:18 +00:00
bincxz
35bf38be70 Improve host tree rename and hover details 2026-06-10 11:04:36 +08:00
bincxz
555c00406e Polish vault and log icons 2026-06-10 10:41:42 +08:00
bincxz
e67012654a Improve SFTP bookmark list accessibility 2026-06-10 10:25:19 +08:00
bincxz
ecdb1d17cd Address SFTP toolbar review feedback 2026-06-10 10:22:51 +08:00
bincxz
a5578b5e60 Refine SFTP toolbar view and bookmarks 2026-06-10 10:16:44 +08:00
陈大猫
fb4641878f Merge pull request #1354 from binaricat/fix/issue-1352-window-controls
fix(ui): restore Windows title bar window control hover (#1352)
2026-06-10 01:32:31 +08:00
bincxz
7d6f30f51f fix(ui): restore Windows title bar window control hover (#1352)
Align window controls with utility icons, extend hover to the full title bar height, restore red close-button hover, flush close to the right edge, and use neutral gray hover for top-bar utility buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 01:29:49 +08:00
陈大猫
9869b645b1 Merge pull request #1353 from binaricat/fix/active-chrome-theme-split-autocomplete
fix(ui): smooth work-tab chrome transitions and split-pane autocomplete
2026-06-10 01:17:34 +08:00
bincxz
037b85bd66 fix(ui): smooth work-tab chrome transitions and split-pane autocomplete
Replace immersive instant-switch with animated active chrome theme sync so
top tabs match terminal sessions immediately on tab click, and clamp
autocomplete popups to the active pane so they stay anchored to the cursor
in split workspaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 01:10:33 +08:00
陈大猫
ba784b8b35 fix: resolve .cmd shim to native exe on Windows to avoid spawn EINVAL (#1350) (#1351)
Windows + Node >= 24: spawning .cmd files with shell=false causes EINVAL.
Claude Code v2.1.169 ships as native binary (no cli.js), npm global install
creates only claude.cmd. Netcatty detected claude.cmd but spawned it with
shell:false -> EINVAL.

Changes:
- resolveWindowsShimToNativeExe: new function that reads .cmd/.bat shims
  and resolves to the real .exe using "%~dp0\...\*.exe" pattern matching
- prepareCommandForSpawn: tries native exe resolution first, falls back
  to shell:true wrapping
- resolveClaudeCodeExecutableForSdk: when cli.js not found, looks for
  bin/claude.exe native binary
- 3 new tests for shim resolution and spawn spec
- Codex CLI unaffected (already handles native exe resolution)

Test: 38/38 shellUtils tests pass, npx tsc --noEmit clean
2026-06-09 23:40:21 +08:00
陈大猫
eae760db3f fix: upload terminal drops to current cwd
Fix terminal drag-and-drop uploads so they target the active terminal cwd and avoid fallback home/login-shell cwd when the active cwd cannot be confirmed.
2026-06-09 21:25:32 +08:00
陈大猫
4b5993cad6 fix host sidebar behavior for editor tabs (#1348) 2026-06-09 21:24:16 +08:00
陈大猫
6af62aa093 Add Arch pacman Linux package (#1344) 2026-06-09 21:07:56 +08:00
陈奇
61e8de4270 fix: synchronous preventDefault in paste handler + text paste fallback
- event.preventDefault() must be called synchronously before the
  async IPC call, otherwise the browser processes the default paste
  action before we can intercept it
- When clipboard has no files (or on error), fall back to text paste
  via pasteTextIntoTerminal since the default action was already
  prevented
2026-06-09 12:13:59 +00:00
陈大猫
27dce4e427 feat: local terminal paste file inserts file path (#1345) (#1346)
When pasting (Ctrl+V / right-click paste) in a local terminal,
if the clipboard contains files, insert their paths instead of
doing nothing.

- New hook useTerminalFilePaste: capture-phase paste listener
  on terminal container, reads clipboard files via Electron bridge,
  formats paths (spaces quoted, deduped), writes to session
- Updated useTerminalContextActions: right-click paste checks
  clipboard files first, falls back to text paste
- New terminalHelpers.extractRootPathsFromClipboardFiles
- Tests: 9 unit tests for path extraction logic
- Verified via headless Chromium integration test (15 tests)
- Build: npm run build , npm test  (1899 pass)
2026-06-09 20:11:12 +08:00
Xuer
8b53fb1c7b feat(snippets): 为快速添加弹窗补充仅粘贴选项 (#1342)
终端侧快速添加片段时无法设置 noAutoRun,与完整编辑面板行为不一致,
补充该选项以便在终端上下文中直接创建仅粘贴不执行的片段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:56:49 +08:00
陈大猫
6c1661dc3c fix: keep connection logs fully visible (#1343) 2026-06-09 18:52:30 +08:00
陈大猫
3662b45121 fix(ai): resolve Windows Codex npm shims before SDK spawn (#1337)
* fix(ai): resolve Windows Codex npm shims before SDK spawn

Codex SDK spawns codexPathOverride without shell:true, so passing
codex.cmd triggers spawn EINVAL on Node 18+. Rewrite npm shims to the
native codex.exe (mirroring #1102 for Claude) on SDK paths only.

Fixes #1101

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): drop unused ctx export and restore test file encoding

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): harden Windows Codex SDK executable resolution

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:34:45 +08:00
陈大猫
437253179e fix(linux): Ubuntu software icon missing due to single-size linux.icon override (#1341)
* fix(linux): restore multi-size hicolor icons for Ubuntu launchers (#1340)

PR #816 set linux.icon to a single 1024px PNG, which regressed the #274
fix and left only hicolor/1024x1024 on .deb installs. Drop the override
so electron-builder uses build/icons again, regenerate those PNGs from the
tight-crop icon-win source, and add a helper script plus a config test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(linux): set linux.icon to icons dir for proper multi-size hicolor icons (#1340)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:26:07 +08:00
Pyro
d85f4edbbb fix: host tree tab jump on first terminal open (#1331) 2026-06-09 17:27:10 +08:00
陈大猫
96c9ccaaa0 fix(vault): add duplicate host to tree view context menu (#1336)
* fix(vault): add duplicate host action to tree view context menu

Wire the existing onDuplicateHost handler into vault host tree menus so
tree view matches grid/list duplicate behavior. Fixes #1329.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vault): switch to hosts section when duplicating from terminal tree

Ensure the host details panel is visible after duplicate is triggered
from the terminal host tree sidebar.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 17:20:12 +08:00
陈大猫
517cbb6cee fix(ai): compress Catty requests only after 413 (#1327)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
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 / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* fix(ai): compress Catty requests only after 413

* fix(ai): retry 413 after tool progress safely

* fix(ai): mark thrown 413 retries after tool progress

* fix(ai): preserve tool results in 413 retry
2026-06-09 13:11:42 +08:00
陈大猫
3bc373dbec Expand custom CSS hooks (#1326) 2026-06-09 12:36:15 +08:00
陈大猫
273fe10296 fix(ui): keep terminal tabs clear of host tree (#1325) 2026-06-09 12:12:41 +08:00
陈大猫
2a10a28cc8 fix(ai): cap Catty agent request payload to prevent HTTP 413 (#1323) (#1324)
* fix(ai): cap Catty agent request payload to prevent HTTP 413

Long-running chats accumulated full terminal tool outputs in SDK history
while token-based compaction only triggered near the model context window,
so nginx gateways could reject oversized JSON bodies before the model saw them.

Add a byte-budget pass that compresses verbose output, tail-preserving
truncation, and a safe sliding window before each Catty agent turn.

Fixes #1323

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): compaction was using unfiltered sdkMessages, fix didAdjust and emergency loop

* fix(ai): compact and retry oversized Catty requests

* fix(ai): preserve current input while guarding 413 retries

* fix(ai): avoid false 413 detection and fit oversized current input

* fix(ai): pair replayed tool results chronologically

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 11:51:03 +08:00
陈奇
f74645e1a4 chore: upgrade Electron to 42.3.3, @electron/asar to 4.2.0, electron-builder to 26.15.2 2026-06-08 22:48:02 +00:00
195 changed files with 9026 additions and 3265 deletions

View File

@@ -34,7 +34,7 @@ body:
attributes:
label: How did you install Netcatty?
options:
- GitHub Release (.dmg / .exe / .AppImage / .deb)
- GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)
- Homebrew
- Built from source (npm run dev / pack)
- Other

View File

@@ -50,6 +50,7 @@ const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
// - AppImage: x64 -> x86_64, arm64 -> arm64
// - deb: x64 -> amd64, arm64 -> arm64
// - rpm: x64 -> x86_64, arm64 -> aarch64
// - pacman: x64 -> x64, arm64 -> aarch64
const files = {
mac: {
arm64: `Netcatty-${version}-mac-arm64.dmg`,
@@ -70,6 +71,10 @@ const files = {
rpm: {
x64: `Netcatty-${version}-linux-x86_64.rpm`,
arm64: `Netcatty-${version}-linux-aarch64.rpm`
},
pacman: {
x64: `Netcatty-${version}-linux-x64.pacman`,
arm64: `Netcatty-${version}-linux-aarch64.pacman`
}
}
};
@@ -88,7 +93,9 @@ const badges = {
deb_x64: `[![DebPackage x64](https://img.shields.io/badge/DebPackage-x64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.x64})`,
deb_arm64: `[![DebPackage arm64](https://img.shields.io/badge/DebPackage-arm64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.arm64})`,
rpm_x64: `[![RpmPackage x64](https://img.shields.io/badge/RpmPackage-x64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.x64})`,
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`,
pacman_x64: `[![ArchPackage x64](https://img.shields.io/badge/ArchPackage-x64-1793D1?style=flat-square&logo=archlinux)](${baseUrl}/${files.linux.pacman.x64})`,
pacman_arm64: `[![ArchPackage arm64](https://img.shields.io/badge/ArchPackage-arm64-1793D1?style=flat-square&logo=archlinux)](${baseUrl}/${files.linux.pacman.arm64})`
}
};
@@ -99,7 +106,7 @@ const content = `
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} ${badges.linux.pacman_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} ${badges.linux.pacman_arm64} |
`;
fs.writeFileSync('release_notes.md', content);

View File

@@ -348,6 +348,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.tar.gz
release/*.yml
release/*.blockmap
@@ -410,6 +411,9 @@ jobs:
- name: Install deps
run: npm ci
- name: Install pacman packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
- name: Set version
shell: bash
run: |
@@ -457,6 +461,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.yml
release/*.blockmap
if-no-files-found: ignore
@@ -510,6 +515,7 @@ jobs:
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
libarchive-tools \
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
@@ -568,6 +574,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.yml
release/*.blockmap
if-no-files-found: ignore
@@ -673,6 +680,7 @@ jobs:
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.pacman
artifacts/*.yml
artifacts/*.blockmap
generate_release_notes: true

33
App.tsx
View File

@@ -138,7 +138,7 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsDir,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
reapplyCurrentTheme,
applyAppTheme,
workspaceFocusStyle,
} = settings;
@@ -244,6 +244,7 @@ function App({ settings }: { settings: SettingsState }) {
runSnippet,
orphanSessions,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
@@ -267,12 +268,11 @@ function App({ settings }: { settings: SettingsState }) {
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
// ---------------------------------------------------------------------------
// Immersive Mode — derive UI chrome colors from the active terminal's theme
// Active tab lookup maps
// ---------------------------------------------------------------------------
const customThemes = useCustomThemes();
const editorTabs = useEditorTabs();
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
@@ -291,8 +291,8 @@ function App({ settings }: { settings: SettingsState }) {
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
// activeTabId-derived chrome (immersive theme, window title, sftp guard) is
// owned by <AppActiveTabChrome/> so switching tabs does not re-render App.
// activeTabId-derived chrome (window title, sftp guard) is owned by
// <AppActiveTabChrome/> so switching tabs does not re-render App.
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -697,12 +697,25 @@ function App({ settings }: { settings: SettingsState }) {
const closeTabsInFlightRef = useRef(false);
const editorTabTopIds = useMemo(
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
[editorTabs],
);
// 顶层标签顺序需要包含编辑器标签,供顶部标签和编辑器邻居计算使用。
const orderedTabsWithEditors = useMemo(
() => [...orderedTabs, ...editorTabs.map((tab) => toEditorTabId(tab.id))],
[orderedTabs, editorTabs],
() => getOrderedWorkTabs(editorTabTopIds),
[editorTabTopIds, getOrderedWorkTabs],
);
const reorderWorkTabs = useCallback((
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
) => {
reorderTabs(draggedId, targetId, position, editorTabTopIds);
}, [editorTabTopIds, reorderTabs]);
// Close many tabs at once with a single batched busy-shell confirmation.
// Used by the "Close all / Close others / Close to the right" context-menu
// actions on tabs (#748).
@@ -959,20 +972,20 @@ function App({ settings }: { settings: SettingsState }) {
<AppActiveTabChrome
showSftpTab={settings.showSftpTab}
setActiveTabId={setActiveTabId}
applyAppTheme={applyAppTheme}
hostById={hostById}
sessionById={sessionById}
workspaceById={workspaceById}
themeById={themeById}
workspaceById={workspaceById}
currentTerminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
reapplyCurrentTheme={reapplyCurrentTheme}
editorTabs={editorTabs}
logViews={logViews}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}

View File

@@ -5,14 +5,10 @@ import {
isEditorTabId,
useActiveTabId,
} from '../state/activeTabStore';
import { setImmersiveActive } from '../state/immersiveStore';
import { useImmersiveMode } from '../state/useImmersiveMode';
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from '../../domain/terminalAppearance';
import { collectSessionIds } from '../../domain/workspace';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type {
Host,
TerminalSession,
@@ -25,15 +21,15 @@ import type { EditorTab } from '../state/editorTabStore';
interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
applyAppTheme: () => void;
hostById: Map<string, Host>;
sessionById: Map<string, TerminalSession>;
workspaceById: Map<string, Workspace>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
accentMode: 'theme' | 'custom';
customAccent: string;
reapplyCurrentTheme: () => void;
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
t: (key: string) => string;
@@ -41,27 +37,24 @@ interface AppActiveTabChromeProps {
/**
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
* work derived from it: immersive-mode theming, window title, and the
* SFTP-tab guard. Extracted out of <App> so that switching top tabs only
* work derived from it: window title and the SFTP-tab guard.
* Extracted out of <App> so that switching top tabs only
* 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.
*
* Renders nothing; publishes "immersive active" to immersiveStore so AppView
* and TopTabs can read it without re-rendering App.
*/
export function AppActiveTabChrome({
showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
workspaceById,
themeById,
workspaceById,
currentTerminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
reapplyCurrentTheme,
editorTabs,
logViews,
t,
@@ -74,55 +67,43 @@ export function AppActiveTabChrome({
}
}, [showSftpTab, activeTabId, setActiveTabId]);
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const chromeThemeDeps = useMemo(() => ({
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
sessionById,
themeById,
workspaceById,
}), [
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
sessionById,
themeById,
workspaceById,
]);
const resolveTheme = (s: TerminalSession): TerminalTheme => {
let baseTheme: TerminalTheme;
if (followAppTerminalTheme) {
baseTheme = currentTerminalTheme;
} else {
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
baseTheme = themeById.get(themeId) || currentTerminalTheme;
}
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
updateActiveChromeThemeDeps(chromeThemeDeps);
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (workspace.viewMode === 'focus') {
const wsSessionIds = collectSessionIds(workspace.root);
const focused = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focused ? resolveTheme(focused) : null;
}
const sessionIds = collectSessionIds(workspace.root);
const wsSessions = sessionIds
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (wsSessions.length === 0) return null;
const firstTheme = resolveTheme(wsSessions[0]);
const allSame = wsSessions.every((s) => resolveTheme(s).id === firstTheme.id);
return allSame ? firstTheme : null;
}
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
...chromeThemeDeps,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
});
}), [chromeThemeDeps, activeTabId]);
useEffect(() => {
setImmersiveActive(activeTerminalTheme !== null);
}, [activeTerminalTheme]);
useActiveChromeTheme({
activeTheme: activeChromeTheme,
applyAppTheme,
});
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();

View File

@@ -2,11 +2,17 @@
import type React from 'react';
import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getEffectiveHostDistro } from '../../domain/host';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
const getLogHostVisualSnapshot = (host: Host) => ({
hostOs: host.os,
hostDistro: getEffectiveHostDistro(host) || undefined,
});
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
{
@@ -65,6 +71,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
hostname: host.hostname,
username,
protocol: 'serial',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
@@ -83,6 +90,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
@@ -708,6 +716,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
hostname: host.hostname,
username: username,
protocol: 'serial',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
@@ -726,6 +735,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const {
getAppHostTreeLayerStyle,
shouldAutoOpenHostTreeOnSurfaceChange,
} = await import('./AppHostTreeLayer');
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
test('shared host tree layer is visible above work tabs', () => {
assert.deepEqual(getAppHostTreeLayerStyle(true), {
visibility: 'visible',
pointerEvents: 'auto',
zIndex: 30,
});
});
test('shared host tree layer is hidden behind root pages', () => {
assert.deepEqual(getAppHostTreeLayerStyle(false), {
visibility: 'hidden',
pointerEvents: 'none',
zIndex: 0,
});
});
test('shared host tree auto-opens when entering a work tab surface', () => {
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
enabled: true,
previousSurfaceVisible: false,
surfaceVisible: true,
}), true);
});
test('shared host tree does not force reopen while already on work tab surfaces', () => {
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
enabled: true,
previousSurfaceVisible: true,
surfaceVisible: true,
}), false);
});
test('shared host tree does not auto-open when disabled', () => {
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
enabled: false,
previousSurfaceVisible: false,
surfaceVisible: true,
}), false);
});
test('host tree layer hides immediately when leaving work tab surfaces', () => {
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
});

View File

@@ -0,0 +1,124 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { scheduleAfterInstantThemeSwitch } from '../state/useActiveChromeTheme';
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
} from './workTabSurface';
interface AppHostTreeLayerProps {
enabled: boolean;
hosts: Host[];
customGroups: string[];
sessions: TerminalSession[];
workspaces: Workspace[];
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
resolvedPreviewTheme: TerminalTheme;
onConnect: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
return {
visibility: surfaceVisible ? 'visible' : 'hidden',
pointerEvents: surfaceVisible ? 'auto' : 'none',
zIndex: surfaceVisible ? 30 : 0,
};
}
export function shouldAutoOpenHostTreeOnSurfaceChange({
enabled,
previousSurfaceVisible,
surfaceVisible,
}: {
enabled: boolean;
previousSurfaceVisible: boolean;
surfaceVisible: boolean;
}): boolean {
return enabled && surfaceVisible && !previousSurfaceVisible;
}
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
sessions,
workspaces,
editorTabs,
logViews,
orderedTabs,
resolvedPreviewTheme,
onConnect,
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const previousSurfaceVisibleRef = useRef(false);
const cancelAutoOpenRef = useRef<(() => void) | null>(null);
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]);
const surfaceVisible = isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
});
useEffect(() => {
cancelAutoOpenRef.current?.();
cancelAutoOpenRef.current = null;
const previousSurfaceVisible = previousSurfaceVisibleRef.current;
previousSurfaceVisibleRef.current = surfaceVisible;
if (shouldAutoOpenHostTreeOnSurfaceChange({
enabled,
previousSurfaceVisible,
surfaceVisible,
})) {
cancelAutoOpenRef.current = scheduleAfterInstantThemeSwitch(() => {
cancelAutoOpenRef.current = null;
terminalHostTreeStore.setIsOpen(true);
});
}
return () => {
cancelAutoOpenRef.current?.();
cancelAutoOpenRef.current = null;
};
}, [enabled, surfaceVisible]);
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
return (
<div
className="absolute left-0 top-0 bottom-0 flex min-h-0"
data-section="app-host-tree-layer"
style={getAppHostTreeLayerStyle(surfaceVisible)}
>
<TerminalHostTreeSidebar
enabled={enabled}
surfaceVisible={surfaceVisible}
hosts={hosts}
customGroups={customGroups}
resolvedPreviewTheme={resolvedPreviewTheme}
activeHostId={activeHostId}
onConnect={onConnect}
onCreateLocalTerminal={onCreateLocalTerminal}
/>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
test('visible log view leaves room for the terminal host sidebar', () => {
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
left: 220,
});
});
test('hidden log view remains hidden while preserving host sidebar offset', () => {
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
visibility: 'hidden',
pointerEvents: 'none',
position: 'absolute',
zIndex: -1,
left: 220,
});
});
test('terminal layer renders only after terminal content is visible or mounted', () => {
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
});
test('active tab chrome keeps removed theme side effects unmounted', () => {
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
});

View File

@@ -1,5 +1,7 @@
import React, { Suspense, lazy, useEffect, useState } from 'react';
import { useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from '../state/activeTabStore';
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { isTerminalContentTabSurface } from './workTabSurface';
import { cn } from '../../lib/utils';
import { ConnectionLog, TerminalTheme } from '../../types';
import type { LogView as LogViewType } from '../state/logViewState';
@@ -29,14 +31,24 @@ interface LogViewWrapperProps {
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
}
export function getLogViewWrapperStyle(
isVisible: boolean,
hostTreeLayoutWidth: number,
): React.CSSProperties {
const baseStyle = {
left: hostTreeLayoutWidth,
};
return isVisible
? baseStyle
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
}
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
const activeTabId = useActiveTabId();
const isVisible = activeTabId === logView.id;
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
// Use same pattern as VaultViewContainer for visibility
const containerStyle: React.CSSProperties = isVisible
? {}
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
return (
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} style={containerStyle}>
@@ -67,6 +79,13 @@ const LazyTerminalLayer = lazy(() =>
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
export function shouldRenderTerminalLayerMount(
isVisible: boolean,
shouldMount: boolean,
): boolean {
return isVisible || shouldMount;
}
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
const isActive = useIsSftpActive();
const [shouldMount, setShouldMount] = useState(isActive);
@@ -85,7 +104,14 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
};
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
const isVisible = useIsTerminalLayerVisible(props.draggingSessionId);
const activeTabId = useActiveTabId();
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
const isVisible = isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}) || !!props.draggingSessionId;
const [shouldMount, setShouldMount] = useState(isVisible);
useEffect(() => {
@@ -107,7 +133,7 @@ export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
return () => window.clearTimeout(id);
}, [shouldMount]);
const shouldRender = shouldMount || isVisible;
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
if (!shouldRender) return null;

View File

@@ -2,7 +2,6 @@
import React, { Suspense, lazy } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
import { useImmersiveActive } from '../state/immersiveStore';
import { editorTabStore } from '../state/editorTabStore';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
import { TopTabs } from '../../components/TopTabs';
@@ -19,7 +18,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { toast } from '../../components/ui/toast';
import { cn } from '../../lib/utils';
import { AppHostTreeLayer } from './AppHostTreeLayer';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
@@ -43,7 +42,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename,
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
@@ -56,12 +55,6 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
// Immersive flag from store (not ctx) so toggling it doesn't re-render <App>.
// Note: we intentionally do NOT subscribe to the active tab id here — editor
// tab visibility self-subscribes inside TextEditorTabView — so plain tab
// switches don't re-render AppView/App at all.
const isImmersive = useImmersiveActive();
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
@@ -113,10 +106,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", isImmersive && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
followAppTerminalTheme={followAppTerminalTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
@@ -139,17 +131,31 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
onSyncNow={handleSyncNowManual}
isImmersiveActive={isImmersive}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
onReorderTabs={reorderWorkTabs}
showSftpTab={settings.showSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
editorTabs={editorTabs}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
hostById={hostById}
/>
<div className="flex-1 relative min-h-0">
<AppHostTreeLayer
enabled={settings.showHostTreeSidebar}
hosts={hosts}
customGroups={customGroups}
sessions={sessions}
workspaces={workspaces}
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
resolvedPreviewTheme={currentTerminalTheme}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>
<VaultViewContainer>
<VaultView
hosts={hosts}
@@ -289,6 +295,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sessionLogsFormat={sessionLogsFormat}
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
sshDebugLogsEnabled={sshDebugLogsEnabled}
showHostTreeSidebar={settings.showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
/>

View File

@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import { toEditorTabId } from "../state/activeTabStore.ts";
import type { EditorTab } from "../state/editorTabStore.ts";
import type { LogView } from "../state/logViewState.ts";
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
id,
name: id,
type,
colors: {
background: type === "dark" ? "#111111" : "#eeeeee",
foreground: type === "dark" ? "#eeeeee" : "#111111",
cursor: "#22aaff",
},
});
const currentTheme = theme("current");
const hostTheme = theme("host-theme");
const logTheme = theme("log-theme", "light");
const baseInput = {
accentMode: "theme" as const,
currentTerminalTheme: currentTheme,
customAccent: "221.2 83.2% 53.3%",
editorTabs: [],
followAppTerminalTheme: false,
hostById: new Map<string, Host>(),
logViews: [],
sessionById: new Map<string, TerminalSession>(),
themeById: new Map([
[currentTheme.id, currentTheme],
[hostTheme.id, hostTheme],
[logTheme.id, logTheme],
]),
workspaceById: new Map<string, Workspace>(),
};
test("editor tabs use the theme from their owning host", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, hostTheme.id);
});
test("log tabs use the saved log theme when available", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "log-1",
logViews: [{
id: "log-1",
connectionLogId: "1",
log: { id: "1", themeId: logTheme.id },
} as unknown as LogView],
});
assert.equal(resolved?.id, logTheme.id);
});
test("root pages use the normal application theme", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "vault",
});
assert.equal(resolved, null);
});
test("chrome theme sync waits until a newly opened session is present in deps", () => {
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map(),
workspaceById: new Map(),
}),
false,
);
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
workspaceById: new Map(),
}),
true,
);
});

View File

@@ -0,0 +1,104 @@
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
export type ResolveActiveChromeThemeInput = {
accentMode: "theme" | "custom";
activeTabId: string;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTab[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
};
export function isActiveChromeThemeResolvable({
activeTabId,
editorTabs,
logViews,
sessionById,
workspaceById,
}: Pick<
ResolveActiveChromeThemeInput,
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
>): boolean {
if (activeTabId === "vault" || activeTabId === "sftp") return true;
if (isEditorTabId(activeTabId)) {
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
}
if (logViews.some((item) => item.id === activeTabId)) return true;
if (workspaceById.has(activeTabId)) return true;
if (sessionById.has(activeTabId)) return true;
return false;
}
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
import { collectSessionIds } from "../../domain/workspace";
import type { EditorTab } from "../state/editorTabStore";
import type { LogView } from "../state/logViewState";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
export function resolveActiveChromeTheme({
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
sessionById,
themeById,
workspaceById,
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
if (activeTabId === "vault" || activeTabId === "sftp") return null;
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(session.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
if (isEditorTabId(activeTabId)) {
const editorTabId = fromEditorTabId(activeTabId);
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
if (!editorTab) return null;
const host = hostById.get(editorTab.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const explicitThemeId = logView.log.themeId;
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (workspace.viewMode === "focus") {
const workspaceSessionIds = collectSessionIds(workspace.root);
const focusedSession = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? workspaceSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
const workspaceSessions = collectSessionIds(workspace.root)
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (workspaceSessions.length === 0) return null;
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
return allSame ? firstTheme : null;
}
const session = sessionById.get(activeTabId);
return session ? resolveSessionTheme(session) : null;
}

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
const restoreBlock = chromeThemeSource.match(
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
)?.[0] ?? "";
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
});

View File

@@ -0,0 +1,109 @@
import type { TerminalTheme } from '../../types';
function hexToHslToken(hex: string): string {
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
const r = parseInt(normalized.slice(1, 3), 16) / 255;
const g = parseInt(normalized.slice(3, 5), 16) / 255;
const b = parseInt(normalized.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightnessToken(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturationToken(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
if (element.style.getPropertyValue(property) === value) return;
element.style.setProperty(property, value);
};
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
if (!element.style.getPropertyValue(property)) return;
element.style.removeProperty(property);
};
const TOP_TABS_THEME_PROPERTIES = [
'--top-tabs-bg',
'--top-tabs-fg',
'--top-tabs-muted',
'--top-tabs-active-bg',
'--top-tabs-accent',
'--background',
'--foreground',
'--accent',
'--primary',
'--secondary',
'--border',
'--muted-foreground',
] as const;
export function clearTopTabsChromeThemeVars(): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(tabsRoot, property);
}
}
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = hexToHslToken(theme.colors.cursor);
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
}
export function hasActiveChromeThemeDataset(): boolean {
if (typeof document === 'undefined') return false;
return Boolean(document.documentElement.dataset.activeChromeTheme);
}

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildOrderedWorkTabIds,
isHostTreeWorkTabSurface,
isRootPageTabId,
isTerminalContentTabSurface,
resolveWorkTabActiveHostId,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
test('work tab order keeps custom positions and appends new tabs', () => {
assert.deepEqual(
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('root pages are not work tab surfaces', () => {
assert.equal(isRootPageTabId('vault'), true);
assert.equal(isRootPageTabId('sftp'), true);
assert.equal(isRootPageTabId('session-1'), false);
});
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
const logViewIds = new Set(['log-1']);
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
for (const activeTabId of orderedTabs) {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
}), true);
}
});
test('shared host tree recognizes active log view before tab ordering catches up', () => {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId: 'log-1',
logViewIds: new Set(['log-1']),
orderedTabs: [],
sessionIds: new Set(),
workspaceIds: new Set(),
}), true);
});
test('terminal content surface is limited to sessions and workspaces', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
});
test('shared host tree resolves active host ids across work tab types', () => {
const sessions = [
{ id: 'session-1', hostId: 'host-1' },
{ id: 'session-2', hostId: 'host-2' },
] as TerminalSession[];
const workspaces = [
{ id: 'workspace-1', focusedSessionId: 'session-2' },
] as Workspace[];
const editorTabs = [
{ id: 'file-1', hostId: 'host-3' },
] as EditorTab[];
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
});

View File

@@ -0,0 +1,87 @@
import {
fromEditorTabId,
isEditorTabId,
} from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
}
export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
): string[] {
const allTabIdSet = new Set(allTabIds);
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}
export function isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds = new Set(),
orderedTabs,
sessionIds,
workspaceIds,
}: {
enabled: boolean;
activeTabId: string;
logViewIds?: ReadonlySet<string>;
orderedTabs: readonly string[];
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
if (!enabled) return false;
if (isRootPageTabId(activeTabId)) return false;
return orderedTabs.includes(activeTabId)
|| isEditorTabId(activeTabId)
|| logViewIds.has(activeTabId)
|| sessionIds.has(activeTabId)
|| workspaceIds.has(activeTabId);
}
export function isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}: {
activeTabId: string;
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
}
export function resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}: {
activeTabId: string;
editorTabs: readonly EditorTab[];
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
}): string | null {
if (isEditorTabId(activeTabId)) {
const editorId = fromEditorTabId(activeTabId);
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
}
const activeSession = sessions.find((session) => session.id === activeTabId);
if (activeSession) return activeSession.hostId ?? null;
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
if (!activeWorkspace) return null;
const focusedSessionId = activeWorkspace.focusedSessionId;
if (focusedSessionId) {
return sessions.find((session) => session.id === focusedSessionId)?.hostId ?? null;
}
return null;
}

View File

@@ -243,6 +243,13 @@ export const enAiMessages: Messages = {
'terminal.layer.hostTree.collapse': 'Collapse host list',
'terminal.layer.hostTree.expand': 'Expand host list',
'terminal.layer.hostTree.empty': 'No hosts found',
'terminal.layer.hostTree.details.host': 'Host',
'terminal.layer.hostTree.details.user': 'User',
'terminal.layer.hostTree.details.port': 'Port',
'terminal.layer.hostTree.details.protocol': 'Protocol',
'terminal.layer.hostTree.details.group': 'Group',
'terminal.layer.hostTree.details.tags': 'Tags',
'terminal.layer.hostTree.details.lastConnected': 'Last connected',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',

View File

@@ -225,6 +225,8 @@ export const enCoreMessages: Messages = {
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
// Update notifications
'update.available.title': 'Update Available',
@@ -264,9 +266,9 @@ export const enCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-side-panel (SFTP/Scripts/Theme/AI panel), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (not the focus-mode terminal list) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
@@ -667,6 +669,7 @@ export const enCoreMessages: Messages = {
'vault.hosts.connectSelected': 'Connect ({count})',
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',

View File

@@ -21,6 +21,14 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
'terminal.composeBar.searchSnippets': 'Search snippets...',
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
'terminal.composeBar.pinnedCount': '{count} pinned',
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.encoding': 'Terminal Encoding',

View File

@@ -123,6 +123,7 @@ export const enVaultMessages: Messages = {
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.bookmark.add': 'Bookmark this path',
'sftp.bookmark.remove': 'Remove bookmark',
'sftp.bookmark.list': 'Bookmarked paths',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
@@ -153,6 +154,8 @@ export const enVaultMessages: Messages = {
'sftp.viewMode.label': 'View mode',
'sftp.viewMode.list': 'List view',
'sftp.viewMode.tree': 'Tree view',
'sftp.viewMode.switchToList': 'Switch to list view',
'sftp.viewMode.switchToTree': 'Switch to tree view',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',

View File

@@ -225,6 +225,8 @@ export const ruCoreMessages: Messages = {
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
// Update notifications
'update.available.title': 'Доступно обновление',
@@ -264,9 +266,9 @@ export const ruCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-side-panel (панель SFTP/скриптов/темы/AI), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не список терминалов Focus) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса',

View File

@@ -42,6 +42,14 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
'terminal.toolbar.focus': 'Фокус',
'terminal.toolbar.focusMode': 'Режим фокуса',
'terminal.toolbar.encoding': 'Кодировка терминала',

View File

@@ -158,6 +158,7 @@ export const ruVaultMessages: Messages = {
'sftp.filter.placeholder': 'Фильтр по имени файла...',
'sftp.bookmark.add': 'Добавить путь в закладки',
'sftp.bookmark.remove': 'Удалить закладку',
'sftp.bookmark.list': 'Закладки путей',
'sftp.bookmark.addGlobal': '+Глобальная',
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
'sftp.bookmark.empty': 'Пока нет закладок',
@@ -188,6 +189,8 @@ export const ruVaultMessages: Messages = {
'sftp.viewMode.label': 'Режим просмотра',
'sftp.viewMode.list': 'Список',
'sftp.viewMode.tree': 'Дерево',
'sftp.viewMode.switchToList': 'Переключиться на список',
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
'sftp.tree.loadError': 'Не удалось загрузить каталог',
'sftp.tree.loading': 'Загрузка...',
'sftp.kind.folder': 'Папка',

View File

@@ -243,6 +243,13 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.hostTree.collapse': '收起主机列表',
'terminal.layer.hostTree.expand': '展开主机列表',
'terminal.layer.hostTree.empty': '没有匹配的主机',
'terminal.layer.hostTree.details.host': '主机',
'terminal.layer.hostTree.details.user': '用户',
'terminal.layer.hostTree.details.port': '端口',
'terminal.layer.hostTree.details.protocol': '协议',
'terminal.layer.hostTree.details.group': '分组',
'terminal.layer.hostTree.details.tags': '标签',
'terminal.layer.hostTree.details.lastConnected': '最近连接',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',

View File

@@ -209,6 +209,8 @@ export const zhCNCoreMessages: Messages = {
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
'settings.vault.showSftpTab': '显示 SFTP 标签页',
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
// Update notifications
'update.available.title': '发现新版本',
@@ -248,9 +250,9 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebarFocus 模式终端列表、terminal-side-panelSFTP/脚本/主题/AI 侧栏、terminal-sftp-panel、terminal-split-pane、terminal-split-resizer、top-tabs。',
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebarFocus 模式终端列表、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panelSFTP/脚本/主题/AI 侧栏,打开时生效、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs。',
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(不是 Focus 模式终端列表 */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留 */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
@@ -441,6 +443,7 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.connectSelected': '连接 ({count})',
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
@@ -541,6 +544,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.bookmark.add': '收藏此路径',
'sftp.bookmark.remove': '取消收藏',
'sftp.bookmark.list': '收藏路径',
'sftp.bookmark.addGlobal': '+全局',
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
@@ -571,6 +575,8 @@ export const zhCNCoreMessages: Messages = {
'sftp.viewMode.label': '视图模式',
'sftp.viewMode.list': '列表视图',
'sftp.viewMode.tree': '树形视图',
'sftp.viewMode.switchToList': '切换到列表视图',
'sftp.viewMode.switchToTree': '切换到树形视图',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',

View File

@@ -229,6 +229,14 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
'terminal.composeBar.searchSnippets': '搜索代码片段...',
'terminal.composeBar.noPinnedSnippets': '点击 + 固定常用代码片段',
'terminal.composeBar.noMatchingSnippets': '没有匹配的代码片段',
'terminal.composeBar.pinnedCount': '已固定 {count} 个',
'terminal.composeBar.unpinSnippet': '从快捷栏移除 {label}',
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.encoding': '终端编码',

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
test("active tab changes notify chrome theme before react subscribers", () => {
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
const syncSource = readFileSync(new URL("./activeChromeThemeSync.ts", import.meta.url), "utf8");
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
assert.ok(
setActiveTabIdBody.indexOf("syncListeners.forEach") < setActiveTabIdBody.indexOf("scheduleNotify"),
"sync chrome theme listeners must run before deferred react notify",
);
assert.match(syncSource, /activeTabStore\.subscribeSync\(notifyActiveChromeThemeForTab\)/);
assert.match(syncSource, /isActiveChromeThemeResolvable/);
assert.match(syncSource, /clearTopTabsChromeThemeVars/);
});

View File

@@ -0,0 +1,39 @@
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { activeTabStore } from './activeTabStore';
import type { EditorTab } from './editorTabStore';
import type { LogView } from './logViewState';
import { syncActiveChromeTheme } from './useActiveChromeTheme';
export type ActiveChromeThemeDeps = {
accentMode: 'theme' | 'custom';
applyAppTheme: () => void;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTab[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
};
let depsRef: ActiveChromeThemeDeps | null = null;
export function updateActiveChromeThemeDeps(deps: ActiveChromeThemeDeps): void {
depsRef = deps;
}
export function notifyActiveChromeThemeForTab(activeTabId: string): void {
if (!depsRef || typeof document === 'undefined') return;
if (activeTabId === 'vault' || activeTabId === 'sftp') {
clearTopTabsChromeThemeVars();
}
if (!isActiveChromeThemeResolvable({ ...depsRef, activeTabId })) return;
const activeTheme = resolveActiveChromeTheme({ ...depsRef, activeTabId });
syncActiveChromeTheme(activeTheme, depsRef.applyAppTheme);
}
activeTabStore.subscribeSync(notifyActiveChromeThemeForTab);

View File

@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
test('editor tab helpers round trip ids', () => {
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
});
test('editor tab helper detects editor top-tab ids', () => {
assert.equal(isEditorTabId('editor:file-1'), true);
assert.equal(isEditorTabId('session-1'), false);
});

View File

@@ -4,6 +4,7 @@ import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
type SyncListener = (activeTabId: string) => void;
// ----- Editor tab id helpers -----
export const EDITOR_PREFIX = 'editor:';
@@ -20,6 +21,7 @@ export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PRE
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private syncListeners = new Set<SyncListener>();
private notifyRafId: number | null = null;
getActiveTabId = () => this.activeTabId;
@@ -39,6 +41,7 @@ class ActiveTabStore {
if (this.activeTabId !== id) {
terminalLayoutSuppressStore.begin();
this.activeTabId = id;
this.syncListeners.forEach((listener) => listener(id));
// Coalesce rapid tab switches into one notification per frame and avoid
// "setState during render" if called from a render phase.
this.scheduleNotify();
@@ -57,6 +60,11 @@ class ActiveTabStore {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
subscribeSync = (listener: SyncListener) => {
this.syncListeners.add(listener);
return () => this.syncListeners.delete(listener);
};
}
export const activeTabStore = new ActiveTabStore();
@@ -109,15 +117,3 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};
// Check if terminal layer should be visible
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
const getSnapshot = useCallback(() => {
const activeTabId = activeTabStore.getActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
}, [draggingSessionId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};

View File

@@ -0,0 +1,41 @@
import { useSyncExternalStore } from 'react';
export type HostTreeInlineHostEdit = {
hostId: string;
initialName: string;
};
type Listener = () => void;
class HostTreeInlineHostEditStore {
private edit: HostTreeInlineHostEdit | null = null;
private listeners = new Set<Listener>();
getEdit = () => this.edit;
startEdit = (edit: HostTreeInlineHostEdit) => {
this.edit = edit;
this.listeners.forEach((listener) => listener());
};
clear = () => {
if (!this.edit) return;
this.edit = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineHostEditStore = new HostTreeInlineHostEditStore();
export const useHostTreeInlineHostEdit = () => {
return useSyncExternalStore(
hostTreeInlineHostEditStore.subscribe,
hostTreeInlineHostEditStore.getEdit,
hostTreeInlineHostEditStore.getEdit,
);
};

View File

@@ -1,32 +0,0 @@
import { useSyncExternalStore } from 'react';
/**
* Tiny external store for "immersive mode active" (whether the active terminal
* tab's theme is driving the app chrome). Kept out of the App component's render
* so that toggling immersive — and tab switches in general — do not force a
* full App re-render. The owner (AppActiveTabChrome) calls setImmersiveActive;
* AppView/TopTabs read it via useImmersiveActive without re-rendering App.
*/
type Listener = () => void;
let immersiveActive = false;
const listeners = new Set<Listener>();
export function setImmersiveActive(active: boolean): void {
if (immersiveActive === active) return;
immersiveActive = active;
listeners.forEach((listener) => listener());
}
function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): boolean {
return immersiveActive;
}
export function useImmersiveActive(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

View File

@@ -34,6 +34,7 @@ import {
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -71,6 +72,7 @@ interface UseSettingsIpcSyncParams {
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
}
@@ -102,6 +104,7 @@ export function useSettingsIpcSync({
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setSftpTransferConcurrencyState,
}: UseSettingsIpcSyncParams) {
// Listen for settings changes from other windows via IPC
@@ -222,6 +225,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
@@ -251,6 +257,7 @@ export function useSettingsIpcSync({
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setSftpTransferConcurrencyState,
setTerminalFontFamilyId,
setTerminalFontSize,

View File

@@ -63,6 +63,7 @@ export const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
export const DEFAULT_SHOW_RECENT_HOSTS = true;
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
export const DEFAULT_SHOW_SFTP_TAB = true;
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -129,11 +130,8 @@ export const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
// If immersive override is active (style tag present), it owns the dark/light class — don't override
if (!document.getElementById('netcatty-immersive-override')) {
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
}
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);

View File

@@ -27,6 +27,7 @@ import {
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -75,6 +76,7 @@ interface UseSettingsStorageSyncParams {
showRecentHosts: boolean;
showOnlyUngroupedHostsInRoot: boolean;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
@@ -109,6 +111,7 @@ interface UseSettingsStorageSyncParams {
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
@@ -130,7 +133,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -139,7 +142,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -153,7 +156,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
@@ -163,7 +166,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
@@ -371,6 +374,12 @@ export function useSettingsStorageSync({
setShowSftpTabState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showHostTreeSidebar) {
setShowHostTreeSidebarState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -436,6 +445,7 @@ export function useSettingsStorageSync({
setSftpTransferConcurrencyState,
setSftpUseCompressedUpload,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setShowRecentHostsState,
setShowSftpTabState,
setTerminalFontFamilyId,

View File

@@ -0,0 +1,5 @@
export const TERMINAL_HOST_TREE_ANIMATION_MS = 220;
export const TERMINAL_HOST_TREE_ANIMATION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
export const TERMINAL_HOST_TREE_ANIMATION = `${TERMINAL_HOST_TREE_ANIMATION_MS}ms ${TERMINAL_HOST_TREE_ANIMATION_EASING}`;
export const TERMINAL_HOST_TREE_LEFT_TRANSITION = `left ${TERMINAL_HOST_TREE_ANIMATION}`;
export const TERMINAL_HOST_TREE_WIDTH_TRANSITION = `width ${TERMINAL_HOST_TREE_ANIMATION}`;

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const {
TERMINAL_HOST_TREE_DEFAULT_WIDTH,
clampTerminalHostTreeWidth,
terminalHostTreeStore,
} = await import('./terminalHostTreeStore.ts');
test('closing host tree state does not mutate layout width by itself', () => {
terminalHostTreeStore.setIsOpen(true);
terminalHostTreeStore.setLayoutWidth(240);
terminalHostTreeStore.setIsOpen(false);
assert.equal(terminalHostTreeStore.getLayoutWidth(), 240);
terminalHostTreeStore.setLayoutWidth(0);
});
test('opening host tree state does not jump the layout width', () => {
storage.set('netcatty_terminal_host_tree_width_v1', '300');
terminalHostTreeStore.setLayoutWidth(0);
terminalHostTreeStore.setIsOpen(false);
terminalHostTreeStore.setIsOpen(true);
assert.equal(terminalHostTreeStore.getLayoutWidth(), 0);
terminalHostTreeStore.setLayoutWidth(0);
});
test('host tree restored layout width is clamped', () => {
assert.equal(clampTerminalHostTreeWidth(80), 160);
assert.equal(clampTerminalHostTreeWidth(999), 360);
assert.equal(clampTerminalHostTreeWidth(0), 160);
assert.equal(TERMINAL_HOST_TREE_DEFAULT_WIDTH, 220);
});

View File

@@ -5,6 +5,17 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
type Listener = () => void;
export const TERMINAL_HOST_TREE_MIN_WIDTH = 160;
export const TERMINAL_HOST_TREE_DEFAULT_WIDTH = 220;
export const TERMINAL_HOST_TREE_MAX_WIDTH = 360;
export function clampTerminalHostTreeWidth(width: number): number {
return Math.max(
TERMINAL_HOST_TREE_MIN_WIDTH,
Math.min(TERMINAL_HOST_TREE_MAX_WIDTH, width),
);
}
function readIsOpen(): boolean {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED);
// Legacy key stores "collapsed"; open is the inverse.
@@ -26,9 +37,6 @@ class TerminalHostTreeStore {
setIsOpen = (open: boolean) => {
if (this.isOpen === open) return;
this.isOpen = open;
if (!open) {
this.layoutWidth = 0;
}
localStorageAdapter.writeString(
STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED,
open ? 'false' : 'true',
@@ -37,7 +45,7 @@ class TerminalHostTreeStore {
};
setLayoutWidth = (width: number) => {
const next = Math.max(0, width);
const next = Math.max(0, Math.round(width));
if (this.layoutWidth === next) return;
this.layoutWidth = next;
this.listeners.forEach((listener) => listener());

View File

@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
THEME_TRANSITION_ATTR,
THEME_TRANSITION_MS,
runThemeTransition,
} from "./themeTransition.ts";
function createRoot() {
const attributes = new Map<string, string>();
return {
attributes,
ownerDocument: { startViewTransition: undefined },
setAttribute: (name: string, value: string) => attributes.set(name, value),
removeAttribute: (name: string) => attributes.delete(name),
getAttribute: (name: string) => attributes.get(name) ?? null,
} as unknown as HTMLElement;
}
test("runThemeTransition applies tokens and clears fallback marker after duration", async () => {
const root = createRoot();
let applied = false;
runThemeTransition(() => {
applied = true;
}, root);
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
await new Promise((resolve) => setTimeout(resolve, THEME_TRANSITION_MS + 60));
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});
test("runThemeTransition cancels a pending fallback reset when invoked again", () => {
const root = createRoot();
let count = 0;
runThemeTransition(() => {
count += 1;
}, root);
runThemeTransition(() => {
count += 2;
}, root);
assert.equal(count, 3);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
});
test("runThemeTransition uses view transition API when available", async () => {
const root = createRoot();
let applied = false;
let finished = false;
const doc = {
startViewTransition: (callback: () => void) => {
callback();
return {
finished: Promise.resolve().then(() => {
finished = true;
}),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, root);
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(finished, true);
});

View File

@@ -0,0 +1,61 @@
import { TERMINAL_HOST_TREE_ANIMATION_MS } from './terminalHostTreeAnimation';
export const THEME_TRANSITION_ATTR = 'data-theme-transition';
export const THEME_TRANSITION_MS = TERMINAL_HOST_TREE_ANIMATION_MS;
type DocumentWithViewTransition = Document & {
startViewTransition?: (callback: () => void | Promise<void>) => {
finished: Promise<void>;
skipTransition: () => void;
};
};
let cancelThemeTransitionReset: (() => void) | null = null;
export function runThemeTransition(
apply: () => void,
root: HTMLElement = document.documentElement,
): void {
cancelThemeTransitionReset?.();
const cleanup = () => {
root.removeAttribute(THEME_TRANSITION_ATTR);
cancelThemeTransitionReset = null;
};
const doc = root.ownerDocument as DocumentWithViewTransition | null;
const startViewTransition = doc?.startViewTransition?.bind(doc);
if (startViewTransition) {
let transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>> | null = null;
try {
transition = startViewTransition(() => {
apply();
});
} catch {
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
return;
}
cancelThemeTransitionReset = () => {
transition?.skipTransition();
cleanup();
};
void transition.finished.finally(cleanup);
return;
}
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
}

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
scheduleChromeLayoutAnimation,
} from "./useActiveChromeTheme.ts";
function createRafRoot() {
const callbacks = new Map<number, FrameRequestCallback>();
let nextId = 1;
const view = {
requestAnimationFrame: (callback: FrameRequestCallback) => {
const id = nextId++;
callbacks.set(id, callback);
return id;
},
cancelAnimationFrame: (id: number) => {
callbacks.delete(id);
},
};
const root = {
ownerDocument: { defaultView: view },
} as unknown as HTMLElement;
const flushFrame = () => {
const [id, callback] = callbacks.entries().next().value ?? [];
if (!id || !callback) return false;
callbacks.delete(id);
callback(0);
return true;
};
return { root, flushFrame };
}
test("chrome layout animations wait until theme settle frames complete", () => {
const { root, flushFrame } = createRafRoot();
let ran = false;
const cancel = scheduleChromeLayoutAnimation(() => {
ran = true;
}, root);
while (!ran && flushFrame()) {
// Drain scheduled animation frames.
}
assert.equal(ran, true);
cancel();
});

View File

@@ -0,0 +1,258 @@
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
applyTopTabsChromeThemeVars,
clearTopTabsChromeThemeVars,
} from "../app/topTabsChromeTheme";
import { runThemeTransition } from "./themeTransition";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightness(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const nextLightness = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(nextLightness * 10) / 10}%`;
}
function adjustSaturation(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const nextSaturation = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(nextSaturation * 10) / 10}% ${parts[2]}`;
}
const CSS_VARS = [
"background",
"foreground",
"card",
"card-foreground",
"popover",
"popover-foreground",
"primary",
"primary-foreground",
"secondary",
"secondary-foreground",
"muted",
"muted-foreground",
"accent",
"accent-foreground",
"destructive",
"destructive-foreground",
"border",
"input",
"ring",
] as const;
function buildChromeCss(theme: TerminalTheme): string {
const bg = hexToHsl(theme.colors.background);
const fg = hexToHsl(theme.colors.foreground);
const cursor = hexToHsl(theme.colors.cursor);
const isDark = theme.type === "dark";
const card = adjustLightness(bg, isDark ? 4 : -3);
const secondary = adjustLightness(bg, isDark ? 6 : -5);
const muted = adjustLightness(bg, isDark ? 10 : -8);
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
const border = adjustLightness(bg, isDark ? 12 : -10);
const cursorLightness = parseFloat(cursor.split(" ")[2] ?? "50");
const primaryFg = cursorLightness > 55 ? "0 0% 0%" : "0 0% 100%";
const values = [
bg, fg, card, fg,
card, fg,
cursor, primaryFg,
secondary, fg,
muted, mutedFg,
cursor, primaryFg,
"0 70% 50%", "0 0% 100%",
border, border, cursor,
];
const rules = CSS_VARS.map((name, index) => `--${name}: ${values[index]} !important`).join("; ");
return [
`:root { ${rules}; }`,
`:root[data-active-chrome-theme] [data-agent-badge] { border-color: hsl(var(--primary) / 0.2) !important; background-color: hsl(var(--primary) / 0.1) !important; }`,
].join("\n");
}
const cssCache = new Map<string, string>();
export function themeFingerprint(theme: TerminalTheme): string {
return `${theme.id}\0${theme.type}\0${theme.colors.background}\0${theme.colors.foreground}\0${theme.colors.cursor}`;
}
function getAppliedChromeFingerprint(): string | null {
if (typeof document === "undefined") return null;
return document.documentElement.dataset.activeChromeTheme ?? null;
}
for (const theme of TERMINAL_THEMES) {
cssCache.set(themeFingerprint(theme), buildChromeCss(theme));
}
function getChromeCss(theme: TerminalTheme): string {
const fingerprint = themeFingerprint(theme);
let css = cssCache.get(fingerprint);
if (!css) {
css = buildChromeCss(theme);
cssCache.set(fingerprint, css);
}
return css;
}
const STYLE_ID = "netcatty-active-chrome-theme";
/** Double-rAF window used to let layout settle after a paint. */
export const INSTANT_THEME_SWITCH_SETTLE_FRAMES = 2;
function getAnimationView(root: HTMLElement) {
return root.ownerDocument?.defaultView ?? globalThis.window;
}
/** Run after instant theme switch finishes suppressing CSS transitions. */
export function scheduleAfterInstantThemeSwitch(
callback: () => void,
root: HTMLElement = document.documentElement,
): () => void {
const view = getAnimationView(root);
const requestFrame = view?.requestAnimationFrame?.bind(view)
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
?? ((id: number) => { globalThis.clearTimeout(id); });
const frameIds: number[] = [];
const scheduleFrames = (remaining: number) => {
const frameId = requestFrame(() => {
const index = frameIds.indexOf(frameId);
if (index >= 0) frameIds.splice(index, 1);
if (remaining <= 1) {
callback();
return;
}
scheduleFrames(remaining - 1);
});
frameIds.push(frameId);
};
scheduleFrames(INSTANT_THEME_SWITCH_SETTLE_FRAMES);
return () => {
for (const frameId of frameIds) cancelFrame(frameId);
};
}
/**
* Run one frame after instant theme switch settles so layout transitions can
* start from the pre-animation state without `transition: none` on :root.
*/
export function scheduleChromeLayoutAnimation(
callback: () => void,
root: HTMLElement = document.documentElement,
): () => void {
let layoutFrameId = 0;
const cancelSettle = scheduleAfterInstantThemeSwitch(() => {
const view = getAnimationView(root);
const requestFrame = view?.requestAnimationFrame?.bind(view)
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
layoutFrameId = requestFrame(() => callback());
}, root);
return () => {
cancelSettle();
const view = getAnimationView(root);
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
?? ((id: number) => { globalThis.clearTimeout(id); });
if (layoutFrameId) cancelFrame(layoutFrameId);
};
}
function removeActiveChromeTheme() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.activeChromeTheme;
}
function applyActiveChromeTheme(theme: TerminalTheme) {
runThemeTransition(() => {
const root = document.documentElement;
const targetClass = theme.type === "dark" ? "dark" : "light";
root.classList.remove("light", "dark");
root.classList.add(targetClass);
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!style) {
style = document.createElement("style");
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = getChromeCss(theme);
root.dataset.activeChromeTheme = themeFingerprint(theme);
netcattyBridge.get()?.setTheme?.(targetClass);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
applyTopTabsChromeThemeVars(theme);
});
}
export function syncActiveChromeTheme(
activeTheme: TerminalTheme | null,
applyAppTheme: () => void,
): void {
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint) return;
if (activeTheme) {
applyActiveChromeTheme(activeTheme);
return;
}
clearTopTabsChromeThemeVars();
runThemeTransition(() => {
removeActiveChromeTheme();
applyAppTheme();
});
}
export function useActiveChromeTheme({
activeTheme,
applyAppTheme,
}: {
activeTheme: TerminalTheme | null;
applyAppTheme: () => void;
}) {
const applyAppThemeRef = useRef(applyAppTheme);
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
syncActiveChromeTheme(activeTheme, applyAppTheme);
}, [activeTheme, applyAppTheme]);
useLayoutEffect(() => {
return () => {
removeActiveChromeTheme();
clearTopTabsChromeThemeVars();
applyAppThemeRef.current();
};
}, []);
}

View File

@@ -0,0 +1,34 @@
import { useCallback } from 'react';
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
import { useStoredNumber } from './useStoredNumber';
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
export const COMPOSE_BAR_HEIGHT_MIN = 72;
export const COMPOSE_BAR_HEIGHT_MAX = 360;
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
function clampHeight(height: number): number {
return Math.max(HEIGHT_CLAMP.min, Math.min(HEIGHT_CLAMP.max, height));
}
/** Persisted compose bar height; call `persist` on mouseup after a drag. */
export function useComposeBarHeight() {
const [height, setHeight, persist] = useStoredNumber(
STORAGE_KEY_COMPOSE_BAR_HEIGHT,
COMPOSE_BAR_HEIGHT_DEFAULT,
HEIGHT_CLAMP,
);
const setClampedHeight = useCallback(
(next: number | ((prev: number) => number)) => {
setHeight((prev) => {
const raw = typeof next === 'function' ? next(prev) : next;
return clampHeight(raw);
});
},
[setHeight],
);
return [height, setClampedHeight, persist] as const;
}

View File

@@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
interface PinnedState {
pinnedIds: string[];
/** True when the user has never saved pins (localStorage key absent). */
neverSaved: boolean;
}
function readPinnedState(): PinnedState {
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS);
if (stored === null) {
return { pinnedIds: [], neverSaved: true };
}
return {
pinnedIds: Array.isArray(stored) ? stored.filter((id) => typeof id === 'string') : [],
neverSaved: false,
};
}
function parseSnippetIdKey(snippetIdKey?: string): Set<string> | null {
if (!snippetIdKey) return null;
const ids = snippetIdKey.split('\0').filter(Boolean);
if (ids.length === 0) return null;
return new Set(ids);
}
/**
* Persisted snippet IDs shown on the terminal compose bar quick strip.
* Pass a stable `snippetIdKey` (`ids.join('\\0')`) to prune pins for deleted snippets.
* On first run, `defaultSeedIds` are written once when pins were never saved.
*/
export function useComposeBarPinnedSnippets(
snippetIdKey?: string,
defaultSeedIds?: readonly string[],
) {
const [{ pinnedIds, neverSaved }, setPinnedState] = useState(readPinnedState);
const skipNextPersistRef = useRef(true);
const needsSeedRef = useRef(neverSaved);
const setPinnedIds = useCallback((updater: string[] | ((prev: string[]) => string[])) => {
setPinnedState((prev) => {
const nextIds = typeof updater === 'function' ? updater(prev.pinnedIds) : updater;
return { pinnedIds: nextIds, neverSaved: false };
});
}, []);
useEffect(() => {
if (skipNextPersistRef.current) {
skipNextPersistRef.current = false;
return;
}
localStorageAdapter.write(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS, pinnedIds);
}, [pinnedIds]);
useEffect(() => {
if (!needsSeedRef.current) return;
const seed = defaultSeedIds?.filter(Boolean).slice(0, 4) ?? [];
if (seed.length === 0) return;
const applySeed = () => {
if (!needsSeedRef.current) return;
needsSeedRef.current = false;
setPinnedState({ pinnedIds: [...seed], neverSaved: false });
};
const isBuiltinSeed = seed.every((id) => id.startsWith('__compose_builtin_'));
if (!isBuiltinSeed) {
applySeed();
return;
}
// Brief delay so vault snippets can load before falling back to built-ins.
const timer = setTimeout(applySeed, 300);
return () => clearTimeout(timer);
}, [defaultSeedIds]);
useEffect(() => {
const valid = parseSnippetIdKey(snippetIdKey);
if (!valid) return;
setPinnedIds((prev) => {
const next = prev.filter((id) => valid.has(id) || id.startsWith('__compose_builtin_'));
return next.length === prev.length ? prev : next;
});
}, [snippetIdKey, setPinnedIds]);
const pin = useCallback((id: string) => {
setPinnedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
}, [setPinnedIds]);
const unpin = useCallback((id: string) => {
setPinnedIds((prev) => prev.filter((x) => x !== id));
}, [setPinnedIds]);
const toggle = useCallback((id: string) => {
setPinnedIds((prev) => (
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
));
}, [setPinnedIds]);
const isPinned = useCallback((id: string) => pinnedIds.includes(id), [pinnedIds]);
return { pinnedIds, pin, unpin, toggle, isPinned };
}

View File

@@ -1,214 +0,0 @@
/**
* Immersive Mode — makes the entire UI chrome adapt colors to match the active terminal's theme.
*
* Performance strategy:
* - All built-in themes' CSS strings are pre-computed at module load (zero cost at switch time)
* - Custom/unknown themes are computed lazily and cached
* - A single `<style>` tag with `!important` overrides inline CSS variables atomically
* - `useLayoutEffect` ensures the update happens before browser paint (no flash)
*/
import { useEffect, useLayoutEffect, useRef } from 'react';
import { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// ---------------------------------------------------------------------------
// Hex → HSL conversion (returns "H S% L%" without the hsl() wrapper)
// ---------------------------------------------------------------------------
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightness(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturation(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
// ---------------------------------------------------------------------------
// Build the CSS rule string from a TerminalTheme
// ---------------------------------------------------------------------------
const CSS_VARS = [
'background', 'foreground', 'card', 'card-foreground',
'popover', 'popover-foreground', 'primary', 'primary-foreground',
'secondary', 'secondary-foreground', 'muted', 'muted-foreground',
'accent', 'accent-foreground', 'destructive', 'destructive-foreground',
'border', 'input', 'ring',
] as const;
function buildImmersiveCss(theme: TerminalTheme): string {
const bg = hexToHsl(theme.colors.background);
const fg = hexToHsl(theme.colors.foreground);
const cursor = hexToHsl(theme.colors.cursor);
const isDark = theme.type === 'dark';
const card = adjustLightness(bg, isDark ? 4 : -3);
const secondary = adjustLightness(bg, isDark ? 6 : -5);
const muted = adjustLightness(bg, isDark ? 10 : -8);
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
const border = adjustLightness(bg, isDark ? 12 : -10);
const cursorL = parseFloat(cursor.split(' ')[2] ?? '50');
const primaryFg = cursorL > 55 ? '0 0% 0%' : '0 0% 100%';
const values = [
bg, fg, card, fg, // background, foreground, card, card-foreground
card, fg, // popover, popover-foreground
cursor, primaryFg, // primary, primary-foreground
secondary, fg, // secondary, secondary-foreground
muted, mutedFg, // muted, muted-foreground
cursor, primaryFg, // accent, accent-foreground
'0 70% 50%', '0 0% 100%', // destructive, destructive-foreground
border, border, cursor, // border, input, ring
];
const rules = CSS_VARS.map((name, i) => `--${name}: ${values[i]} !important`).join('; ');
return `:root { ${rules}; }`;
}
// ---------------------------------------------------------------------------
// Pre-compute CSS for all built-in themes at module load — O(1) lookup at switch time
// ---------------------------------------------------------------------------
const cssCache = new Map<string, string>();
// Fingerprint: id + type + 3 key colors (detects in-place edits including dark↔light)
function themeFingerprint(t: TerminalTheme): string {
return `${t.id}\0${t.type}\0${t.colors.background}\0${t.colors.foreground}\0${t.colors.cursor}`;
}
// Pre-compute built-in themes
for (const theme of TERMINAL_THEMES) {
cssCache.set(themeFingerprint(theme), buildImmersiveCss(theme));
}
/** Get (or lazily compute & cache) the immersive CSS for a theme. */
function getImmersiveCss(theme: TerminalTheme): string {
const fp = themeFingerprint(theme);
let css = cssCache.get(fp);
if (!css) {
css = buildImmersiveCss(theme);
cssCache.set(fp, css);
}
return css;
}
// ---------------------------------------------------------------------------
// Style tag management
// ---------------------------------------------------------------------------
const STYLE_ID = 'netcatty-immersive-override';
function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
const root = document.documentElement;
const targetClass = isDark ? 'dark' : 'light';
if (!root.classList.contains(targetClass)) {
root.classList.remove('light', 'dark');
root.classList.add(targetClass);
}
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = css;
// Sync native Electron window chrome
netcattyBridge.get()?.setTheme?.(isDark ? 'dark' : 'light');
netcattyBridge.get()?.setBackgroundColor?.(bg);
}
function removeImmersiveStyle() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.immersiveTheme;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useImmersiveMode({
activeTabId,
activeTerminalTheme,
restoreOriginalTheme,
}: {
activeTabId: string;
activeTerminalTheme: TerminalTheme | null;
restoreOriginalTheme: () => void;
}) {
const overrideActiveRef = useRef(false);
const appliedFpRef = useRef<string | null>(null);
const restoreRef = useRef(restoreOriginalTheme);
restoreRef.current = restoreOriginalTheme;
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !activeTabId.startsWith('log-');
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
useLayoutEffect(() => {
if (isTerminalTab && activeTerminalTheme) {
const fp = themeFingerprint(activeTerminalTheme);
if (appliedFpRef.current === fp) return;
overrideActiveRef.current = true;
appliedFpRef.current = fp;
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
document.documentElement.dataset.immersiveTheme = fp;
}
}, [isTerminalTab, activeTerminalTheme]);
// RESTORE: useEffect — runs after paint, with fade overlay
useEffect(() => {
if (isTerminalTab && activeTerminalTheme) return;
if (!overrideActiveRef.current) return;
overrideActiveRef.current = false;
appliedFpRef.current = null;
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
const overlay = document.createElement('div');
overlay.className = 'immersive-fade-overlay';
overlay.style.backgroundColor = `hsl(${bg})`;
document.body.appendChild(overlay);
removeImmersiveStyle();
restoreOriginalTheme();
requestAnimationFrame(() => {
overlay.classList.add('fade-out');
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
});
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
}, [isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
// Cleanup on unmount
useEffect(() => {
return () => {
removeImmersiveStyle();
appliedFpRef.current = null;
if (overrideActiveRef.current) {
overrideActiveRef.current = false;
restoreRef.current();
}
};
}, []);
}

View File

@@ -16,6 +16,7 @@ SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
createCopiedTerminalSessionClone,
@@ -857,31 +858,33 @@ export const useSessionState = () => {
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
const orderedTabs = useMemo(() => {
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}, [orphanSessions, workspaces, logViews, tabOrder]);
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
], [orphanSessions, workspaces, logViews]);
const reorderTabs = useCallback((draggedId: string, targetId: string, position: 'before' | 'after' = 'before') => {
const getOrderedWorkTabs = useCallback((additionalTabIds: readonly string[] = []) => {
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
return buildOrderedWorkTabIds(tabOrder, allTabIds);
}, [baseWorkTabIds, tabOrder]);
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
const orderedTabs = useMemo(
() => getOrderedWorkTabs(),
[getOrderedWorkTabs],
);
const reorderTabs = useCallback((
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
additionalTabIds: readonly string[] = [],
) => {
if (draggedId === targetId) return;
setTabOrder(prevTabOrder => {
// Get all current tab IDs (orphan sessions + workspaces + log views)
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
const allTabIdSet = new Set(allTabIds);
// Build current effective order: existing order + new tabs at end
@@ -913,7 +916,7 @@ export const useSessionState = () => {
return currentOrder;
});
}, [orphanSessions, workspaces, logViews]);
}, [baseWorkTabIds]);
return {
sessions,
@@ -958,6 +961,7 @@ export const useSessionState = () => {
toggleBroadcast,
isBroadcastEnabled,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
// Log views
logViews,

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition } from './themeTransition';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
@@ -43,6 +45,7 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
@@ -83,6 +86,7 @@ import {
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
DEFAULT_SHOW_RECENT_HOSTS,
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
@@ -104,6 +108,7 @@ import { useSettingsStorageSync } from './settingsStorageSync';
import { useSettingsIpcSync } from './settingsIpcSync';
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
import { useSystemSettingsEffects } from './systemSettingsEffects';
import { applyCustomCssToDocument } from '../../lib/customCss';
export const useSettingsState = () => {
const initialCustomKeyBindingsRecord =
@@ -229,6 +234,10 @@ export const useSettingsState = () => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
return stored ?? DEFAULT_SHOW_SFTP_TAB;
});
const [showHostTreeSidebar, setShowHostTreeSidebarState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -441,7 +450,9 @@ export const useSettingsState = () => {
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
runThemeTransition(() => {
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
});
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const syncCustomCssFromStorage = useCallback(() => {
@@ -523,6 +534,8 @@ export const useSettingsState = () => {
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -534,7 +547,12 @@ export const useSettingsState = () => {
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
if (persistMountedRef.current) {
runThemeTransition(apply);
} else {
apply();
}
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
@@ -608,6 +626,7 @@ export const useSettingsState = () => {
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setSftpTransferConcurrencyState,
});
@@ -634,7 +653,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -643,7 +662,7 @@ export const useSettingsState = () => {
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -750,16 +769,16 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
}, [notifySettingsChanged]);
const setShowHostTreeSidebar = useCallback((enabled: boolean) => {
setShowHostTreeSidebarState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
// Always apply CSS to document (needed on mount)
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'netcatty-custom-css';
document.head.appendChild(styleEl);
}
styleEl.textContent = customCSS;
applyCustomCssToDocument(customCSS);
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
@@ -923,8 +942,7 @@ export const useSettingsState = () => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, [setTerminalSettings]);
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
const reapplyCurrentTheme = useCallback(() => {
const applyAppTheme = useCallback(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
@@ -994,6 +1012,8 @@ export const useSettingsState = () => {
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1027,7 +1047,7 @@ export const useSettingsState = () => {
windowOpacity,
setWindowOpacity,
rehydrateAllFromStorage,
reapplyCurrentTheme,
applyAppTheme,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
@@ -1038,7 +1058,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
]),
};

View File

@@ -170,10 +170,10 @@ export const useTerminalBackend = () => {
return bridge.listSerialPorts();
}, []);
const getSessionPwd = useCallback(async (sessionId: string) => {
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
return bridge.getSessionPwd(sessionId);
return bridge.getSessionPwd(sessionId, options);
}, []);
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {

View File

@@ -4,12 +4,16 @@ import type { Host } from '../../types';
export interface VaultHostTreeActions {
onDeleteHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onRenameHost: (host: Host) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
commitInlineGroupRename: (name: string) => void;
cancelInlineGroupEdit: () => void;
commitInlineHostRename: (name: string) => void;
cancelInlineHostEdit: () => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
managedGroupPaths?: Set<string>;

View File

@@ -143,6 +143,14 @@ test("buildSyncPayload includes AI configuration settings", () => {
});
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.showHostTreeSidebar, false);
});
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
@@ -228,6 +236,24 @@ test("applySyncPayload restores AI configuration settings", async () => {
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
});
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
showHostTreeSidebar: false,
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
});
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
// Without this nudge, the apply path writes to localStorage but
// `useAIState` (listening for `storage` events) never sees the changes

View File

@@ -63,6 +63,7 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -404,6 +405,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
settings.workspaceFocusStyle = workspaceFocusStyle;
@@ -524,7 +527,6 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
// Immersive mode (legacy — always enabled, ignore incoming value)
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
if (settings.showOnlyUngroupedHostsInRoot != null) {
localStorageAdapter.writeBoolean(
@@ -535,6 +537,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
if (settings.showHostTreeSidebar != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
}
if (settings.workspaceFocusStyle != null) {
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 B

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -9,6 +9,11 @@ import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
import {
getAIPanelDiagnosticHiddenParts,
getAIPanelProfilerProps,
isAIPanelDiagnosticPartHidden,
} from './ai/aiPanelDiagnostics';
type Translate = (key: string) => string;
type ExportFormat = 'md' | 'json' | 'txt';
@@ -111,138 +116,163 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
removeSelectedUserSkill,
globalPermissionMode,
setGlobalPermissionMode
}) => (
}) => {
const hiddenParts = getAIPanelDiagnosticHiddenParts();
const hideHeader = isAIPanelDiagnosticPartHidden('header', hiddenParts);
const hideHistory = isAIPanelDiagnosticPartHidden('history', hiddenParts);
const hideMessages = isAIPanelDiagnosticPartHidden('messages', hiddenParts);
const hideRecent = isAIPanelDiagnosticPartHidden('recent', hiddenParts);
const hideInput = isAIPanelDiagnosticPartHidden('input', hiddenParts);
return (
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
{/* ── Header ── */}
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
<AgentSelector
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
onSelectAgent={handleAgentChange}
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
onRediscover={rediscover}
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
<ConversationExport
session={activeSession}
onExport={handleExport}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
>
<History size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
>
<Plus size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
</Tooltip>
</div>
</div>
{!hideHeader && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Header')}>
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
<AgentSelector
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
onSelectAgent={handleAgentChange}
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
onRediscover={rediscover}
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
<ConversationExport
session={activeSession}
onExport={handleExport}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
>
<History size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
>
<Plus size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
</Tooltip>
</div>
</div>
</React.Profiler>
)}
{/* ── Main content ── */}
{showHistory ? (
<SessionHistoryDrawer
sessions={historySessions}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onClose={() => setShowHistory(false)}
/>
{showHistory && !hideHistory ? (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.History')}>
<SessionHistoryDrawer
sessions={historySessions}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onClose={() => setShowHistory(false)}
/>
</React.Profiler>
) : (
<>
{/* Chat messages */}
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
activeSessionId={activeSessionId}
/>
{!hideMessages && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Messages')}>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
activeSessionId={activeSessionId}
/>
</React.Profiler>
)}
{/* Recent sessions (Zed-style, shown when no messages) */}
{messages.length === 0 && historySessions.length > 0 && (
<div className="shrink-0 px-4 pb-1">
<div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
<button
onClick={() => setShowHistory(true)}
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
>
{t('ai.chat.viewAll')}
</button>
{messages.length === 0 && historySessions.length > 0 && !hideRecent && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Recent')}>
<div className="shrink-0 px-4 pb-1">
<div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
<button
onClick={() => setShowHistory(true)}
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
>
{t('ai.chat.viewAll')}
</button>
</div>
{historySessions.slice(0, 3).map((session) => (
<button
key={session.id}
onClick={() => handleSelectSession(session.id)}
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
>
<span className="text-[13px] text-foreground/60 truncate pr-4">
{session.title || t('ai.chat.untitled')}
</span>
<span className="text-[11px] text-muted-foreground/25 shrink-0">
{formatRelativeTime(new Date(session.updatedAt), t)}
</span>
</button>
))}
</div>
{historySessions.slice(0, 3).map((session) => (
<button
key={session.id}
onClick={() => handleSelectSession(session.id)}
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
>
<span className="text-[13px] text-foreground/60 truncate pr-4">
{session.title || t('ai.chat.untitled')}
</span>
<span className="text-[11px] text-muted-foreground/25 shrink-0">
{formatRelativeTime(new Date(session.updatedAt), t)}
</span>
</button>
))}
</div>
</React.Profiler>
)}
{/* Input area */}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
disabled={!canSendCurrentAgent}
providerName={providerDisplayName}
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
providerSwitcher={
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
? {
providers: cattyConfiguredProviders,
selectedProviderId: effectiveActiveProvider?.id,
selectedModelId: effectiveActiveModelId || undefined,
onSelect: handleAgentProviderModelSelect,
}
: undefined
}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
onAddUserSkill={addSelectedUserSkill}
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>
{!hideInput && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
<ChatInput
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
disabled={!canSendCurrentAgent}
providerName={providerDisplayName}
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
providerSwitcher={
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
? {
providers: cattyConfiguredProviders,
selectedProviderId: effectiveActiveProvider?.id,
selectedModelId: effectiveActiveModelId || undefined,
onSelect: handleAgentProviderModelSelect,
}
: undefined
}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
onAddUserSkill={addSelectedUserSkill}
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>
</React.Profiler>
)}
</>
)}
</div>
);
);
};

View File

@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AIDraft, AISession } from '../infrastructure/ai/types';
import {
hasAIChatSidePanelRetainedContent,
shouldKeepAIChatSidePanelMounted,
} from './AIChatSidePanel.tsx';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types.ts';
const draft = (overrides: Partial<AIDraft> = {}): AIDraft => ({
text: '',
agentId: 'catty',
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: 1,
...overrides,
});
const session = (overrides: Partial<AISession> = {}): AISession => ({
id: 'session-1',
title: 'Session',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-1' },
messages: [],
createdAt: 1,
updatedAt: 1,
...overrides,
});
const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePanelProps => ({
sessions: [],
activeSessionIdMap: {},
draftsByScope: {},
panelViewByScope: {},
setActiveSessionId: () => undefined,
ensureDraftForScope: () => undefined,
updateDraft: () => undefined,
showDraftView: () => undefined,
showSessionView: () => undefined,
clearDraftForScope: () => undefined,
addDraftFiles: async () => undefined,
removeDraftFile: () => undefined,
createSession: () => session(),
deleteSession: () => undefined,
updateSessionTitle: () => undefined,
updateSessionExternalSessionId: () => undefined,
addMessageToSession: () => undefined,
updateLastMessage: () => undefined,
updateMessageById: () => undefined,
providers: [],
activeProviderId: '',
activeModelId: '',
defaultAgentId: 'catty',
toolIntegrationMode: 'mcp',
externalAgents: [],
agentModelMap: {},
setAgentModel: () => undefined,
agentProviderMap: {},
setAgentProvider: () => undefined,
globalPermissionMode: 'autonomous',
scopeType: 'terminal',
scopeTargetId: 'terminal-1',
isVisible: false,
...overrides,
});
test('hidden empty AI side panel can release its subtree', () => {
const props = baseProps();
assert.equal(hasAIChatSidePanelRetainedContent(props), false);
assert.equal(shouldKeepAIChatSidePanelMounted(props), false);
});
test('hidden AI side panel is retained when it has draft text', () => {
const props = baseProps({
draftsByScope: {
'terminal:terminal-1': draft({ text: 'hello' }),
},
});
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
});
test('hidden AI side panel is retained when it has session messages', () => {
const props = baseProps({
activeSessionIdMap: { 'terminal:terminal-1': 'session-1' },
sessions: [
session({
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
}),
],
});
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
});
test('visible AI side panel is always mounted even when empty', () => {
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
});

View File

@@ -49,13 +49,43 @@ import { useConversationExport } from './ai/hooks/useConversationExport';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import { generateId, isCopilotAgentConfig, modelPresetsContainId } from './AIChatSidePanelHelpers';
import { AIChatPanelContent } from './AIChatPanelContent';
import {
getAIPanelProfilerProps,
profileAIPanelCalculation,
} from './ai/aiPanelDiagnostics';
function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): boolean {
export function hasAIChatSidePanelRetainedContent(props: Pick<
AIChatSidePanelProps,
'activeSessionIdMap' | 'draftsByScope' | 'sessions' | 'scopeTargetId' | 'scopeType'
>): boolean {
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
const activeSession = sessionId
? props.sessions.find((session) => session.id === sessionId)
: null;
if (activeSession && activeSession.messages.length > 0) {
return true;
}
const draft = props.draftsByScope[scopeKey] ?? null;
return Boolean(
draft
&& (
draft.text.trim().length > 0
|| draft.attachments.length > 0
|| draft.selectedUserSkillSlugs.length > 0
),
);
}
export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): boolean {
if (props.isVisible ?? true) {
return true;
}
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
if (hasAIChatSidePanelRetainedContent(props)) {
return true;
}
return isAIChatSessionStreaming(sessionId);
}
@@ -146,12 +176,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const deferredSessions = useDeferredValue(sessions);
const historySessions = useMemo(
() => getScopedHistorySessions(
deferredSessions,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
() => profileAIPanelCalculation(
'AIChatSidePanel.historySessions',
() => getScopedHistorySessions(
deferredSessions,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
),
),
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
);
@@ -877,52 +910,54 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
return (
<AIChatPanelContent
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
handleAgentChange={handleAgentChange}
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
rediscover={rediscover}
handleOpenSettings={handleOpenSettings}
activeSession={activeSession}
handleExport={handleExport}
showHistory={showHistory}
setShowHistory={setShowHistory}
handleNewChat={handleNewChat}
historySessions={historySessions}
activeSessionId={activeSessionId}
handleSelectSession={handleSelectSession}
handleDeleteSession={handleDeleteSession}
messages={messages}
isStreaming={isStreaming}
inputValue={inputValue}
setInputValue={setInputValue}
handleSend={handleSend}
handleStop={handleStop}
canSendCurrentAgent={canSendCurrentAgent}
providerDisplayName={providerDisplayName}
modelDisplayName={modelDisplayName}
agentModelPresets={agentModelPresets}
selectedAgentModel={selectedAgentModel}
handleAgentModelSelect={handleAgentModelSelect}
cattyConfiguredProviders={cattyConfiguredProviders}
effectiveActiveProvider={effectiveActiveProvider}
effectiveActiveModelId={effectiveActiveModelId}
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
files={files}
addFiles={addFiles}
removeFile={removeFile}
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
addSelectedUserSkill={addSelectedUserSkill}
removeSelectedUserSkill={removeSelectedUserSkill}
globalPermissionMode={globalPermissionMode}
setGlobalPermissionMode={setGlobalPermissionMode}
/>
<React.Profiler {...getAIPanelProfilerProps('AIChatSidePanel.Active')}>
<AIChatPanelContent
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
handleAgentChange={handleAgentChange}
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
rediscover={rediscover}
handleOpenSettings={handleOpenSettings}
activeSession={activeSession}
handleExport={handleExport}
showHistory={showHistory}
setShowHistory={setShowHistory}
handleNewChat={handleNewChat}
historySessions={historySessions}
activeSessionId={activeSessionId}
handleSelectSession={handleSelectSession}
handleDeleteSession={handleDeleteSession}
messages={messages}
isStreaming={isStreaming}
inputValue={inputValue}
setInputValue={setInputValue}
handleSend={handleSend}
handleStop={handleStop}
canSendCurrentAgent={canSendCurrentAgent}
providerDisplayName={providerDisplayName}
modelDisplayName={modelDisplayName}
agentModelPresets={agentModelPresets}
selectedAgentModel={selectedAgentModel}
handleAgentModelSelect={handleAgentModelSelect}
cattyConfiguredProviders={cattyConfiguredProviders}
effectiveActiveProvider={effectiveActiveProvider}
effectiveActiveModelId={effectiveActiveModelId}
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
files={files}
addFiles={addFiles}
removeFile={removeFile}
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
addSelectedUserSkill={addSelectedUserSkill}
removeSelectedUserSkill={removeSelectedUserSkill}
globalPermissionMode={globalPermissionMode}
setGlobalPermissionMode={setGlobalPermissionMode}
/>
</React.Profiler>
);
};
@@ -992,14 +1027,10 @@ function aiChatSidePanelPropsAreEqual(
}
const AIChatSidePanel = React.memo(function AIChatSidePanel(props: AIChatSidePanelProps) {
// Keep every mounted AI panel alive — the parent (AIChatPanelsHost) only hides
// inactive tabs via CSS, mirroring the SFTP/Scripts/Theme panels. Returning
// null here used to tear down the whole subtree on each top-tab switch, which
// forced the Streamdown-backed message list to re-parse + re-highlight up to
// 50 messages synchronously on every switch (the source of the jank). Effects
// inside AIChatSidePanelActive are gated by `isVisible`, and re-renders for
// hidden, non-streaming panels are skipped by `aiChatSidePanelPropsAreEqual`,
// so staying mounted is cheap while eliminating the remount cost.
if (!shouldKeepAIChatSidePanelMounted(props)) return null;
// Keep hidden panels alive only when they contain real work (messages, draft
// content, or an active stream). Empty hidden panels can drop their heavy
// input/agent-picker subtree and remount cheaply when shown again.
return <AIChatSidePanelActive {...props} />;
}, aiChatSidePanelPropsAreEqual);
AIChatSidePanel.displayName = 'AIChatSidePanel';

View File

@@ -1,16 +1,17 @@
import {
Bookmark,
ChevronDown,
CircleUserRound,
Server,
Terminal,
Trash2,
Usb,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import { ScrollArea } from "./ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -66,6 +67,7 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost";
const isSerial = log.protocol === "serial";
const hasPersistedHostIcon = !isLocal && !isSerial && !!log.hostDistro;
return (
<div
@@ -82,8 +84,8 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
{/* User column */}
<div className="flex items-center gap-2 w-56 shrink-0">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
<User size={14} />
<div className="h-9 w-9 rounded-xl bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 flex items-center justify-center shrink-0">
<CircleUserRound size={18} strokeWidth={2.25} />
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{log.localUsername}</div>
@@ -93,12 +95,28 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
{/* Host column */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
</div>
{hasPersistedHostIcon ? (
<DistroAvatar
host={{
os: log.hostOs ?? "linux",
distro: log.hostDistro,
distroMode: "auto",
}}
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
size="log"
/>
) : (
<div className={cn(
"h-9 w-9 rounded-xl flex items-center justify-center shrink-0",
isSerial
? "bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950"
: isLocal
? "bg-slate-600 text-white dark:bg-slate-300 dark:text-slate-950"
: "bg-primary text-primary-foreground"
)}>
{isSerial ? <Usb size={17} /> : isLocal ? <Terminal size={17} /> : <Server size={17} />}
</div>
)}
<div className="min-w-0">
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
<div className="text-xs text-muted-foreground truncate">

View File

@@ -68,11 +68,12 @@ export const DISTRO_COLORS: Record<string, string> = {
};
type DistroAvatarProps = {
host: Host;
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
Partial<Pick<Host, "protocol">>;
fallback: string;
className?: string;
/** xs matches top tab bar icons (h-4 rounded rect) */
size?: "xs" | "sm" | "md" | "lg";
size?: "xs" | "sm" | "md" | "tree" | "log" | "lg";
};
const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
@@ -91,12 +92,16 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
xs: "h-4 w-4 rounded",
sm: "h-5 w-5 rounded",
md: "h-8 w-8 rounded",
lg: "h-11 w-11 rounded",
tree: "h-8 w-8 rounded-lg",
log: "h-9 w-9 rounded-xl",
lg: "h-11 w-11 rounded-xl",
};
const iconSizes = {
xs: "h-2.5 w-2.5",
sm: "h-3 w-3",
md: "h-4 w-4",
tree: "h-4 w-4",
log: "h-5 w-5",
lg: "h-5 w-5",
};
@@ -108,7 +113,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-amber-500/15 text-amber-500",
"shrink-0 rounded flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
containerClass,
className,
)}
@@ -141,7 +146,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-primary/15 text-primary",
"shrink-0 rounded flex items-center justify-center bg-primary text-primary-foreground",
containerClass,
className,
)}

View File

@@ -1,5 +1,5 @@
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import {
hostTreeInlineGroupEditStore,
@@ -171,7 +171,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
return (
<div>
{/* Group Node */}
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<Collapsible
open={isExpanded}
onOpenChange={() => {
if (isInlineEditing) return;
onToggle(node.path);
}}
>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
@@ -182,8 +188,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
draggable
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
data-section="host-tree-row"
data-row-type="group"
data-group-path={node.path}
draggable={!isInlineEditing}
onDragStart={(e) => {
if (isInlineEditing) return;
e.dataTransfer.setData("group-path", node.path);
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -213,8 +225,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
</div>
)}
</div>
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center text-primary transition-colors dark:text-primary">
{isExpanded ? (
<FolderOpen size={21} strokeWidth={2.35} />
) : (
<Folder size={21} strokeWidth={2.35} />
)}
</div>
{isInlineEditing && commitRename && cancelRename ? (
<HostTreeGroupInlineRenameInput
@@ -359,7 +375,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
depth,
onConnect,
onEditHost,
onDuplicateHost: _onDuplicateHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
moveHostToGroup: _moveHostToGroup,
@@ -390,6 +406,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
isSelected ? "bg-primary/10" : "",
)}
style={{ paddingLeft }}
data-section="host-tree-row"
data-row-type="host"
data-host-id={host.id}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onClick={() => {
@@ -414,7 +433,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
)}
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
<div className="mr-3 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="xs" />
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate flex items-center gap-1.5">
@@ -452,6 +471,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<HostTreeHostContextMenuContent
host={host}
onConnect={onConnect}
onDuplicateHost={onDuplicateHost}
onCopyCredentials={onCopyCredentials}
onDeleteHost={onDeleteHost}
/>
@@ -491,6 +511,20 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
groupConfigs = [],
}) => {
const { t } = useI18n();
const inlineEdit = useHostTreeInlineGroupEdit();
const vaultTreeActions = useVaultHostTreeActions();
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
const handleTreePointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!inlineEdit?.groupPath || !cancelRename) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('[data-inline-group-edit="true"]')) return;
const row = target.closest('[data-section="host-tree-row"]');
if (!row) return;
if (row.getAttribute('data-group-path') === inlineEdit.groupPath) return;
cancelRename();
}, [cancelRename, inlineEdit?.groupPath]);
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
@@ -562,7 +596,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
}, [groupTree, sortMode]);
return (
<div className="space-y-1">
<div className="space-y-1" onPointerDownCapture={handleTreePointerDownCapture}>
{/* Expand/Collapse controls */}
{groupTree.length > 0 && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">

View File

@@ -48,6 +48,7 @@ import {
VaultHeaderSearch,
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
// Import utilities and components from keychain module
@@ -678,7 +679,7 @@ echo $3 >> "$FILE"`);
{/* Keys Section */}
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
<h2 className={vaultSectionTitleClass}>
{t("keychain.section.keys")}
</h2>
<span className="text-xs text-muted-foreground">
@@ -743,7 +744,7 @@ echo $3 >> "$FILE"`);
{activeFilter === "key" && filteredIdentities.length > 0 && (
<div className="space-y-3 px-3 pb-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
<h2 className={vaultSectionTitleClass}>
{t("keychain.section.identities")}
</h2>
<span className="text-xs text-muted-foreground">

View File

@@ -44,6 +44,7 @@ import {
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from "./vault/VaultPageHeader";
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
interface KnownHostsManagerProps {
knownHosts: KnownHost[];
@@ -167,9 +168,10 @@ const HostItem = React.memo<HostItemProps>(
</Tooltip>
</div>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
<Server size={18} />
</div>
<VaultEntityIcon
className={vaultPrimaryIconClass}
icon={<Server size={18} />}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold truncate block">
{knownHost.hostname}
@@ -205,9 +207,10 @@ const HostItem = React.memo<HostItemProps>(
converted && "opacity-60",
)}
>
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
<Server size={18} />
</div>
<VaultEntityIcon
className={vaultPrimaryIconClass}
icon={<Server size={18} />}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold truncate block">
{knownHost.hostname}

View File

@@ -247,34 +247,34 @@ const LogViewComponent: React.FC<LogViewProps> = ({
return (
<div className="h-full w-full flex flex-col bg-background">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50 bg-secondary/30 shrink-0">
<div className="flex items-center gap-3">
<div className="flex h-9 items-center justify-between gap-3 px-3 py-1 border-b border-border/50 bg-secondary/30 shrink-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
"h-6 w-6 shrink-0 rounded-md flex items-center justify-center",
isLocal
? "bg-emerald-500/10 text-emerald-500"
: "bg-blue-500/10 text-blue-500"
)}
>
<FileText size={16} />
<FileText size={14} />
</div>
<div>
<div className="text-sm font-medium">
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<div className="min-w-0 text-sm font-medium leading-none truncate">
{isLocal ? t("logs.localTerminal") : log.hostname}
</div>
<div className="text-xs text-muted-foreground">
<div className="text-xs leading-none text-muted-foreground truncate">
{formattedDate} {log.localUsername}@{log.localHostname}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex h-7 shrink-0 items-center gap-1.5">
{/* Export button */}
{log.terminalData && (
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2"
className="gap-1.5 h-7 px-2 text-xs"
onClick={handleExport}
disabled={isExporting}
>
@@ -287,18 +287,18 @@ const LogViewComponent: React.FC<LogViewProps> = ({
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2"
className="gap-1.5 h-7 px-2 text-xs"
onClick={() => setThemeModalOpen(true)}
>
<Palette size={14} />
<span className="text-xs">{t("logView.appearance")}</span>
</Button>
<span className="text-xs text-muted-foreground bg-secondary px-2 py-1 rounded">
<span className="h-6 inline-flex items-center rounded bg-secondary px-2 text-xs text-muted-foreground">
{t("logView.readOnly")}
</span>
<Button variant="ghost" size="sm" onClick={onClose}>
<X size={16} />
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
<X size={14} />
</Button>
</div>
</div>

View File

@@ -47,6 +47,7 @@ import {
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
// Import components and utilities from port-forwarding module
@@ -690,9 +691,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
</VaultPageHeader>
{/* Rules List */}
<div className="flex-1 overflow-auto p-4">
<div className="flex-1 overflow-y-auto">
{!hasRules ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<div className="flex h-full flex-col items-center justify-center p-3 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Zap size={32} className="opacity-60" />
</div>
@@ -704,9 +705,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
</p>
</div>
) : (
<div className="space-y-3">
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">{t("pf.title")}</h2>
<h2 className={vaultSectionTitleClass}>{t("pf.title")}</h2>
<span className="text-xs text-muted-foreground">
{t("pf.rulesCount", { count: filteredRules.length })}
</span>

View File

@@ -59,7 +59,14 @@ import {
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import {
VaultEntityIcon,
vaultProxyCommandIconClass,
vaultProxyHttpIconClass,
vaultProxySocksIconClass,
} from "./vault/VaultEntityIcon";
interface ProxyProfilesManagerProps {
proxyProfiles: ProxyProfile[];
@@ -99,17 +106,17 @@ const proxyProtocolMeta = {
http: {
label: "HTTP",
Icon: Globe,
iconClassName: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
iconClassName: vaultProxyHttpIconClass,
},
socks5: {
label: "SOCKS5",
Icon: Route,
iconClassName: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
iconClassName: vaultProxySocksIconClass,
},
command: {
labelKey: "hostDetails.proxyPanel.command",
Icon: SquareTerminal,
iconClassName: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
iconClassName: vaultProxyCommandIconClass,
},
} satisfies Record<ProxyConfig["type"], {
label?: string;
@@ -163,15 +170,11 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
onClick={onClick}
>
<div className="flex items-center gap-3 h-full">
<div
className={cn(
"h-11 w-11 rounded-xl flex items-center justify-center",
protocol.iconClassName,
)}
<VaultEntityIcon
className={protocol.iconClassName}
title={protocolLabel}
>
<ProtocolIcon size={18} />
</div>
icon={<ProtocolIcon size={18} />}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<div className="text-sm font-semibold truncate">{profile.label}</div>
@@ -397,7 +400,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
<div className="flex-1 overflow-y-auto">
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
<h2 className={vaultSectionTitleClass}>
{t("proxyProfiles.section.proxies")}
</h2>
<span className="text-xs text-muted-foreground">

View File

@@ -46,6 +46,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
const [label, setLabel] = useState('');
const [command, setCommand] = useState('');
const [packagePath, setPackagePath] = useState('');
const [noAutoRun, setNoAutoRun] = useState(false);
const [editing, setEditing] = useState<Snippet | null>(null);
const labelInputRef = useRef<HTMLInputElement>(null);
@@ -58,6 +59,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
setLabel('');
setCommand('');
setPackagePath('');
setNoAutoRun(false);
setOpen(true);
};
window.addEventListener('netcatty:snippets:add', handler);
@@ -75,6 +77,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
setLabel(snippet.label ?? '');
setCommand(snippet.command ?? '');
setPackagePath(snippet.package ?? '');
setNoAutoRun(snippet.noAutoRun ?? false);
setOpen(true);
};
window.addEventListener('netcatty:snippets:edit', handler);
@@ -121,6 +124,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
label: label.trim(),
command,
package: trimmedPackage || '',
noAutoRun: noAutoRun || undefined,
});
} else {
onCreateSnippet({
@@ -130,10 +134,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
tags: [],
package: trimmedPackage || '',
targets: [],
noAutoRun: noAutoRun || undefined,
});
}
setOpen(false);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command]);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command, noAutoRun]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -199,6 +204,16 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
createText={t('snippets.field.createPackage')}
/>
</div>
<label className="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
checked={noAutoRun}
onChange={(e) => setNoAutoRun(e.target.checked)}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
</label>
</div>
<DialogFooter className="shrink-0">

View File

@@ -18,9 +18,10 @@ import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
import SettingsAITab from "./settings/tabs/SettingsAITab";
import SettingsSyncTab from "./settings/tabs/SettingsSyncTab";
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -50,74 +51,111 @@ class AITabErrorBoundary extends React.Component<
type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const settingsTabTriggerClassName =
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
const settingsTabIconClassName = "shrink-0";
const settingsTabLabelClassName = "min-w-0 truncate";
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
type TerminalTabSettingsProps = Pick<
SettingsState,
| 'terminalThemeId'
| 'setTerminalThemeId'
| 'followAppTerminalTheme'
| 'setFollowAppTerminalTheme'
| 'terminalThemeDarkId'
| 'setTerminalThemeDarkId'
| 'terminalThemeLightId'
| 'setTerminalThemeLightId'
| 'lightUiThemeId'
| 'darkUiThemeId'
| 'terminalFontFamilyId'
| 'setTerminalFontFamilyId'
| 'terminalFontSize'
| 'setTerminalFontSize'
| 'terminalSettings'
| 'updateTerminalSetting'
| 'workspaceFocusStyle'
| 'setWorkspaceFocusStyle'
>;
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
workspaceFocusStyle,
setWorkspaceFocusStyle,
}) {
const availableFonts = useAvailableFonts();
return (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
terminalThemeId={terminalThemeId}
setTerminalThemeId={setTerminalThemeId}
followAppTerminalTheme={followAppTerminalTheme}
setFollowAppTerminalTheme={setFollowAppTerminalTheme}
terminalThemeDarkId={terminalThemeDarkId}
setTerminalThemeDarkId={setTerminalThemeDarkId}
terminalThemeLightId={terminalThemeLightId}
setTerminalThemeLightId={setTerminalThemeLightId}
lightUiThemeId={lightUiThemeId}
darkUiThemeId={darkUiThemeId}
terminalFontFamilyId={terminalFontFamilyId}
setTerminalFontFamilyId={setTerminalFontFamilyId}
terminalFontSize={terminalFontSize}
setTerminalFontSize={setTerminalFontSize}
terminalSettings={terminalSettings}
updateTerminalSetting={updateTerminalSetting}
availableFonts={availableFonts}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
workspaceFocusStyle={workspaceFocusStyle}
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
/>
);
};
});
const SettingsAITabContainer: React.FC = () => {
const aiState = useAIState();
return (
<AITabErrorBoundary>
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
toolIntegrationMode={aiState.toolIntegrationMode}
setToolIntegrationMode={aiState.setToolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</React.Suspense>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
toolIntegrationMode={aiState.toolIntegrationMode}
setToolIntegrationMode={aiState.setToolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</AITabErrorBoundary>
);
};
@@ -325,13 +363,34 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
/>
/>
)}
{mountedTabs.has("terminal") && (
<SettingsTerminalTabContainer settings={settings} />
<SettingsTerminalTabContainer
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
)}
{mountedTabs.has("shortcuts") && (
@@ -355,9 +414,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{mountedTabs.has("sync") && (
<React.Suspense fallback={null}>
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
</React.Suspense>
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
)}
{mountedTabs.has("system") && (

View File

@@ -841,7 +841,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
onClick={handlePaneFocus}
>
{showWorkspaceHostHeader && displayHost && (
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
<div
className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5"
data-section="terminal-sftp-host-header"
>
<div className="flex items-center gap-2 min-w-0">
<DistroAvatar
host={displayHost}

View File

@@ -24,7 +24,6 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
@@ -93,8 +92,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const rootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef("sftp-main-view");
useInstantThemeSwitch(rootRef);
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {

View File

@@ -19,7 +19,13 @@ import {
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from './vault/VaultPageHeader';
import {
VaultEntityIcon,
vaultPrimaryIconClass,
vaultSnippetIconClass,
} from './vault/VaultEntityIcon';
interface SnippetsManagerProps {
snippets: Snippet[];
@@ -790,7 +796,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{displayedPackages.length > 0 && !search.trim() && (
<>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">{t('snippets.section.packages')}</h3>
<h3 className={vaultSectionTitleClass}>{t('snippets.section.packages')}</h3>
</div>
<div className={cn(
viewMode === 'grid'
@@ -823,9 +829,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onClick={() => setSelectedPackage(pkg.path)}
>
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<Package size={18} />
</div>
<VaultEntityIcon
className={vaultPrimaryIconClass}
icon={<Package size={18} />}
/>
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{pkg.name}</div>
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
@@ -846,7 +853,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{displayedSnippets.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground">{t('snippets.section.snippets')}</h3>
<h3 className={vaultSectionTitleClass}>{t('snippets.section.snippets')}</h3>
<div className={cn(
viewMode === 'grid'
? "grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3"
@@ -870,9 +877,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onClick={() => handleEdit(snippet)}
>
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<FileCode size={18} />
</div>
<VaultEntityIcon
className={vaultSnippetIconClass}
icon={<FileCode size={18} />}
/>
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
<Tooltip>

View File

@@ -179,7 +179,7 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 relative text-muted-foreground hover:text-foreground app-no-drag",
"h-7 w-7 relative app-no-drag top-tab-utility-btn",
className
)}
style={style}

View File

@@ -50,7 +50,7 @@ import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTermi
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
@@ -72,6 +72,7 @@ import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
import { useTerminalEffects } from "./terminal/useTerminalEffects";
@@ -413,11 +414,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined;
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
const resolveSftpInitialPath = useCallback(async (options?: { preferFreshBackend?: boolean }): Promise<string | undefined> => {
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: terminalCwdTracker.getRendererCwd(),
sessionId: sessionRef.current,
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
preferFreshBackend: options?.preferFreshBackend,
});
return cwd ?? undefined;
}, [terminalBackend, terminalCwdTracker]);
@@ -848,6 +850,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
scrollOnPasteRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
isLocalConnection,
terminalBackend,
});
// Kept fresh on every render so the mouseTracking capture handler at
// handleContextMenuCapture (which is bound once per sessionId) can
@@ -1053,6 +1057,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef,
});
useTerminalFilePaste({
isLocalConnection,
status,
termRef,
sessionRef,
terminalBackend,
scrollToBottomAfterProgrammaticInput,
containerRef,
});
const renderControls = useCallback((opts?: { showClose?: boolean }) => (
<TerminalToolbar
status={status}
@@ -1104,7 +1118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};

View File

@@ -136,6 +136,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
showHostTreeSidebar = true,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
}) => {
@@ -569,7 +570,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionId,
cwdRevisionAtCommand: revisionAtCommand,
getCwdRevision: () => terminalCwdRevisionRef.current,
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
canProbe: async () => {
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
const host = sessionHostsMapRef.current.get(sessionId);
@@ -706,7 +707,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return resolvePreferredTerminalCwd({
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
sessionId,
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
preferFreshBackend: options?.preferFreshBackend,
});
}, [getActiveTerminalSessionId, terminalBackend]);
@@ -1108,6 +1109,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpInitialLocationForTab,
setSftpPendingUploadsForTab,
setupMcpApprovalBridge,
showHostTreeSidebar,
sidePanelOpenTabs,
sidePanelPosition,
sidePanelWidth,

169
components/TopTabs.test.ts Normal file
View File

@@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
Object.defineProperty(globalThis, "requestAnimationFrame", {
configurable: true,
value: (callback: (time: number) => void) => setTimeout(() => callback(Date.now()), 0) as unknown as number,
});
const {
computeHostTreeTabGutter,
shouldKeepHostTreeToggleSurface,
shouldShowHostTreeToggle,
} = await import("./TopTabs.tsx");
const { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
test("host tree tab gutter fills the remaining sidebar width", () => {
assert.equal(computeHostTreeTabGutter(280, 120), 160);
});
test("host tree tab gutter never goes negative", () => {
assert.equal(computeHostTreeTabGutter(120, 280), 0);
});
test("host tree tab surface stays mounted when root pages are active", () => {
assert.equal(shouldKeepHostTreeToggleSurface({
enabled: true,
activeWorkTabCount: 2,
}), true);
});
test("host tree tab surface is hidden without work tabs", () => {
assert.equal(shouldKeepHostTreeToggleSurface({
enabled: true,
activeWorkTabCount: 0,
}), false);
});
test("host tree tab layout transitions match the sidebar timing", () => {
const hostTreeCss = [
".top-tab-root-label",
".top-tab-host-tree-toggle-slot",
].map((selector) => {
const start = indexCss.indexOf(selector);
assert.notEqual(start, -1);
const end = indexCss.indexOf("}", start);
return indexCss.slice(start, end);
}).join("\n");
const gutterStart = indexCss.indexOf(".top-tab-host-tree-gutter");
assert.notEqual(gutterStart, -1);
const gutterEnd = indexCss.indexOf("}", gutterStart);
const gutterCss = indexCss.slice(gutterStart, gutterEnd);
assert.match(hostTreeCss, /width 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/);
assert.match(hostTreeCss, /max-width 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/);
assert.doesNotMatch(hostTreeCss, /transition:\s*none/);
assert.doesNotMatch(hostTreeCss, /280ms/);
assert.doesNotMatch(gutterCss, /transition/);
assert.match(indexCss, /\.top-tab-host-tree-gutter-exit[\s\S]*transition: width 220ms/);
});
test("host tree toggle appears with opacity only and no bounce animation", () => {
assert.doesNotMatch(indexCss, /top-tab-host-tree-toggle-pop/);
assert.doesNotMatch(indexCss, /@keyframes\s+pop-in/);
const start = indexCss.indexOf(".top-tab-host-tree-toggle-slot");
assert.notEqual(start, -1);
const end = indexCss.indexOf("}", start);
const toggleSlotCss = indexCss.slice(start, end);
assert.match(toggleSlotCss, /opacity 220ms ease/);
assert.doesNotMatch(toggleSlotCss, /transform/);
assert.doesNotMatch(toggleSlotCss, /scale/);
});
test("host tree chrome enters after theme switch settles so root labels can animate", () => {
assert.match(topTabsSource, /hostTreeChromeReady/);
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);
assert.match(topTabsSource, /scheduleChromeLayoutAnimation\(\(\) => \{\s*cancelRootTabsCompactRef\.current = null;\s*setRootTabsCompact\(true\);/);
assert.match(topTabsSource, /compact=\{rootTabsCompact\}/);
assert.match(topTabsSource, /data-visible=\{effectiveShowHostTreeToggle \? 'true' : 'false'\}/);
});
test("host tree chrome exits before root labels expand back on vault", () => {
assert.match(topTabsSource, /cancelChromeExitRef/);
assert.match(topTabsSource, /hostTreeGutterExiting/);
assert.match(topTabsSource, /setRootTabsCompact\(false\)/);
assert.match(topTabsSource, /top-tab-host-tree-gutter-exit/);
assert.match(topTabsSource, /effectiveShowHostTreeToggle = hostTreeChromeReady/);
});
test("host tree toggle is shown for an active editor tab", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "editor:file-1",
orderedTabs: ["session-1", "editor:file-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), true);
});
test("host tree toggle is shown for log tabs", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "log-1",
logViewIds: new Set(["log-1"]),
orderedTabs: ["session-1", "log-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), true);
});
test("host tree toggle is shown for log tabs before tab ordering catches up", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "log-1",
logViewIds: new Set(["log-1"]),
orderedTabs: [],
sessionIds: new Set(),
workspaceIds: new Set(),
}), true);
});
test("clicking a log tab activates the shared work-tab surface", () => {
activeTabStore.setActiveTabId("vault");
activateLogViewTab("log-1");
assert.equal(activeTabStore.getActiveTabId(), "log-1");
});
test("host tree toggle is hidden when host sidebar is disabled", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: false,
activeTabId: "session-1",
orderedTabs: ["session-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), false);
});
test("host tree toggle is hidden on root pages", () => {
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "vault",
orderedTabs: ["session-1", "editor:file-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), false);
assert.equal(shouldShowHostTreeToggle({
enabled: true,
activeTabId: "sftp",
orderedTabs: ["session-1", "editor:file-1"],
sessionIds: new Set(["session-1"]),
workspaceIds: new Set(),
}), false);
});

View File

@@ -1,6 +1,7 @@
import { Folder, FolderLock, Menu, Moon, MoreHorizontal, Plus, Settings, Sparkles, Sun } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
import type { EditorTab } from '../application/state/editorTabStore';
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
@@ -29,16 +30,60 @@ import {
WindowControls,
WorkspaceTopTab,
} from './top-tabs/TopTabItems';
import { TERMINAL_HOST_TREE_ANIMATION_MS } from '../application/state/terminalHostTreeAnimation';
import {
scheduleAfterInstantThemeSwitch,
scheduleChromeLayoutAnimation,
} from '../application/state/useActiveChromeTheme';
import { useTopTabLifecycleAnimations } from './top-tabs/useTopTabLifecycleAnimations';
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
const noDragRegionStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties;
const emptyTabStyle: React.CSSProperties = {};
export function computeHostTreeTabGutter(hostTreeLayoutWidth: number, toggleRight: number): number {
return Math.max(0, hostTreeLayoutWidth - toggleRight);
}
export function shouldShowHostTreeToggle({
enabled,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
}: {
enabled: boolean;
activeTabId: string;
logViewIds?: ReadonlySet<string>;
orderedTabs: readonly string[];
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
return isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
});
}
export function shouldKeepHostTreeToggleSurface({
enabled,
activeWorkTabCount,
}: {
enabled: boolean;
activeWorkTabCount: number;
}): boolean {
return enabled && activeWorkTabCount > 0;
}
interface TopTabsProps {
theme: 'dark' | 'light';
followAppTerminalTheme?: boolean;
hosts: Host[];
sessions: TerminalSession[];
orphanSessions: TerminalSession[];
@@ -61,11 +106,11 @@ interface TopTabsProps {
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
onSyncNow?: () => Promise<void>;
isImmersiveActive?: boolean;
onStartSessionDrag: (sessionId: string) => void;
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
editorTabs: readonly EditorTab[];
onRequestCloseEditorTab: (editorTabId: string) => void;
hostById: Map<string, Host>;
@@ -73,7 +118,6 @@ interface TopTabsProps {
const TopTabsInner: React.FC<TopTabsProps> = ({
theme,
followAppTerminalTheme = false,
hosts,
sessions,
orphanSessions,
@@ -96,11 +140,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
windowOpacity,
setWindowOpacity,
onSyncNow,
isImmersiveActive,
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
showSftpTab,
showHostTreeSidebar,
editorTabs,
onRequestCloseEditorTab,
hostById,
@@ -113,9 +157,18 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const toggleHostTree = useToggleTerminalHostTree();
const activeTabId = useActiveTabId();
const { getTabAnimationClass } = useTopTabLifecycleAnimations(orderedTabs);
const [hostTreeTogglePop, setHostTreeTogglePop] = useState(false);
const fixedLeftTabsRef = useRef<HTMLDivElement>(null);
const hostTreeToggleSlotRef = useRef<HTMLDivElement>(null);
const suppressHostTreeToggleClickRef = useRef(false);
const hostTreeGutterCloseRafRef = useRef<number | null>(null);
const cancelHostTreeChromeReadyRef = useRef<(() => void) | null>(null);
const cancelRootTabsCompactRef = useRef<(() => void) | null>(null);
const cancelChromeExitRef = useRef<(() => void) | null>(null);
const [hostTreeTabGutter, setHostTreeTabGutter] = useState(0);
const [hostTreeChromeReady, setHostTreeChromeReady] = useState(false);
const [hostTreeGutterExiting, setHostTreeGutterExiting] = useState(false);
const [rootTabsCompact, setRootTabsCompact] = useState(false);
const showWindowControls = !isMacClient;
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
@@ -220,15 +273,99 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return counts;
}, [sessions]);
const hasTerminalOrWorkspaceTabs = sessions.length > 0 || workspaces.length > 0;
const isActiveTerminalOrWorkspaceTab = orphanSessionMap.has(activeTabId) || workspaceMap.has(activeTabId);
const showHostTreeToggle = hasTerminalOrWorkspaceTabs && isActiveTerminalOrWorkspaceTab;
const activeWorkTabCount = orderedTabs.length;
const showHostTreeToggle = shouldShowHostTreeToggle({
enabled: showHostTreeSidebar,
activeTabId,
logViewIds: new Set(logViewMap.keys()),
orderedTabs,
sessionIds: new Set(orphanSessionMap.keys()),
workspaceIds: new Set(workspaceMap.keys()),
});
const hasHostTreeToggleSurface = shouldKeepHostTreeToggleSurface({
enabled: showHostTreeSidebar,
activeWorkTabCount,
});
const effectiveShowHostTreeToggle = hostTreeChromeReady;
const updateHostTreeTabGutter = useCallback(() => {
if (!showHostTreeToggle || hostTreeLayoutWidth <= 0) {
useEffect(() => {
cancelHostTreeChromeReadyRef.current?.();
cancelHostTreeChromeReadyRef.current = null;
cancelRootTabsCompactRef.current?.();
cancelRootTabsCompactRef.current = null;
cancelChromeExitRef.current?.();
cancelChromeExitRef.current = null;
if (!showHostTreeToggle) {
if (hostTreeChromeReady) {
setRootTabsCompact(false);
setHostTreeGutterExiting(true);
const gutterRaf = window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => setHostTreeTabGutter(0));
});
const timer = window.setTimeout(() => {
cancelChromeExitRef.current = null;
setHostTreeChromeReady(false);
setHostTreeGutterExiting(false);
}, TERMINAL_HOST_TREE_ANIMATION_MS);
cancelChromeExitRef.current = () => {
window.cancelAnimationFrame(gutterRaf);
window.clearTimeout(timer);
};
} else {
setHostTreeChromeReady(false);
setHostTreeGutterExiting(false);
setRootTabsCompact(false);
}
return () => {
cancelChromeExitRef.current?.();
cancelChromeExitRef.current = null;
};
}
if (!hostTreeChromeReady) {
cancelHostTreeChromeReadyRef.current = scheduleAfterInstantThemeSwitch(() => {
cancelHostTreeChromeReadyRef.current = null;
setHostTreeChromeReady(true);
});
}
if (!rootTabsCompact) {
cancelRootTabsCompactRef.current = scheduleChromeLayoutAnimation(() => {
cancelRootTabsCompactRef.current = null;
setRootTabsCompact(true);
});
}
return () => {
cancelHostTreeChromeReadyRef.current?.();
cancelHostTreeChromeReadyRef.current = null;
cancelRootTabsCompactRef.current?.();
cancelRootTabsCompactRef.current = null;
};
}, [hostTreeChromeReady, rootTabsCompact, showHostTreeToggle]);
const updateHostTreeTabGutter = useCallback((options?: { deferClose?: boolean }) => {
if (hostTreeGutterExiting) return;
if (!effectiveShowHostTreeToggle || hostTreeLayoutWidth <= 0) {
if (!effectiveShowHostTreeToggle && options?.deferClose) {
if (hostTreeGutterCloseRafRef.current !== null) {
window.cancelAnimationFrame(hostTreeGutterCloseRafRef.current);
}
hostTreeGutterCloseRafRef.current = window.requestAnimationFrame(() => {
hostTreeGutterCloseRafRef.current = null;
setHostTreeTabGutter(0);
});
return;
}
setHostTreeTabGutter(0);
return;
}
if (hostTreeGutterCloseRafRef.current !== null) {
window.cancelAnimationFrame(hostTreeGutterCloseRafRef.current);
hostTreeGutterCloseRafRef.current = null;
}
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
const toggleSlot = hostTreeToggleSlotRef.current;
if (!root || !toggleSlot) {
@@ -237,38 +374,46 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}
const rootLeft = root.getBoundingClientRect().left;
const toggleRight = toggleSlot.getBoundingClientRect().right - rootLeft;
setHostTreeTabGutter(Math.max(0, hostTreeLayoutWidth - toggleRight));
}, [hostTreeLayoutWidth, showHostTreeToggle]);
setHostTreeTabGutter(computeHostTreeTabGutter(hostTreeLayoutWidth, toggleRight));
}, [effectiveShowHostTreeToggle, hostTreeGutterExiting, hostTreeLayoutWidth]);
const updateHostTreeTabGutterRef = useRef(updateHostTreeTabGutter);
updateHostTreeTabGutterRef.current = updateHostTreeTabGutter;
useLayoutEffect(() => {
updateHostTreeTabGutter();
updateHostTreeTabGutter({ deferClose: true });
}, [hostTreeLayoutWidth, updateHostTreeTabGutter]);
useLayoutEffect(() => {
const syncGutter = () => updateHostTreeTabGutterRef.current();
syncGutter({ deferClose: true });
const rafId = window.requestAnimationFrame(() => syncGutter());
const settleTimer = window.setTimeout(syncGutter, 320);
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
if (!root) return;
const ro = new ResizeObserver(() => updateHostTreeTabGutter());
ro.observe(root);
const ro = new ResizeObserver(() => syncGutter());
if (root) ro.observe(root);
if (fixedLeftTabsRef.current) ro.observe(fixedLeftTabsRef.current);
if (tabsContainerRef.current) ro.observe(tabsContainerRef.current);
if (hostTreeToggleSlotRef.current) ro.observe(hostTreeToggleSlotRef.current);
window.addEventListener('resize', updateHostTreeTabGutter);
window.addEventListener('resize', syncGutter);
return () => {
window.cancelAnimationFrame(rafId);
if (hostTreeGutterCloseRafRef.current !== null) {
window.cancelAnimationFrame(hostTreeGutterCloseRafRef.current);
hostTreeGutterCloseRafRef.current = null;
}
window.clearTimeout(settleTimer);
ro.disconnect();
window.removeEventListener('resize', updateHostTreeTabGutter);
window.removeEventListener('resize', syncGutter);
};
}, [
updateHostTreeTabGutter,
orderedTabs.length,
showSftpTab,
isWindowFullscreen,
showHostTreeToggle,
effectiveShowHostTreeToggle,
isHostTreeOpen,
]);
useEffect(() => {
if (!showHostTreeToggle) return;
setHostTreeTogglePop(true);
const timer = window.setTimeout(() => setHostTreeTogglePop(false), 360);
return () => window.clearTimeout(timer);
}, [showHostTreeToggle]);
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('tab-reorder-id', tabId);
@@ -336,6 +481,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
scrollTopTabIntoComfortView(e.currentTarget, tab, 'smooth');
}, []);
const handleHostTreeTogglePointerDown = useCallback((e: React.PointerEvent) => {
if (!effectiveShowHostTreeToggle) return;
e.preventDefault();
e.stopPropagation();
suppressHostTreeToggleClickRef.current = true;
toggleHostTree();
}, [effectiveShowHostTreeToggle, toggleHostTree]);
const handleHostTreeToggleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (suppressHostTreeToggleClickRef.current) {
suppressHostTreeToggleClickRef.current = false;
return;
}
if (!effectiveShowHostTreeToggle) return;
toggleHostTree();
}, [effectiveShowHostTreeToggle, toggleHostTree]);
// Pre-compute tab shift styles for all tabs to avoid recalculation during render
const tabShiftStyles = useMemo(() => {
if (!dropIndicator || !isDraggingForReorder || !draggedTabIdRef.current) {
@@ -448,6 +611,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
const isBeingDragged = draggingSessionId === tabId;
const shiftStyle = tabShiftStyles[tabId] || emptyTabStyle;
const showDropIndicatorBefore = dropIndicator?.tabId === tabId && dropIndicator.position === 'before';
const showDropIndicatorAfter = dropIndicator?.tabId === tabId && dropIndicator.position === 'after';
return (
<EditorTopTab
key={tabId}
@@ -456,6 +624,16 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
host={host}
suffix={suffix}
onRequestCloseEditorTab={onRequestCloseEditorTab}
isBeingDragged={isBeingDragged}
isDraggingForReorder={isDraggingForReorder}
shiftStyle={shiftStyle}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
onTabDragStart={handleTabDragStart}
onTabDragEnd={handleTabDragEnd}
onTabDragOver={handleTabDragOver}
onTabDragLeave={handleTabDragLeave}
onTabDrop={handleTabDrop}
tabAnimationClass={getTabAnimationClass(tabId)}
/>
);
@@ -532,12 +710,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
if (item.type === 'logView') {
const logView = item.logView;
const isBeingDragged = draggingSessionId === logView.id;
const shiftStyle = tabShiftStyles[logView.id] || emptyTabStyle;
const showDropIndicatorBefore = dropIndicator?.tabId === logView.id && dropIndicator.position === 'before';
const showDropIndicatorAfter = dropIndicator?.tabId === logView.id && dropIndicator.position === 'after';
return (
<LogViewTopTab
key={logView.id}
logView={logView}
onCloseLogView={onCloseLogView}
isBeingDragged={isBeingDragged}
isDraggingForReorder={isDraggingForReorder}
shiftStyle={shiftStyle}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
onTabDragStart={handleTabDragStart}
onTabDragEnd={handleTabDragEnd}
onTabDragOver={handleTabDragOver}
onTabDragLeave={handleTabDragLeave}
onTabDrop={handleTabDrop}
t={t}
tabAnimationClass={getTabAnimationClass(logView.id)}
/>
@@ -576,17 +768,21 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<div
className="h-9 flex items-end gap-0 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
className="h-9 flex items-end gap-0 app-drag overflow-visible"
style={{
...dragRegionStyle,
paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12,
paddingRight: showWindowControls ? 0 : 12,
}}
>
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
<div ref={fixedLeftTabsRef} className="flex items-end gap-0 flex-shrink-0 app-drag">
<RootTopTab
tabId="vault"
label="Vaults"
icon={<FolderLock size={14} />}
className="rounded"
compact={showHostTreeToggle}
compact={rootTabsCompact}
/>
{showSftpTab && (
<RootTopTab
@@ -594,7 +790,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
label="SFTP"
icon={<Folder size={14} />}
className="rounded-t-md"
compact={showHostTreeToggle}
compact={rootTabsCompact}
/>
)}
</div>
@@ -612,11 +808,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}
}}
>
{hasTerminalOrWorkspaceTabs && (
{hasHostTreeToggleSurface && (
<div
ref={hostTreeToggleSlotRef}
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end"
data-visible={showHostTreeToggle ? 'true' : 'false'}
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
style={noDragRegionStyle}
>
<Tooltip>
<TooltipTrigger asChild>
@@ -627,15 +824,16 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
data-state={isHostTreeOpen ? 'active' : 'inactive'}
className={cn(
'h-7 w-7 flex-shrink-0 app-no-drag rounded-none hover:bg-transparent',
hostTreeTogglePop && showHostTreeToggle && 'top-tab-host-tree-toggle-pop',
)}
style={{
color: isHostTreeOpen
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
pointerEvents: showHostTreeToggle ? 'auto' : 'none',
pointerEvents: effectiveShowHostTreeToggle ? 'auto' : 'none',
...noDragRegionStyle,
}}
onClick={toggleHostTree}
onPointerDown={handleHostTreeTogglePointerDown}
onClick={handleHostTreeToggleClick}
>
<Menu size={14} />
</Button>
@@ -646,9 +844,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Tooltip>
</div>
)}
{showHostTreeToggle && (
{hasHostTreeToggleSurface && (
<div
className="top-tab-host-tree-gutter flex-shrink-0"
className={cn(
'top-tab-host-tree-gutter flex-shrink-0',
hostTreeGutterExiting && 'top-tab-host-tree-gutter-exit',
)}
style={{ width: hostTreeTabGutter }}
aria-hidden
/>
@@ -721,9 +922,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Tooltip>
)}
{/* Fixed right controls — utility icons + window controls share one row */}
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
<div
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7"
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7 overflow-visible"
style={dragRegionStyle}
>
<Tooltip>
@@ -731,7 +932,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 app-no-drag"
className="h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
@@ -743,13 +944,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<WindowOpacityButton
windowOpacity={windowOpacity}
setWindowOpacity={setWindowOpacity}
className="h-7 w-7 shrink-0"
className="h-7 w-7 shrink-0 top-tab-utility-btn"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<SyncStatusButton
onOpenSettings={onOpenSettings}
onSyncNow={onSyncNow}
className="h-7 w-7 shrink-0"
className="h-7 w-7 shrink-0 top-tab-utility-btn"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<Tooltip>
@@ -757,10 +958,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 app-no-drag"
className="h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive && !followAppTerminalTheme}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
@@ -772,7 +972,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 app-no-drag"
className="h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenSettings}
>
@@ -781,10 +981,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</TooltipTrigger>
<TooltipContent>{t('topTabs.openSettings')}</TooltipContent>
</Tooltip>
{!isMacClient && <WindowControls />}
{showWindowControls && <WindowControls />}
</div>
{/* Small drag shim to the right edge (macOS only on Windows the close button should touch the edge) */}
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0 self-end" />}
{isMacClient && !showWindowControls && (
<div className="w-2 h-9 app-drag flex-shrink-0 self-end" />
)}
</div>
</div>
);
@@ -809,9 +1011,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.setWindowOpacity === next.setWindowOpacity &&
prev.onSyncNow === next.onSyncNow &&
prev.onToggleTheme === next.onToggleTheme &&
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
prev.isImmersiveActive === next.isImmersiveActive &&
prev.showSftpTab === next.showSftpTab
prev.showSftpTab === next.showSftpTab &&
prev.showHostTreeSidebar === next.showHostTreeSidebar
);
};

View File

@@ -57,7 +57,6 @@ import {
STORAGE_KEY_VAULT_SIDEBAR_WIDTH,
} from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import {
ConnectionLog,
GroupConfig,
@@ -89,6 +88,7 @@ import SnippetsManager from "./SnippetsManager";
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
import { HostTreeGroupDeleteDialog } from "./host/HostTreeGroupDeleteDialog";
import { useHostTreeInlineGroupActions } from "./vault/useHostTreeInlineGroupActions";
import { useHostTreeInlineHostActions } from "./vault/useHostTreeInlineHostActions";
import { useRegisterVaultHostTreeActions } from "./vault/useRegisterVaultHostTreeActions";
import { Button } from "./ui/button";
import { RippleButton } from "./ui/ripple";
@@ -262,8 +262,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
const [deleteGroupWithHosts, setDeleteGroupWithHosts] = useState(false);
useInstantThemeSwitch(rootRef);
// Sidebar collapsed state with localStorage persistence
const [storedSidebarCollapsed, setStoredSidebarCollapsed] = useStoredBoolean(
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
@@ -482,6 +480,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}, []);
const handleDuplicateHost = useCallback((host: Host) => {
setCurrentSection("hosts");
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
// Create a copy of the host with a new ID and modified label
const duplicatedHost: Host = {
...host,
@@ -962,8 +963,20 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
t,
});
const {
startInlineRenameHost,
commitInlineHostRename,
cancelInlineHostEdit,
} = useHostTreeInlineHostActions({
hosts,
onUpdateHosts,
t,
});
useRegisterVaultHostTreeActions({
handleCopyCredentials,
handleDuplicateHost,
startInlineRenameHost,
onDeleteHost,
handleUnmanageGroup,
moveHostToGroup,
@@ -974,6 +987,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
startInlineDeleteGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
commitInlineHostRename,
cancelInlineHostEdit,
});
const isHostsSectionActive = currentSection === "hosts";

View File

@@ -26,6 +26,11 @@ import {
resolveApproval,
type ApprovalRequest,
} from '../../infrastructure/ai/shared/approvalGate';
import {
getAIPanelDiagnosticHiddenParts,
getAIPanelProfilerProps,
isAIPanelDiagnosticPartHidden,
} from './aiPanelDiagnostics';
interface ChatMessageListProps {
messages: ChatMessage[];
@@ -140,6 +145,10 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
dragStart.current = null;
}, []);
const { t } = useI18n();
const hiddenParts = getAIPanelDiagnosticHiddenParts();
const hideAttachments = isAIPanelDiagnosticPartHidden('attachments', hiddenParts);
const hideMarkdown = isAIPanelDiagnosticPartHidden('markdown', hiddenParts);
const hideToolCalls = isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts);
const [renderedTailCount, setRenderedTailCount] = useState(MESSAGE_RENDER_BATCH);
useEffect(() => {
@@ -201,17 +210,20 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
)}
{displayedMessages.map((message) => {
if (message.role === 'tool') {
if (hideToolCalls) return null;
return (
<React.Fragment key={message.id}>
{message.toolResults?.map((tr) => (
<div key={tr.toolCallId}>
<ToolCall
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
</div>
<React.Profiler key={tr.toolCallId} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Result')}>
<div>
<ToolCall
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
</div>
</React.Profiler>
))}
</React.Fragment>
);
@@ -234,7 +246,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
)}
{/* User attachments (images, files) — fallback to legacy `images` field */}
{isUser && (message.attachments ?? message.images)?.length && (
{isUser && !hideAttachments && (message.attachments ?? message.images)?.length && (
<div className="flex gap-1.5 flex-wrap mb-1">
{(message.attachments ?? message.images)!.map((att, i) => (
att.terminalSelection ? (
@@ -269,16 +281,22 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{message.content && (
isUser
? <div className="whitespace-pre-wrap break-words text-[13px] leading-[1.45]">{message.content}</div>
: <MessageResponse isAnimating={isThisStreaming}>
{message.content}
</MessageResponse>
: hideMarkdown
? <div className="whitespace-pre-wrap break-words text-[13px] leading-[1.45]">{message.content}</div>
: (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
<MessageResponse isAnimating={isThisStreaming}>
{message.content}
</MessageResponse>
</React.Profiler>
)
)}
{/* Pending tool calls from the *last* assistant message are rendered
after all tool-result messages (see below) for chronological order.
Unresolved tool calls from earlier or cancelled messages are shown
inline — as interrupted, or with approval controls if still pending. */}
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
{!hideToolCalls && (message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id),
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
@@ -291,16 +309,18 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<div key={tc.id}>
<ToolCall
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
<React.Profiler key={tc.id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Pending')}>
<div>
<ToolCall
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
</React.Profiler>
);
})}
@@ -332,7 +352,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{/* Pending tool calls from the last assistant message — rendered here
(after all tool-result messages) so they appear at the bottom. */}
{lastAssistantMessage?.toolCalls?.filter((tc) =>
{!hideToolCalls && lastAssistantMessage?.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
@@ -345,35 +365,39 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<div key={tc.id}>
<ToolCall
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
<React.Profiler key={tc.id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Last')}>
<div>
<ToolCall
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
</React.Profiler>
);
})}
{/* Standalone MCP/SDK approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
{!hideToolCalls && Array.from(pendingApprovals.entries())
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
.map(([id, req]) => {
return (
<div key={id}>
<ToolCall
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
</div>
<React.Profiler key={id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Approval')}>
<div>
<ToolCall
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
</div>
</React.Profiler>
);
})}
{/* Streaming indicator — only when no content and no thinking yet */}

View File

@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
},
});
const {
AI_PANEL_FORCE_HIDE_ALL_CONTENT,
AI_PANEL_FORCE_HIDE_SHELL,
AI_PANEL_DIAGNOSTIC_HIDE_KEY,
AI_PANEL_DIAGNOSTIC_PROFILE_KEY,
getAIPanelDiagnosticHiddenParts,
isAIPanelDiagnosticPartHidden,
isAIPanelDiagnosticsProfilingEnabled,
} = await import('./aiPanelDiagnostics.ts');
test('AI panel diagnostics does not hide content by default', () => {
window.localStorage.removeItem(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
assert.equal(AI_PANEL_FORCE_HIDE_ALL_CONTENT, false);
assert.equal(isAIPanelDiagnosticPartHidden('header'), false);
assert.equal(isAIPanelDiagnosticPartHidden('input'), false);
});
test('AI panel diagnostics does not hide the side panel shell by default', () => {
assert.equal(AI_PANEL_FORCE_HIDE_SHELL, false);
});
test('AI panel diagnostics parses hidden parts from local storage', () => {
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_HIDE_KEY, ' messages, input ,markdown ');
const hiddenParts = getAIPanelDiagnosticHiddenParts();
assert.equal(hiddenParts.has('messages'), true);
assert.equal(hiddenParts.has('input'), true);
assert.equal(hiddenParts.has('markdown'), true);
assert.equal(isAIPanelDiagnosticPartHidden('messages', hiddenParts), true);
assert.equal(isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts), false);
});
test('AI panel diagnostics supports hiding everything at once', () => {
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_HIDE_KEY, 'all');
const hiddenParts = getAIPanelDiagnosticHiddenParts();
assert.equal(isAIPanelDiagnosticPartHidden('header', hiddenParts), true);
assert.equal(isAIPanelDiagnosticPartHidden('input', hiddenParts), true);
});
test('AI panel profiling accepts common enabled values', () => {
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_PROFILE_KEY, 'on');
assert.equal(isAIPanelDiagnosticsProfilingEnabled(), true);
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_PROFILE_KEY, '0');
assert.equal(isAIPanelDiagnosticsProfilingEnabled(), false);
});

View File

@@ -0,0 +1,81 @@
import type React from 'react';
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = 'netcatty.aiDebug.hide';
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = 'netcatty.aiDebug.profile';
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
export const AI_PANEL_FORCE_HIDE_SHELL = false;
export type AIPanelDiagnosticPart =
| 'all'
| 'attachments'
| 'header'
| 'history'
| 'input'
| 'markdown'
| 'messages'
| 'recent'
| 'toolcalls';
function readLocalStorageValue(key: string): string {
if (typeof window === 'undefined') return '';
try {
return window.localStorage.getItem(key) ?? '';
} catch {
return '';
}
}
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
return new Set(['all']);
}
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
return new Set(
raw
.split(',')
.map((part) => part.trim().toLowerCase())
.filter(Boolean),
);
}
export function isAIPanelDiagnosticPartHidden(
part: AIPanelDiagnosticPart,
hiddenParts = getAIPanelDiagnosticHiddenParts(),
): boolean {
return hiddenParts.has('all') || hiddenParts.has(part);
}
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
export function logAIPanelProfiler(
id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number,
baseDuration: number,
): void {
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
console.info(
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
);
}
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
const startedAt = performance.now();
try {
return calculate();
} finally {
const elapsed = performance.now() - startedAt;
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
}
}
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
return {
id,
onRender: logAIPanelProfiler,
};
}

View File

@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
import {
buildHistoricalToolReplayMaps,
buildHistoricalToolResultReplayText,
buildHistoricalUserReplayContent,
} from "./cattyHistoryReplay.ts";
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
test("buildHistoricalUserReplayContent replaces historical image data with a placeholder", () => {
const attachment: ChatMessageAttachment = {
base64Data: "A".repeat(100_000),
mediaType: "image/png",
filename: "screenshot.png",
};
const result = buildHistoricalUserReplayContent("inspect this", [attachment]);
assert.match(result, /inspect this/);
assert.match(result, /Historical image attachment omitted from replay/);
assert.match(result, /filename=screenshot\.png/);
assert.doesNotMatch(result, /AAAAA/);
});
test("buildHistoricalUserReplayContent preserves historical file path metadata", () => {
const content = buildHistoricalUserReplayContent("inspect this file", [{
base64Data: "A".repeat(200),
mediaType: "text/plain",
filename: "deploy.log",
filePath: "/tmp/netcatty/deploy.log",
}]);
assert.match(content, /Historical file attachment omitted from replay/);
assert.match(content, /filename=deploy\.log/);
assert.match(content, /path=\/tmp\/netcatty\/deploy\.log/);
assert.doesNotMatch(content, /AAAAAAAA/);
});
test("buildHistoricalUserReplayContent replaces historical terminal selections with metadata only", () => {
const attachment: ChatMessageAttachment = {
base64Data: "VGhpcyBpcyBhIGxvbmcgdGVybWluYWwgc2VsZWN0aW9u",
mediaType: "text/plain",
filename: "terminal-selection.log",
terminalSelection: true,
previewText: "npm run build failed on vite",
lineCount: 42,
};
const result = buildHistoricalUserReplayContent("", [attachment]);
assert.match(result, /Historical terminal selection omitted from replay/);
assert.match(result, /filename=terminal-selection\.log/);
assert.match(result, /lines=42/);
assert.match(result, /preview=npm run build failed on vite/);
assert.doesNotMatch(result, /long terminal selection/);
});
test("buildHistoricalToolResultReplayText replaces historical terminal output with a replay placeholder", () => {
const toolCall: ToolCall = {
id: "call-1",
name: "terminal_execute",
arguments: { command: "npm run build" },
};
const result: ToolResult = {
toolCallId: "call-1",
content: "BUILD ".repeat(20_000),
isError: true,
};
const replay = buildHistoricalToolResultReplayText(result, toolCall);
assert.match(replay, /Historical terminal output omitted from replay/);
assert.match(replay, /command=npm run build/);
assert.match(replay, /status=error/);
assert.doesNotMatch(replay, /BUILD BUILD BUILD/);
});
test("buildHistoricalToolResultReplayText keeps non-terminal tool results intact", () => {
const toolCall: ToolCall = {
id: "call-1",
name: "web_search",
arguments: { query: "Vercel AI SDK" },
};
const result: ToolResult = {
toolCallId: "call-1",
content: "search result summary",
};
assert.equal(buildHistoricalToolResultReplayText(result, toolCall), "search result summary");
});
test("buildHistoricalToolResultReplayText can preserve terminal output for 413 retries", () => {
const toolCall: ToolCall = {
id: "call-1",
name: "terminal_execute",
arguments: { command: "npm test" },
};
const result: ToolResult = {
toolCallId: "call-1",
content: "real terminal output",
};
assert.equal(
buildHistoricalToolResultReplayText(result, toolCall, { preserveTerminalOutput: true }),
"real terminal output",
);
});
test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest preceding call", () => {
const messages: ChatMessage[] = [
{
id: "assistant-1",
role: "assistant",
content: "",
timestamp: 1,
toolCalls: [{ id: "call1", name: "url_fetch", arguments: { url: "https://example.com" } }],
},
{
id: "tool-1",
role: "tool",
content: "",
timestamp: 2,
toolResults: [{ toolCallId: "call1", content: "PAGE" }],
},
{
id: "assistant-2",
role: "assistant",
content: "",
timestamp: 3,
toolCalls: [{ id: "call1", name: "terminal_execute", arguments: { command: "cat /tmp/log" } }],
},
{
id: "tool-2",
role: "tool",
content: "",
timestamp: 4,
toolResults: [{ toolCallId: "call1", content: "TERMINAL BYTES" }],
},
];
const maps = buildHistoricalToolReplayMaps(messages);
const secondResult = messages[3].toolResults?.[0];
assert.ok(secondResult);
const pairedCall = maps.toolCallByToolResult.get(secondResult);
assert.equal(pairedCall?.name, "terminal_execute");
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[0])?.has(messages[0].toolCalls![0]), true);
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
});

View File

@@ -0,0 +1,138 @@
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
const MAX_TOOL_COMMAND_CHARS = 220;
function truncateInline(value: string, maxChars: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxChars) return normalized;
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
}
function describeAttachmentSize(attachment: ChatMessageAttachment): string {
return `${attachment.base64Data.length} base64 chars`;
}
function formatTerminalSelectionPlaceholder(
attachment: ChatMessageAttachment,
index: number,
): string {
const details = [
`filename=${attachment.filename || `terminal-selection-${index + 1}.log`}`,
attachment.lineCount != null ? `lines=${attachment.lineCount}` : undefined,
attachment.previewText ? `preview=${truncateInline(attachment.previewText, MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS)}` : undefined,
describeAttachmentSize(attachment),
].filter(Boolean).join(", ");
return `[Historical terminal selection omitted from replay: ${details}]`;
}
function formatAttachmentPlaceholder(
attachment: ChatMessageAttachment,
index: number,
): string {
const label = attachment.mediaType.startsWith("image/") ? "image" : "file";
const details = [
attachment.filename ? `filename=${attachment.filename}` : undefined,
attachment.filePath ? `path=${attachment.filePath}` : undefined,
`mediaType=${attachment.mediaType}`,
describeAttachmentSize(attachment),
].filter(Boolean).join(", ");
return `[Historical ${label} attachment omitted from replay: ${details || `attachment-${index + 1}`}]`;
}
export function buildHistoricalUserReplayContent(
content: string,
attachments: ChatMessageAttachment[] = [],
): string {
const placeholders = attachments.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
if (!placeholders.length) return content;
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
return content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
}
function getToolCommand(toolCall?: ToolCall): string | undefined {
const args = toolCall?.arguments ?? {};
if (typeof args.command === "string") return args.command;
const serialized = JSON.stringify(args);
return serialized && serialized !== "{}" ? serialized : undefined;
}
export function buildHistoricalToolReplayMaps(messages: ChatMessage[]): {
resolvedToolCallsByAssistant: Map<ChatMessage, Set<ToolCall>>;
toolCallByToolResult: Map<ToolResult, ToolCall>;
} {
const resolvedToolCallsByAssistant = new Map<ChatMessage, Set<ToolCall>>();
const toolCallByToolResult = new Map<ToolResult, ToolCall>();
const pendingToolCalls: Array<{ message: ChatMessage; toolCall: ToolCall }> = [];
for (const message of messages) {
if (message.role === "assistant" && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
pendingToolCalls.push({ message, toolCall });
}
continue;
}
if (message.role !== "tool" || !message.toolResults?.length) continue;
for (const result of message.toolResults) {
const pendingIndex = findLastIndex(
pendingToolCalls,
({ toolCall }) => toolCall.id === result.toolCallId,
);
if (pendingIndex < 0) continue;
const [paired] = pendingToolCalls.splice(pendingIndex, 1);
toolCallByToolResult.set(result, paired.toolCall);
const resolved = resolvedToolCallsByAssistant.get(paired.message) ?? new Set<ToolCall>();
resolved.add(paired.toolCall);
resolvedToolCallsByAssistant.set(paired.message, resolved);
}
}
return { resolvedToolCallsByAssistant, toolCallByToolResult };
}
function findLastIndex<T>(items: T[], predicate: (item: T) => boolean): number {
for (let index = items.length - 1; index >= 0; index -= 1) {
if (predicate(items[index])) return index;
}
return -1;
}
export function buildHistoricalToolResultReplayText(
result: ToolResult,
toolCall?: ToolCall,
{
preserveTerminalOutput = false,
}: {
preserveTerminalOutput?: boolean;
} = {},
): string {
const toolName = toolCall?.name ?? "unknown";
if (!isTerminalToolName(toolName) || preserveTerminalOutput) {
return result.content;
}
const details = [
`toolCallId=${result.toolCallId}`,
getToolCommand(toolCall) ? `command=${truncateInline(getToolCommand(toolCall) ?? "", MAX_TOOL_COMMAND_CHARS)}` : undefined,
`outputChars=${result.content.length}`,
result.isError ? "status=error" : "status=success",
].filter(Boolean).join(", ");
return `[Historical terminal output omitted from replay: ${details}. Re-run terminal_execute if exact output is needed.]`;
}
function isTerminalToolName(toolName: string): boolean {
return toolName === "terminal" || toolName === "terminal_exec" || toolName === "terminal_execute";
}

View File

@@ -75,7 +75,7 @@ test("buildExternalAgentHistoryMessagesForBridge keeps fallback history availabl
);
});
test("buildExternalAgentHistoryMessages expands terminal selection attachments", () => {
test("buildExternalAgentHistoryMessages replaces historical terminal selection attachments with placeholders", () => {
const terminalSelection = createTerminalSelectionAttachment("docker ps -a\npermission denied");
assert.ok(terminalSelection);
const messages: ChatMessage[] = [
@@ -88,9 +88,9 @@ test("buildExternalAgentHistoryMessages expands terminal selection attachments",
assert.equal(result.length, 1);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /\[Terminal selection:/);
assert.match(result[0].content, /Historical terminal selection omitted from replay/);
assert.match(result[0].content, /docker ps -a/);
assert.match(result[0].content, /permission denied/);
assert.doesNotMatch(result[0].content, /permission denied/);
});
test("buildExternalAgentHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
@@ -292,12 +292,9 @@ test("buildExternalAgentHistoryMessages still drops one-word filler user message
}
});
test("buildExternalAgentHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
// Regression: tool results used to only reach fallback replay via the
// 500-char compact summary. If the user's last interaction produced a
// large tool output (cat/rg/fetched file), any "use that output"-style
// follow-up lost the actual bytes. Now tool messages flow through the
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
test("buildExternalAgentHistoryMessages replaces recent terminal tool output with a replay placeholder", () => {
// Historical terminal output should remain self-describing without
// replaying the actual bytes on every follow-up.
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
const messages: ChatMessage[] = [
message("u1", "user", "cat /etc/hosts"),
@@ -315,16 +312,15 @@ test("buildExternalAgentHistoryMessages preserves recent tool results verbatim (
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Raw-window tool result carries both the [from ...] provenance label
// and the actual bytes (not just the 500-char compact summary).
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
// Confirm we kept enough bytes to exceed the compact-summary cap.
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): \[Historical terminal output omitted from replay/);
assert.match(flat, /outputChars=1500/);
assert.doesNotMatch(flat, /DATA DATA DATA/);
const toolResultIdx = flat.indexOf("Tool result [from terminal");
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
const toolResultChunk = flat.slice(toolResultIdx);
assert.ok(
toolResultChunk.length > 600,
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
toolResultChunk.length < 500,
`expected terminal result placeholder to stay compact, got ${toolResultChunk.length}`,
);
});
@@ -650,8 +646,10 @@ test("buildExternalAgentHistoryMessages resolves tool_call provenance correctly
//
// Extract the two Tool-result lines and match each to its expected
// args. Use non-greedy .*? — the args JSON can contain parentheses.
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*Historical terminal output omitted from replay[^\n]*cat \/etc\/hosts/);
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*Historical terminal output omitted from replay[^\n]*cat \/etc\/resolv\.conf/);
assert.doesNotMatch(flat, /HOSTS_BYTES/);
assert.doesNotMatch(flat, /RESOLV_BYTES/);
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");

View File

@@ -1,5 +1,5 @@
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
import { buildPromptWithTerminalSelectionAttachments } from "../../application/state/terminalSelectionAttachment.ts";
import { buildHistoricalToolResultReplayText, buildHistoricalUserReplayContent } from "./cattyHistoryReplay.ts";
type ExternalAgentHistoryMessage = { role: "user" | "assistant"; content: string };
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
@@ -62,7 +62,7 @@ function isDurableConstraintText(value: string): boolean {
function getUserHistoryContent(message: ChatMessage): string {
if (message.role !== "user") return message.content || "";
return buildPromptWithTerminalSelectionAttachments(
return buildHistoricalUserReplayContent(
message.content || "",
message.attachments ?? [],
);
@@ -127,7 +127,6 @@ function summarizeToolMessage(
if (!message.toolResults?.length) return [];
return message.toolResults.map((result) => {
const prefix = result.isError ? "Tool error" : "Tool result";
const content = normalizeWhitespace(result.content || "");
// Same provenance problem as the raw-window path: once a tool result
// lands in the compact section (older than the 6-item raw window),
// its paired assistant tool_call is almost always gone. Without the
@@ -139,7 +138,10 @@ function summarizeToolMessage(
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
: undefined);
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(normalizeWhitespace(replayContent), MAX_TOOL_SUMMARY_CHARS)}`;
});
}
@@ -247,13 +249,11 @@ function toRawHistoryMessage(
}
if (message.role === "tool" && message.toolResults?.length) {
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
// per message, ~2000). Without this, follow-up turns after stale-session
// recovery would only see the 500-char compact summary in
// summarizeToolMessage, losing the actual bytes the user might reference
// ("use that output", "what did cat show?"). external agent replay only supports user/
// assistant roles, so we flatten to "assistant" — the tool results were
// produced during the assistant's turn.
// Keep recent tool results self-describing while replacing terminal
// output with placeholders, so stale-session recovery doesn't replay
// bulky command output on every follow-up. External agent replay only
// supports user/assistant roles, so we flatten to "assistant" — the
// tool results were produced during the assistant's turn.
//
// Inline the originating tool_call's name+args. Tool calls and their
// results live in separate messages; if the last six raw items start
@@ -266,7 +266,10 @@ function toRawHistoryMessage(
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
: undefined);
return `${prefix}${callLabel} (${result.toolCallId}): ${replayContent}`;
});
return [{
sourceId: message.id,

View File

@@ -21,6 +21,7 @@ import type {
ExternalAgentConfig,
ProviderAdvancedParams,
ProviderConfig,
ToolResult,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
@@ -35,17 +36,30 @@ import {
prepareContextCompaction,
resolveContextWindow,
} from '../../../infrastructure/ai/contextCompaction';
import {
compressMessagesForRequestTooLargeRetry,
} from '../../../infrastructure/ai/requestPayloadCompression';
import {
createCattyRequestTooLargeRetryError,
hadToolProgressBeforeRequestTooLarge,
} from '../../../infrastructure/ai/cattyRequestTooLargeRetry';
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { getExternalAgentSdkBackend } from '../../../infrastructure/ai/managedAgents';
import { runSdkAgentTurn } from '../../../infrastructure/ai/sdkAgentAdapter';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import { classifyError, isRequestTooLargeError } from '../../../infrastructure/ai/errorClassifier';
import { isSdkStreamStateError } from '../../../infrastructure/ai/shared/streamStateErrors';
import {
buildPromptWithTerminalSelectionAttachments,
isTerminalSelectionAttachment,
} from '../../../application/state/terminalSelectionAttachment';
import { latestAISessionsSnapshot } from '../../../application/state/aiStateSnapshots';
import {
buildHistoricalToolReplayMaps,
buildHistoricalToolResultReplayText,
buildHistoricalUserReplayContent,
} from '../cattyHistoryReplay';
import {
extractProviderContinuationFromRawChunk,
getOpenAIChatAssistantFieldsForHistoryMessage,
@@ -334,6 +348,7 @@ export function useAIChatStreaming({
// Track the current assistant message ID so updates target the correct message
let activeMsgId = currentAssistantMsgId;
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
let hadToolProgress = false;
const reader = result.fullStream.getReader();
// -- Text-delta batching: accumulate deltas and flush periodically --
@@ -409,7 +424,16 @@ export function useAIChatStreaming({
try {
while (true) {
const { done, value } = await reader.read();
let readResult: ReadableStreamReadResult<unknown>;
try {
readResult = await reader.read();
} catch (readErr) {
if (isRequestTooLargeError(readErr)) {
throw createCattyRequestTooLargeRetryError(readErr, hadToolProgress);
}
throw readErr;
}
const { done, value } = readResult;
if (done) break;
// Use the StreamChunk union for type narrowing instead of unsafe casts
const chunk = value as StreamChunk;
@@ -476,6 +500,7 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolCallChunk;
hadToolProgress = true;
const messageId = ensureAssistantMessage();
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
updateMessageById(streamSessionId, messageId, msg => ({
@@ -501,6 +526,7 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolResultChunk;
hadToolProgress = true;
// Mark the assistant message's tool execution as completed
updateMessageById(streamSessionId, activeMsgId, msg =>
msg.role === 'assistant' && msg.executionStatus === 'running'
@@ -547,6 +573,14 @@ export function useAIChatStreaming({
console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
break;
}
if (isRequestTooLargeError(typedChunk.error)) {
cancelPendingFlush();
flushText();
throw createCattyRequestTooLargeRetryError(
typedChunk.error,
hadToolProgress,
);
}
cancelPendingFlush();
flushText();
updateMessageById(streamSessionId, activeMsgId, msg => ({
@@ -779,73 +813,86 @@ export function useAIChatStreaming({
};
try {
// Issue #5: Build SDK messages including tool-call and tool-result messages
// so the LLM maintains full conversation context
const allMessages = currentSession?.messages ?? [];
let openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
const buildSdkMessages = (
allMessages: ChatMessage[],
includeCurrentUserMessage: boolean,
{
preserveTerminalToolResults = new Set<ToolResult>(),
}: {
preserveTerminalToolResults?: ReadonlySet<ToolResult>;
} = {},
): Array<ModelMessage> => {
const { resolvedToolCallsByAssistant, toolCallByToolResult } = buildHistoricalToolReplayMaps(allMessages);
const nextFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
const sdkMessages: Array<ModelMessage> = [];
let previousHistoryMessageWasToolResult = false;
// Collect all tool call IDs that have a corresponding tool result,
// so we can skip orphaned tool calls (e.g. from user stopping mid-execution)
const resolvedToolCallIds = new Set<string>();
for (const m of allMessages) {
if (m.role === 'tool' && m.toolResults) {
for (const tr of m.toolResults) resolvedToolCallIds.add(tr.toolCallId);
}
}
const findToolName = (toolCallId: string): string => {
for (const prev of allMessages) {
if (prev.role === 'assistant' && prev.toolCalls) {
const tc = prev.toolCalls.find(t => t.id === toolCallId);
if (tc) return tc.name;
}
}
return 'unknown';
};
const sdkMessages: Array<ModelMessage> = [];
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
let previousHistoryMessageWasToolResult = false;
for (const m of allMessages) {
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
if (m.role === 'user') {
// Build multimodal content when attachments are present (fallback to legacy `images` field)
const messageAttachments = m.attachments ?? m.images;
const modelText = messageAttachments?.length
? buildPromptWithTerminalSelectionAttachments(m.content, messageAttachments)
: m.content;
const modelAttachments = messageAttachments?.filter(
(attachment) => !isTerminalSelectionAttachment(attachment),
);
if (modelAttachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: modelText });
for (const att of modelAttachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
for (const m of allMessages) {
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
if (m.role === 'user') {
// Historical attachments are replayed as placeholders so screenshots,
// files, and terminal selections do not balloon every follow-up request.
const messageAttachments = m.attachments ?? m.images;
sdkMessages.push({
role: 'user',
content: buildHistoricalUserReplayContent(m.content, messageAttachments ?? []),
});
} else if (m.role === 'assistant') {
const activeContinuation = isProviderContinuationForSource(
m.providerContinuation,
continuationContext.source,
)
? m.providerContinuation
: undefined;
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
m,
continuationContext.source,
);
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
const resolvedToolCalls = resolvedToolCallsByAssistant.get(m);
const resolvedCalls = resolvedToolCalls
? m.toolCalls.filter(tc => resolvedToolCalls.has(tc))
: [];
const contentParts: AssistantContentPart[] = [];
if (resolvedCalls.length > 0) {
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: modelText });
}
} else if (m.role === 'assistant') {
const activeContinuation = isProviderContinuationForSource(
m.providerContinuation,
continuationContext.source,
)
? m.providerContinuation
: undefined;
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
m,
continuationContext.source,
);
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
const contentParts: AssistantContentPart[] = [];
if (resolvedCalls.length > 0) {
if (m.content) {
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
}
for (const tc of resolvedCalls) {
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: tc.arguments ?? {},
...(providerOptions ? { providerOptions } : {}),
});
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
sdkMessages.push(message);
if (resolvedCalls.length > 0) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
}
}
} else if (m.content) {
const contentParts: AssistantContentPart[] = [];
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
@@ -854,84 +901,91 @@ export function useAIChatStreaming({
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
}
if (m.content) {
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
}
for (const tc of resolvedCalls) {
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: tc.arguments ?? {},
...(providerOptions ? { providerOptions } : {}),
});
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
const message: ModelMessage = {
role: 'assistant',
content: toAssistantModelContent(contentParts),
};
sdkMessages.push(message);
if (resolvedCalls.length > 0) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
if (currentMessageFollowsToolResult) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
}
}
} else if (m.content) {
const contentParts: AssistantContentPart[] = [];
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
} else if (m.role === 'tool' && m.toolResults?.length) {
sdkMessages.push({
role: 'tool',
content: m.toolResults.map(tr => {
const toolCall = toolCallByToolResult.get(tr);
return {
type: 'tool-result' as const,
toolCallId: tr.toolCallId,
toolName: toolCall?.name ?? 'unknown',
output: {
type: 'text' as const,
value: buildHistoricalToolResultReplayText(tr, toolCall, {
preserveTerminalOutput: preserveTerminalToolResults.has(tr),
}),
},
};
}),
});
const message: ModelMessage = {
role: 'assistant',
content: toAssistantModelContent(contentParts),
};
sdkMessages.push(message);
if (currentMessageFollowsToolResult) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
}
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
}
if (includeCurrentUserMessage) {
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
const modelText = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter(
(attachment) => !isTerminalSelectionAttachment(attachment),
);
if (!modelAttachments.length) {
sdkMessages.push({ role: 'user', content: modelText });
} else {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: modelText });
for (const att of modelAttachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
}
}
sdkMessages.push({ role: 'user', content: parts });
}
}
} else if (m.role === 'tool' && m.toolResults?.length) {
sdkMessages.push({
role: 'tool',
content: m.toolResults.map(tr => ({
type: 'tool-result' as const,
toolCallId: tr.toolCallId,
toolName: findToolName(tr.toolCallId),
output: { type: 'text' as const, value: tr.content },
})),
});
}
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
}
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: trimmed });
for (const att of attachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
sdkMessages.push({ role: 'user', content: trimmed });
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: trimmed });
}
openAIChatAssistantFieldsByMessage = nextFieldsByMessage;
return sdkMessages;
};
const sdkMessages = buildSdkMessages(currentSession?.messages ?? [], true);
const collectToolResultsAfterMessage = (
messages: ChatMessage[],
messageId: string,
): Set<ToolResult> => {
const results = new Set<ToolResult>();
let afterMessage = false;
for (const message of messages) {
if (message.id === messageId) {
afterMessage = true;
continue;
}
if (!afterMessage || message.role !== 'tool' || !message.toolResults?.length) continue;
for (const result of message.toolResults) {
results.add(result);
}
}
return results;
};
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
@@ -959,63 +1013,178 @@ export function useAIChatStreaming({
defaultContextWindow: DEFAULT_CONTEXT_WINDOW_TOKENS,
});
const outputReserveTokens = Math.min(4096, Math.ceil(contextWindow * 0.05));
const requestReserveTokens = outputReserveTokens + estimateUnknownTokens({
const getRequestReserveTokens = () => outputReserveTokens + estimateUnknownTokens({
systemPrompt,
toolNames: Object.keys(tools),
openAIChatAssistantFields: Array.from(openAIChatAssistantFieldsByMessage.values()),
});
let messagesForStream = sdkMessages;
try {
const compacted = await prepareContextCompaction({
messages: sdkMessages,
contextWindow,
reservedTokens: requestReserveTokens,
protectRecentMessages: DEFAULT_PROTECT_RECENT_MESSAGES,
summarize: async (messagesToSummarize) => {
updateLastMessage(sessionId, msg => ({ ...msg, statusText: 'Compacting earlier context...' }));
const result = await generateText({
model,
system: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Summarize this earlier conversation context for the next model turn:\n\n${formatMessagesForCompaction(messagesToSummarize)}`,
}],
abortSignal: abortController.signal,
maxOutputTokens: 1600,
temperature: 0,
});
return result.text;
},
const summarizeForCompaction = async (messagesToSummarize: ModelMessage[]) => {
updateLastMessage(sessionId, msg => ({ ...msg, statusText: 'Compacting earlier context...' }));
const result = await generateText({
model,
system: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Summarize this earlier conversation context for the next model turn:\n\n${formatMessagesForCompaction(messagesToSummarize)}`,
}],
abortSignal: abortController.signal,
maxOutputTokens: 1600,
temperature: 0,
});
messagesForStream = compacted.messages;
} catch (err) {
if (abortController.signal.aborted) throw err;
console.warn('[Catty] Context compaction failed; falling back to recent messages only:', err);
messagesForStream = keepRecentContextMessages(sdkMessages, DEFAULT_PROTECT_RECENT_MESSAGES);
}
return result.text;
};
const prepareMessagesForStream = (messages: ModelMessage[]): ModelMessage[] => {
const pruned = pruneMessages({
messages,
reasoning: 'all',
emptyMessages: 'remove',
});
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
pruned,
openAIChatAssistantFieldsByMessage,
);
return pruned;
};
const compactMessages = async (
messages: ModelMessage[],
{
force = false,
statusText,
fallbackLog,
compressForRequestTooLargeRetry = false,
compressionLog,
}: {
force?: boolean;
statusText?: string;
fallbackLog: string;
compressForRequestTooLargeRetry?: boolean;
compressionLog?: string;
},
): Promise<ModelMessage[]> => {
const compressRetryMessages = (candidateMessages: ModelMessage[], log?: string): ModelMessage[] => {
if (!compressForRequestTooLargeRetry) return candidateMessages;
const compressed = compressMessagesForRequestTooLargeRetry(candidateMessages);
if (compressed.didAdjust && log) {
console.warn(log);
}
return compressed.messages;
};
messagesForStream = pruneMessages({
messages: messagesForStream,
reasoning: 'all',
emptyMessages: 'remove',
try {
if (statusText) {
updateLastMessage(sessionId, msg => ({ ...msg, statusText }));
}
const inputMessages = compressRetryMessages(messages, compressionLog);
const compacted = await prepareContextCompaction({
messages: inputMessages,
contextWindow,
reservedTokens: getRequestReserveTokens(),
thresholdRatio: force ? 0 : undefined,
protectRecentMessages: DEFAULT_PROTECT_RECENT_MESSAGES,
summarize: summarizeForCompaction,
});
let nextMessages = force && !compacted.didCompact
? keepRecentContextMessages(inputMessages, DEFAULT_PROTECT_RECENT_MESSAGES)
: compacted.messages;
return compressRetryMessages(nextMessages);
} catch (err) {
if (abortController.signal.aborted) throw err;
console.warn(fallbackLog, err);
const fallbackMessages = keepRecentContextMessages(messages, DEFAULT_PROTECT_RECENT_MESSAGES);
if (!compressForRequestTooLargeRetry) {
return fallbackMessages;
}
const compressed = compressMessagesForRequestTooLargeRetry(fallbackMessages);
if (compressed.didAdjust) {
console.warn('[Catty] Request content compressed after compaction fallback.');
}
return compressed.messages;
}
};
let messagesForStream = sdkMessages;
messagesForStream = await compactMessages(messagesForStream, {
fallbackLog: '[Catty] Context compaction failed; falling back to recent messages only:',
});
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
messagesForStream,
openAIChatAssistantFieldsByMessage,
);
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
messagesForStream,
abortController.signal,
assistantMsgId,
context.activeProvider?.advancedParams,
continuationContext,
);
messagesForStream = prepareMessagesForStream(messagesForStream);
try {
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
messagesForStream,
abortController.signal,
assistantMsgId,
context.activeProvider?.advancedParams,
continuationContext,
);
} catch (streamErr) {
if (abortController.signal.aborted || !isRequestTooLargeError(streamErr)) {
throw streamErr;
}
console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
const statusText = 'Request was too large. Compacting context and retrying...';
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
if (hadToolProgress) {
const latestSession = latestAISessionsSnapshot?.find(session => session.id === sessionId);
if (latestSession) {
retryBaseMessages = buildSdkMessages(latestSession.messages, false, {
preserveTerminalToolResults: collectToolResultsAfterMessage(
latestSession.messages,
assistantMsgId,
),
});
}
retryAssistantMsgId = generateId();
addMessageToSession(sessionId, {
id: retryAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: activeModelId || context.activeProvider?.defaultModel || '',
providerId: context.activeProvider?.providerId,
statusText,
});
} else {
updateMessageById(sessionId, assistantMsgId, msg => ({
...msg,
content: '',
thinking: undefined,
thinkingDurationMs: undefined,
providerContinuation: undefined,
toolCalls: undefined,
errorInfo: undefined,
executionStatus: undefined,
pendingApproval: undefined,
statusText,
}));
}
const retryMessages = prepareMessagesForStream(await compactMessages(retryBaseMessages, {
force: true,
statusText,
fallbackLog: '[Catty] Forced context compaction after 413 failed; falling back to recent messages only:',
compressForRequestTooLargeRetry: true,
compressionLog: '[Catty] Request content compressed after forced context compaction.',
}));
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
retryMessages,
abortController.signal,
retryAssistantMsgId,
context.activeProvider?.advancedParams,
continuationContext,
);
}
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);
@@ -1028,7 +1197,7 @@ export function useAIChatStreaming({
}
}, [
processCattyStream, reportStreamError, setStreamingForScope,
updateLastMessage,
addMessageToSession, updateLastMessage, updateMessageById,
]);
return {

View File

@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
const root = new URL("..", import.meta.url);
function readProjectFile(path: string): string {
return readFileSync(join(root.pathname, path), "utf8");
}
test("terminal side panel exposes stable custom CSS regions", () => {
const source = readProjectFile("components/terminalLayer/TerminalLayerSidePanelSection.tsx");
assert.match(source, /terminal-side-panel-shell/);
assert.match(source, /terminal-side-panel-tabs/);
assert.match(source, /terminal-side-panel-content/);
assert.match(source, /terminal-side-panel-resizer/);
assert.match(source, /isSidePanelOpenForCurrentTab \? 'terminal-side-panel' : undefined/);
});
test("terminal side panel shell is isolated from surrounding layout churn", () => {
const source = readProjectFile("components/terminalLayer/TerminalLayerSidePanelSection.tsx");
assert.match(source, /contain: 'layout paint style'/);
});
test("SFTP panel exposes stable custom CSS regions", () => {
const source = [
readProjectFile("components/SftpSidePanel.tsx"),
readProjectFile("components/sftp/SftpPaneView.tsx"),
readProjectFile("components/sftp/SftpPaneToolbar.tsx"),
readProjectFile("components/sftp/SftpPaneFileList.tsx"),
readProjectFile("components/sftp/SftpFileRow.tsx"),
readProjectFile("components/sftp/SftpPaneTreeView.tsx"),
readProjectFile("components/sftp/SftpPaneTreeNode.tsx"),
readProjectFile("components/sftp/SftpTransferQueue.tsx"),
].join("\n");
[
"terminal-sftp-panel",
"terminal-sftp-host-header",
"terminal-sftp-pane",
"terminal-sftp-toolbar",
"terminal-sftp-path",
"terminal-sftp-filter-bar",
"terminal-sftp-list",
"terminal-sftp-list-header",
"terminal-sftp-list-row",
"terminal-sftp-tree",
"terminal-sftp-tree-row",
"terminal-sftp-transfer-queue",
"terminal-sftp-transfer-queue-header",
"terminal-sftp-transfer-list",
].forEach((hook) => assert.match(source, new RegExp(hook)));
});
test("terminal host tree exposes stable custom CSS regions", () => {
const source = readProjectFile("components/terminalLayer/TerminalHostTreeSidebar.tsx");
assert.match(source, /terminal-host-tree-sidebar-shell/);
assert.match(source, /terminal-host-tree-sidebar/);
assert.match(source, /terminal-host-tree-sidebar-content/);
});
test("custom CSS help lists the expanded terminal and SFTP hooks", () => {
const source = readProjectFile("application/i18n/locales/zh-CN/core.ts");
[
"terminal-side-panel-tabs",
"terminal-side-panel-content",
"terminal-sftp-toolbar",
"terminal-sftp-list-row",
"terminal-sftp-tree-row",
"terminal-sftp-transfer-queue",
"terminal-host-tree-sidebar-content",
].forEach((hook) => assert.match(source, new RegExp(hook)));
});

View File

@@ -5,6 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import {
canPromoteTextEditor,
getTextEditorContentStats,
isTextEditorReadOnly,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
@@ -43,3 +44,8 @@ test("renders the promote button disabled while a save is running", () => {
assert.match(savingMarkup, /disabled=""/);
assert.doesNotMatch(idleMarkup, /disabled=""/);
});
test("counts editor content without allocating line arrays", () => {
assert.deepEqual(getTextEditorContentStats(""), { lineCount: 1, charCount: 0 });
assert.deepEqual(getTextEditorContentStats("one\ntwo\n"), { lineCount: 3, charCount: 8 });
});

View File

@@ -146,13 +146,13 @@ const getEditorColors = (isDark: boolean): EditorColors => ({
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
});
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
/** Build a fingerprint string so we can detect UI theme color changes cheaply. */
const getThemeSignal = (): string => {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return '';
}
const root = document.documentElement;
return root.dataset.immersiveTheme
return root.dataset.activeChromeTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
};
@@ -182,28 +182,37 @@ export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean =
export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving;
export function getTextEditorContentStats(content: string): { lineCount: number; charCount: number } {
let lineCount = 1;
for (let i = 0; i < content.length; i += 1) {
if (content.charCodeAt(i) === 10) lineCount += 1;
}
return { lineCount, charCount: content.length };
}
export const TextEditorPromoteButton: React.FC<{
saving: boolean;
onPromoteToTab: () => void;
title: string;
}> = ({ saving, onPromoteToTab, title }) => (
}> = React.memo(({ saving, onPromoteToTab, title }) => (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-6 w-6"
onClick={onPromoteToTab}
disabled={!canPromoteTextEditor({ saving })}
>
<Maximize2 size={14} />
<Maximize2 size={13} />
</Button>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
);
));
TextEditorPromoteButton.displayName = 'TextEditorPromoteButton';
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
fileName,
content,
languageId,
@@ -238,13 +247,13 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
);
// Track a signal that changes whenever immersive-mode or base theme colors change
// Track a signal that changes whenever active chrome or base theme colors change
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
// Define and update custom Monaco themes from active chrome / base UI colors
useEffect(() => {
if (!monaco) return;
@@ -284,7 +293,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
// Listen for theme changes via MutationObserver on <html> class, style, and active chrome attr
useEffect(() => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
const root = document.documentElement;
@@ -295,7 +304,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
const observer = new MutationObserver(updateTheme);
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'style', 'data-immersive-theme'],
attributeFilter: ['class', 'style', 'data-active-chrome-theme'],
});
return () => observer.disconnect();
}, []);
@@ -465,6 +474,8 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
const languageName = useMemo(() => getLanguageName(languageId), [languageId]);
const contentStats = useMemo(() => getTextEditorContentStats(content), [content]);
const languageOptions = useMemo(
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
[supportedLanguages],
@@ -477,35 +488,35 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
data-hotkey-close-tab={chrome === 'modal' ? 'true' : undefined}
>
{/* Header */}
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-baseline gap-2 flex-1 min-w-0">
<span className="text-sm font-semibold truncate flex-shrink-0">
<div className="h-9 px-3 py-1.5 border-b border-border/60 flex-shrink-0">
<div className="flex h-full items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-sm font-semibold leading-none truncate flex-shrink-0">
{fileName}
</span>
{subtitle && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground truncate cursor-default">
<span className="text-xs leading-none text-muted-foreground truncate cursor-default">
{subtitle}
</span>
</TooltipTrigger>
<TooltipContent>{subtitle}</TooltipContent>
</Tooltip>
)}
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
{saveError && <span className="text-xs leading-none text-destructive truncate">{saveError}</span>}
</div>
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-6 items-center gap-2 min-w-0">
{/* Search button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-6 w-6"
onClick={handleSearch}
>
<Search size={14} />
<Search size={13} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.search')}</TooltipContent>
@@ -517,10 +528,10 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
<Button
variant={wordWrap ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
className="h-6 w-6"
onClick={onToggleWordWrap}
>
<WrapText size={14} />
<WrapText size={13} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.editor.wordWrap')}</TooltipContent>
@@ -532,21 +543,21 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
value={languageId}
onValueChange={(v) => onLanguageChange(v || 'plaintext')}
placeholder={t('sftp.editor.syntaxHighlight')}
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
triggerClassName="h-6 max-w-[170px] min-w-[112px] text-xs"
/>
{/* Save button */}
<Button
variant="default"
size="sm"
className="h-7"
className="h-6 px-2.5 text-xs"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<Loader2 size={14} className="mr-1.5 animate-spin" />
<Loader2 size={13} className="mr-1 animate-spin" />
) : (
<CloudUpload size={14} className="mr-1.5" />
<CloudUpload size={13} className="mr-1" />
)}
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
</Button>
@@ -565,10 +576,10 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-6 w-6"
onClick={onRequestClose}
>
<X size={14} />
<X size={13} />
</Button>
)}
</div>
@@ -618,14 +629,17 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
{/* Footer */}
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{getLanguageName(languageId)}
{languageName}
</span>
<span>
{content.split('\n').length} lines {content.length} characters
{contentStats.lineCount} lines {contentStats.charCount} characters
</span>
</div>
</div>
);
};
export const TextEditorPane = React.memo(TextEditorPaneInner);
TextEditorPane.displayName = 'TextEditorPane';
export default TextEditorPane;

View File

@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const { getTextEditorTabShellStyle } = await import('./TextEditorTabView');
test('visible editor tab leaves room for the terminal host sidebar', () => {
assert.deepEqual(getTextEditorTabShellStyle(true, 280), {
zIndex: 20,
left: 280,
});
});
test('hidden editor tab stays hidden', () => {
assert.deepEqual(getTextEditorTabShellStyle(false, 280), {
pointerEvents: 'none',
visibility: 'hidden',
zIndex: 20,
left: 280,
});
});

View File

@@ -11,6 +11,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { saveEditorTab } from '../../application/state/editorTabSave';
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
import { useIsEditorTabActive } from '../../application/state/activeTabStore';
import { useTerminalHostTreeLayoutWidth } from '../../application/state/terminalHostTreeStore';
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
import type { Host } from '../../types';
import { toast } from '../ui/toast';
@@ -27,6 +28,14 @@ export interface TextEditorTabViewProps {
onRequestClose: (tabId: EditorTabId) => void;
}
export function getTextEditorTabShellStyle(isVisible: boolean, hostTreeLayoutWidth: number): React.CSSProperties {
return {
...(isVisible ? null : { pointerEvents: 'none', visibility: 'hidden' }),
zIndex: 20,
left: hostTreeLayoutWidth,
};
}
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
tabId,
hotkeyScheme,
@@ -39,6 +48,7 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
// Self-subscribe visibility so switching tabs only re-renders this editor
// instance, not AppView/App.
const isVisible = useIsEditorTabActive(tabId);
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const handleContentChange = useCallback(
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
@@ -70,6 +80,10 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
}
}, [tabId, t]);
const handleRequestClose = useCallback(() => {
onRequestClose(tabId);
}, [onRequestClose, tabId]);
// Tab has been closed — render nothing (parent should remove this instance,
// but guard here in case of a transient render before unmount).
if (!tab) return null;
@@ -87,18 +101,17 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
// all fill their flex-1 parent via `absolute inset-0`. Match that here so
// an inactive editor tab doesn't collapse to zero height in normal flow,
// and an active one fills the viewport instead of stacking beneath others.
// z-index high enough to stay above the TerminalLayer's inner `z-10` panels
// (TerminalLayer root is visibility:hidden when editor tabs are active, but
// its children's stacking contexts can still overlap without an explicit z.)
// z-index high enough to stay above the terminal workspace while leaving
// room for the shared host sidebar when it is open.
<div
style={{ display: isVisible ? undefined : 'none', zIndex: 20 }}
className="absolute inset-0 min-h-0 flex flex-col bg-background"
style={getTextEditorTabShellStyle(isVisible, hostTreeLayoutWidth)}
className="absolute top-0 right-0 bottom-0 min-h-0 flex flex-col bg-background"
>
<TextEditorPane
chrome="tab"
fileName={`${tab.fileName}${isDirty ? ' *' : ''}`}
subtitle={subtitle}
onRequestClose={() => onRequestClose(tabId)}
onRequestClose={handleRequestClose}
content={tab.content}
languageId={tab.languageId}
wordWrap={tab.wordWrap}

View File

@@ -1,4 +1,4 @@
import { FileSymlink, Folder, FolderOpen, Monitor, Server } from 'lucide-react';
import { Copy, FileSymlink, Folder, FolderOpen, Monitor, Pencil, Server } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -8,6 +8,8 @@ import { ContextMenuContent, ContextMenuItem } from '../ui/context-menu';
export interface HostTreeHostContextMenuHandlers {
onConnect: (host: Host) => void;
onRenameHost?: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onDeleteHost: (host: Host) => void;
}
@@ -17,6 +19,8 @@ export const HostTreeHostContextMenuContent: React.FC<
> = ({
host,
onConnect,
onRenameHost,
onDuplicateHost,
onCopyCredentials,
onDeleteHost,
}) => {
@@ -28,6 +32,14 @@ export const HostTreeHostContextMenuContent: React.FC<
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
{onRenameHost && (
<ContextMenuItem onClick={() => onRenameHost(host)}>
<Pencil className="mr-2 h-4 w-4" /> {t('common.rename')}
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>

View File

@@ -43,11 +43,23 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
return (
<input
ref={inputRef}
data-inline-group-edit="true"
value={value}
draggable={false}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onBlur={() => {
queueMicrotask(() => {
commit();
});
}}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onDragStart={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
@@ -60,7 +72,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
}
}}
className={cn(
'min-w-0 flex-1 truncate rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
className,
)}
style={style}

View File

@@ -8,6 +8,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Identity } from '../../types';
import { Button } from '../ui/button';
import { VaultEntityIcon, vaultIdentityIconClass } from '../vault/VaultEntityIcon';
interface IdentityCardProps {
identity: Identity;
@@ -52,9 +53,10 @@ export const IdentityCard: React.FC<IdentityCardProps> = ({
onClick={onClick}
>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-green-500/15 text-green-500 flex items-center justify-center">
<User size={18} />
</div>
<VaultEntityIcon
className={vaultIdentityIconClass}
icon={<User size={18} />}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold truncate">{identity.label || 'Add a label...'}</div>
<div className="text-[11px] font-mono text-muted-foreground truncate">

View File

@@ -73,7 +73,7 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
return (
<>
<div className="flex items-center gap-3 mb-4">
<div className="h-10 w-10 rounded-lg bg-green-500/15 text-green-500 flex items-center justify-center">
<div className="h-10 w-10 rounded-lg bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 flex items-center justify-center">
<User size={20} />
</div>
<Input

View File

@@ -8,6 +8,11 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import {
VaultEntityIcon,
vaultCertificateIconClass,
vaultKeyIconClass,
} from '../vault/VaultEntityIcon';
import {
ContextMenu,
ContextMenuContent,
@@ -55,14 +60,12 @@ export const KeyCard: React.FC<KeyCardProps> = ({
onClick={onClick}
>
<div className="flex items-center gap-3 h-full">
<div className={cn(
"h-11 w-11 rounded-xl flex items-center justify-center",
keyItem.certificate
? "bg-emerald-500/15 text-emerald-500"
: "bg-primary/15 text-primary"
)}>
{getKeyIcon(keyItem)}
</div>
<VaultEntityIcon
className={keyItem.certificate
? vaultCertificateIconClass
: vaultKeyIconClass}
icon={getKeyIcon(keyItem)}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold truncate">{keyItem.label}</div>
<div className="text-[11px] font-mono text-muted-foreground truncate">

View File

@@ -10,6 +10,7 @@ import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuSeparator,ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { vaultEntityIconClass } from '../vault/VaultEntityIcon';
import { getStatusColor,getTypeColor } from './utils';
export type ViewMode = 'grid' | 'list';
@@ -60,7 +61,8 @@ export const RuleCard: React.FC<RuleCardProps> = ({
>
<div className="flex items-center gap-3 h-full">
<div className={cn(
"h-11 w-11 rounded-xl flex items-center justify-center text-sm font-bold transition-colors",
vaultEntityIconClass,
"text-sm font-bold transition-colors",
getTypeColor(rule.type, isActive)
)}>
{rule.type[0].toUpperCase()}

Some files were not shown because too many files have changed in this diff Show More