Compare commits

..
155 changed files with 285 additions and 7011 deletions

No files matched your search

-67
View File
@@ -7,7 +7,6 @@ import {
handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
import type { Host } from '../types';
const baseHost: Host = {
@@ -399,69 +398,3 @@ test('keyboard-interactive submit keeps the prompt and password unchanged when d
assert.equal(hosts[0].password, 'old-password');
assert.deepEqual(errors, ['Request not found']);
});
test('local host without explicit shell resolves the configured Settings shell', () => {
const connectedHosts: Host[] = [];
const localHost: Host = { ...baseHost, id: 'local-1', hostname: 'localhost', protocol: 'local' };
const discoveredShells = [
{ id: 'wsl-ubuntu', name: 'Ubuntu (WSL)', command: 'wsl.exe', args: ['-d', 'Ubuntu'] },
];
handleConnectToHostImpl(
() => ({
addConnectionLog: () => {},
connectToHost: (host: Host) => {
connectedHosts.push(host);
return 'local-session';
},
discoveredShells,
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: '' }),
resolveShellSetting,
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
terminalSettings: { localShell: 'wsl-ubuntu' },
}) as never,
localHost,
);
assert.equal(connectedHosts.length, 1);
assert.equal(connectedHosts[0].localShell, 'wsl.exe');
assert.deepEqual(connectedHosts[0].localShellArgs, ['-d', 'Ubuntu']);
assert.equal(connectedHosts[0].localShellName, 'Ubuntu (WSL)');
});
test('local host with an explicit shell keeps it (no Settings fallback)', () => {
const connectedHosts: Host[] = [];
const localHost: Host = {
...baseHost,
id: 'local-2',
hostname: 'localhost',
protocol: 'local',
localShell: 'pwsh',
};
handleConnectToHostImpl(
() => ({
addConnectionLog: () => {},
connectToHost: (host: Host) => {
connectedHosts.push(host);
return 'local-session-2';
},
discoveredShells: [],
identities: [],
keys: [],
resolveEffectiveHost: (host: Host) => host,
resolveHostAuth: () => ({ username: '' }),
resolveShellSetting,
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
terminalSettings: { localShell: 'wsl-ubuntu' },
}) as never,
localHost,
);
assert.equal(connectedHosts.length, 1);
assert.equal(connectedHosts[0].localShell, 'pwsh');
assert.equal(connectedHosts[0].localShellName, undefined);
});
+5 -21
View File
@@ -4,7 +4,6 @@ import type { Host, HostProtocol, TerminalSession } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
@@ -205,10 +204,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
// Local-shell vault hosts carry no SSH credentials, so log the system
// user like local terminals do (see handleConnectToHostImpl) instead of
// 'root' or an inherited group SSH username.
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
username: resolvedAuth.username || 'root',
protocol,
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
@@ -1108,15 +1104,11 @@ export function handleCreateLocalTerminalImpl(
}
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
const { addConnectionLog, connectToHost, discoveredShells, identities, keys, resolveEffectiveHost, resolveHostAuth, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
{
const { username, hostname: localHost } = systemInfoRef.current;
const effectiveHost = resolveHostDefaultLocalShell(resolveEffectiveHost(host), {
discoveredShells,
resolveShellSetting,
terminalSettings,
});
const effectiveHost = resolveEffectiveHost(host);
// Handle serial hosts separately
if (effectiveHost.protocol === 'serial') {
@@ -1146,10 +1138,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
// Local-shell vault hosts carry no SSH credentials, so log the system
// user like local terminals do (see handleCreateLocalTerminalImpl)
// instead of 'root' or an inherited group SSH username.
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
username: resolvedAuth.username || 'root',
protocol,
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
@@ -1197,13 +1186,8 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
// Gates the protocol picker (legacy name kept for its existing wiring).
// Only prompt when Telnet is available but isn't the host's default protocol;
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
// SSH-only guard, matching VaultView's handleHostConnect: local-shell and
// serial hosts must always launch their own transport, never offer
// SSH/Telnet pickers driven by an inherited group Telnet flag. Hosts
// without an explicit protocol are legacy SSH entries.
const effective = resolveEffectiveHost(host);
const effectiveProtocol = effective.protocol ?? 'ssh';
return effectiveProtocol === 'ssh' && Boolean(effective.telnetEnabled);
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
}
}
+3 -14
View File
@@ -34,17 +34,6 @@ test('serial targets use the serial editor', () => {
);
});
test('local targets use the local shell editor', () => {
assert.equal(
resolveWorkSurfaceHostEditorKind({
mode: 'edit',
openedHost: host({ protocol: 'local' }),
requestId: 3,
}),
'local',
);
});
test('new and ssh targets use the standard editor', () => {
assert.equal(
resolveWorkSurfaceHostEditorKind({ mode: 'new', defaultGroup: null, requestId: 1 }),
@@ -82,8 +71,8 @@ test('editor overlay leaves the work surface interactive outside the panel', ()
assert.match(source, /pointer-events-none absolute inset-0 z-40/);
assert.match(source, /\[&>\*\]:pointer-events-auto/);
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 3);
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 3);
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 2);
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
});
test('editor host panels share vault resize width persistence', () => {
@@ -92,7 +81,7 @@ test('editor host panels share vault resize width persistence', () => {
assert.match(source, /STORAGE_KEY_VAULT_HOST_PANEL_WIDTH/);
assert.match(source, /resizable:\s*true/);
assert.match(source, /\{\.\.\.hostPanelResizeProps\}/);
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 3);
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 2);
});
test('editor stays mounted while another app surface is active', () => {
+3 -17
View File
@@ -5,7 +5,6 @@ import type { EditorTabChrome } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useI18n } from '../i18n/I18nProvider';
import HostDetailsPanel from '../../components/HostDetailsPanel';
import LocalShellHostDetailsPanel from '../../components/LocalShellHostDetailsPanel';
import SerialHostDetailsPanel from '../../components/SerialHostDetailsPanel';
import { PortalContainerProvider } from '../../components/ui/portal-container';
import { resolveGroupDefaults } from '../../domain/groupConfig';
@@ -23,16 +22,14 @@ import type {
} from '../../types';
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
export type WorkSurfaceHostEditorKind = 'standard' | 'serial' | 'local';
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
export function resolveWorkSurfaceHostEditorKind(
target: WorkSurfaceHostEditorTarget,
): WorkSurfaceHostEditorKind {
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
? 'serial'
: target.mode === 'edit' && target.openedHost.protocol === 'local'
? 'local'
: 'standard';
: 'standard';
}
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
@@ -163,18 +160,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
style={getAppHostEditorLayerStyle(surfaceVisible)}
>
<PortalContainerProvider container={portalContainer}>
{target.mode === 'edit' && target.openedHost.protocol === 'local' ? (
<LocalShellHostDetailsPanel
key={editorKey}
initialData={target.openedHost}
groups={groups}
onSave={onSave}
onCancel={onCancel}
layout="overlay"
className="pointer-events-auto"
{...hostPanelResizeProps}
/>
) : target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
<SerialHostDetailsPanel
key={editorKey}
initialData={target.openedHost}
@@ -1,35 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { createHostTerminalSession } from '../state/sessionFactories';
// Exercise the production application callback, including its async command
// resolution boundary, before the state hook snapshots the shell type.
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
const callback = source.match(/const handleRunSnippet = useCallback\(([\s\S]*?)\n {2}\);/)?.[1];
assert.ok(callback);
const compiled = ts.transpile(`const run = useCallback(${callback});`, { target: ts.ScriptTarget.ES2022 });
for (const script of [false, true]) {
test(`saved default local shell is resolved before ${script ? 'script' : 'snippet'} sessions are created`, async () => {
let session: ReturnType<typeof createHostTerminalSession> | undefined;
const run = new Function('useCallback', 'isScriptSnippet', 'runSnippet', 'resolveSnippetCommand', 'resolveEffectiveHost', `${compiled} return run;`)(
(value: unknown) => value,
() => script,
(_snippet: unknown, hosts: ReturnType<typeof createLocalShellHost>[]) => {
session = createHostTerminalSession('snippet-local', hosts[0]);
},
async () => 'echo hello',
(host: ReturnType<typeof createLocalShellHost>) => resolveHostDefaultLocalShell(host, {
terminalSettings: { localShell: 'cmd' },
resolveShellSetting: () => ({ command: 'cmd.exe' }),
}),
);
await run({ id: 'snippet-1' }, [createLocalShellHost({ os: 'windows' })]);
assert.equal(session?.protocol, 'local');
assert.equal(session?.localShell, 'cmd.exe');
assert.equal(session?.shellType, 'cmd');
});
}
+19 -31
View File
@@ -38,7 +38,6 @@ import {
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
import { useI18n } from '../i18n/I18nProvider';
import { matchesKeyBinding } from '../../domain/models';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { resolveGroupDefaults, applyGroupDefaults } from '../../domain/groupConfig';
import { upsertKnownHost } from '../../domain/knownHosts';
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
@@ -271,6 +270,22 @@ export function AppSideEffects() {
renameSessionInline,
} = sessionState;
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
// AppSideEffects only needs host/session maps for glue handlers and effects.
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
if (targetHosts.length === 0) return;
if (isScriptSnippet(snippet)) {
runSnippet(snippet, targetHosts);
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
runSnippet(snippet, targetHosts, command);
},
[runSnippet],
);
// Presence-only list: keystrokes must not rebuild App domains / TopTabs structure.
const editorTabs = useEditorTabChromeList();
@@ -1352,32 +1367,8 @@ export function AppSideEffects() {
{ validProxyProfileIds: proxyProfileIdSet },
)
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
const effectiveHost = materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
// Local-shell vault hosts saved with "System default shell" carry no
// localShell; resolve the Settings → Terminal shell here at the shared
// effective-host boundary so every launch path (connect, tray/dock open,
// workspace creation/append, tunnels) uses the configured shell instead
// of silently falling back to the backend OS default shell.
return resolveHostDefaultLocalShell(effectiveHost, {
discoveredShells,
resolveShellSetting,
terminalSettings,
});
}, [groupConfigs, proxyProfileIdSet, proxyProfiles, discoveredShells, terminalSettings]);
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
if (targetHosts.length === 0) return;
if (isScriptSnippet(snippet)) {
runSnippet(snippet, targetHosts.map(resolveEffectiveHost));
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
runSnippet(snippet, targetHosts.map(resolveEffectiveHost), command);
},
[runSnippet, resolveEffectiveHost],
);
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
@@ -1408,17 +1399,14 @@ export function AppSideEffects() {
return handleConnectToHostImpl(() => ({
addConnectionLog,
connectToHost,
discoveredShells,
host,
identities,
keys,
resolveEffectiveHost: effectiveHostResolver,
resolveHostAuth,
resolveShellSetting,
systemInfoRef,
terminalSettings,
}), host, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
+2 -6
View File
@@ -37,7 +37,6 @@ import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
import { useDefaultLocalShellContext } from '../../lib/useDiscoveredShells';
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
@@ -261,7 +260,7 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
@@ -355,16 +354,14 @@ function AppViewInner({ domains }: AppViewProps) {
[proxyProfiles],
);
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
validProxyProfileIds,
defaultLocalShell,
})
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
const host = hosts.find((entry) => entry.id === hostId);
@@ -749,7 +746,6 @@ function AppViewInner({ domains }: AppViewProps) {
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleTerminalCommandExecuted}
onDeleteShellHistoryEntry={removeShellHistoryEntry}
onClearCommandHistory={clearAllCommandHistory}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
-1
View File
@@ -36,7 +36,6 @@ export function VaultHost() {
return {
addShellHistoryEntry: actions?.addShellHistoryEntry,
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
clearAllCommandHistory: actions?.clearAllCommandHistory,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
@@ -72,7 +72,6 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
clearAllCommandHistory,
} = vault;
useLayoutEffect(() => {
@@ -145,14 +144,12 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
clearAllCommandHistory,
});
return () => {
registerVaultSnapshotActions(null);
};
}, [
addShellHistoryEntry,
clearAllCommandHistory,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
@@ -1,64 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import en from '../locales/en.ts';
import ru from '../locales/ru.ts';
import es from '../locales/es.ts';
import zhCN from '../locales/zh-CN.ts';
import zhTW from '../locales/zh-TW.ts';
const keys = [
'cloudSync.provider.disconnect',
'cloudSync.provider.disconnect.confirmTitle',
'cloudSync.provider.disconnect.confirmMessage',
'cloudSync.provider.disconnect.confirmAction',
'cloudSync.provider.disconnect.toastTitle',
'cloudSync.provider.disconnect.cloudRemains',
'cloudSync.provider.disconnect.failed',
] as const;
test('disconnect copy exists in every bundled locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
for (const key of keys) {
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
}
}
});
test('disconnect confirm copy states that saved provider credentials are removed', () => {
// The confirm dialog must explain the data consequence of disconnecting:
// locally saved endpoint + credentials removal, not just "stops syncing".
const expected = [
[en, 'will be removed'],
[es, 'eliminarán'],
[ru, 'будут удалены'],
[zhCN, '删除'],
[zhTW, '刪除'],
] as const;
for (const [messages, fragment] of expected) {
assert.ok(
(messages['cloudSync.provider.disconnect.confirmMessage'] as string).includes(fragment),
`confirmMessage should mention credential removal: ${fragment}`,
);
}
});
test('disconnect toast copy explains the cloud copy is retained', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
assert.ok(
(messages['cloudSync.provider.disconnect.cloudRemains'] as string).includes(
'{name}',
),
`${locale} cloudRemains hint should reference the provider name`,
);
}
});
test('disconnect copy distinguishes login cleanup from retained local data', () => {
assert.match(en['cloudSync.provider.disconnect.confirmMessage'], /backup history and sync encryption password stay here/);
assert.doesNotMatch(en['cloudSync.provider.disconnect.cloudRemains'], /change.*password/);
for (const messages of [en, ru, es, zhCN, zhTW]) {
assert.ok(messages['cloudSync.provider.disconnect.failed'].includes('{name}'));
}
});
-4
View File
@@ -522,8 +522,6 @@ export const enAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.undoLastTurn': 'Undo last turn',
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
@@ -535,8 +533,6 @@ export const enAiMessages: Messages = {
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.forkFromHere': 'Fork from here',
'ai.chat.forkCreated': 'Branched into a new chat — the original stays in history.',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts': 'Hosts',
-4
View File
@@ -1123,7 +1123,6 @@ Highlight the focused split pane:
'vault.tree.expandAll': 'Expand All',
'vault.tree.collapseAll': 'Collapse All',
'vault.hosts.newHost': 'New Host',
'vault.hosts.newLocalShell': 'Local Shell (CMD)',
'vault.hosts.newGroup': 'New Group',
'vault.hosts.import': 'Import',
'vault.hosts.export': 'Export',
@@ -1149,9 +1148,6 @@ Highlight the focused split pane:
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.connectSelected': 'Connect ({count})',
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
'vault.hosts.moveSelected': 'Move to group ({count})',
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts',
+1 -8
View File
@@ -44,10 +44,6 @@ export const enTerminalMessages: Messages = {
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'history.action.delete': 'Delete from history',
'history.action.clearAll': 'Clear all history',
'history.clearAll.dialog.title': 'Clear all command history?',
'history.clearAll.dialog.desc': 'This permanently deletes all locally recorded command history and autocomplete suggestions. This cannot be undone.',
'history.clearAll.dialog.confirm': 'Clear all',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
@@ -314,11 +310,8 @@ export const enTerminalMessages: Messages = {
'cloudSync.provider.connecting': 'Connecting...',
'cloudSync.provider.disconnect': 'Disconnect',
'cloudSync.provider.disconnect.confirmTitle': 'Disconnect "{name}"?',
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Its saved address and login credentials will be removed. Your local vault, backup history and sync encryption password stay here. If system credential storage is unavailable, plugin credentials may need manual removal.',
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Your local vault stays on this computer.',
'cloudSync.provider.disconnect.confirmAction': 'Disconnect',
'cloudSync.provider.disconnect.toastTitle': 'Disconnected',
'cloudSync.provider.disconnect.cloudRemains': 'Encrypted sync copies may still exist in your {name} storage. To remove them, delete the sync files there. Disconnecting does not clear this device for handover.',
'cloudSync.provider.disconnect.failed': 'Could not finish disconnecting from {name}. Saved connection details may remain. Please try again.',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
'cloudSync.provider.s3': 'S3 Compatible',
-15
View File
@@ -1018,21 +1018,6 @@ export const enVaultMessages: Messages = {
'hostDetails.addTag': 'Add a tag...',
'hostDetails.createTag': 'Create tag',
'hostDetails.createGroup': 'Create group',
// Local shell (CMD) host entries
'localShell.subtitle': 'Local Shell',
'localShell.panel.title': 'New Local Shell',
'localShell.panel.title.edit': 'Edit Local Shell',
'localShell.field.label': 'Name',
'localShell.field.labelPlaceholder': 'e.g. Project A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': 'System default shell',
'localShell.field.shellDefaultDesc': 'Leave empty to use the default local shell configured in Settings → Terminal.',
'localShell.field.shellEmpty': 'No shells found',
'localShell.field.shellResolved': 'Runs {command}',
'localShell.field.startDir': 'Start directory',
'localShell.field.startDirPlaceholder': 'Inherit global start directory (home if unset)',
'localShell.saveAndConnect': 'Save & Connect',
'hostDetails.plugin.title': 'Plugin connection',
'hostDetails.plugin.provider.placeholder': 'Select a connection provider',
'hostDetails.plugin.provider.empty': 'No connection providers are installed',
-4
View File
@@ -513,8 +513,6 @@ export const esAiMessages: Messages = {
'ai.chat.hoursAgo': 'hace {n} h',
'ai.chat.daysAgo': 'hace {n} d',
'ai.chat.newChat': 'Nuevo chat',
'ai.chat.undoLastTurn': 'Deshacer el último turno',
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
'ai.chat.allSessions': 'Todas las sesiones',
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
'ai.chat.jumpNav': 'Ir al mensaje',
@@ -526,8 +524,6 @@ export const esAiMessages: Messages = {
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
'ai.chat.noSessions': 'No hay sesiones anteriores',
'ai.chat.forkFromHere': 'Bifurcar desde aquí',
'ai.chat.forkCreated': 'Conversación bifurcada en un chat nuevo; el original permanece en el historial.',
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.menuHosts': 'Hosts',
-4
View File
@@ -1081,7 +1081,6 @@ Resalta el panel dividido enfocado:
'vault.tree.expandAll': 'Expandir todo',
'vault.tree.collapseAll': 'Contraer todo',
'vault.hosts.newHost': 'Nuevo host',
'vault.hosts.newLocalShell': 'Shell local (CMD)',
'vault.hosts.newGroup': 'Nuevo grupo',
'vault.hosts.import': 'Importar',
'vault.hosts.export': 'Exportar',
@@ -1107,9 +1106,6 @@ Resalta el panel dividido enfocado:
'vault.hosts.deleteMultiple.success': 'Se eliminaron {count} hosts',
'vault.hosts.connectSelected': 'Conectar ({count})',
'vault.hosts.connectMultiple.success': 'Conectando {count} hosts',
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
'vault.hosts.empty.title': 'Configura tus hosts',
+1 -8
View File
@@ -44,10 +44,6 @@ export const esTerminalMessages: Messages = {
'history.action.run': 'Ejecutar en la terminal',
'history.action.saveAsSnippet': 'Guardar como snippet',
'history.action.delete': 'Eliminar del historial',
'history.action.clearAll': 'Borrar todo el historial',
'history.clearAll.dialog.title': '¿Borrar todo el historial de comandos?',
'history.clearAll.dialog.desc': 'Esto elimina de forma permanente todo el historial de comandos local y las sugerencias de autocompletado. Esta acción no se puede deshacer.',
'history.clearAll.dialog.confirm': 'Borrar todo',
'terminal.toolbar.library': 'Biblioteca',
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
@@ -313,11 +309,8 @@ export const esTerminalMessages: Messages = {
'cloudSync.provider.connecting': 'Conectando...',
'cloudSync.provider.disconnect': 'Desconectar',
'cloudSync.provider.disconnect.confirmTitle': '¿Desconectar "{name}"?',
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. Se eliminarán su dirección y credenciales de acceso guardadas. La bóveda local, las copias de seguridad y la contraseña de cifrado de sincronización permanecerán aquí. Si el almacén de credenciales del sistema no está disponible, puede ser necesario eliminar manualmente las credenciales de los complementos.',
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. La bóveda local permanecerá en este equipo.',
'cloudSync.provider.disconnect.confirmAction': 'Desconectar',
'cloudSync.provider.disconnect.toastTitle': 'Desconectado',
'cloudSync.provider.disconnect.cloudRemains': 'Las copias de sincronización cifradas pueden seguir en tu almacenamiento de {name}. Para eliminarlas, borra los archivos de sincronización allí. Desconectar no prepara este dispositivo para entregarlo a otra persona.',
'cloudSync.provider.disconnect.failed': 'No se pudo completar la desconexión de {name}. Los datos de conexión guardados podrían permanecer. Inténtalo de nuevo.',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
'cloudSync.provider.s3': 'Compatible con S3',
-15
View File
@@ -1008,21 +1008,6 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
'localShell.panel.title.edit': 'Editar shell local',
'localShell.field.label': 'Nombre',
'localShell.field.labelPlaceholder': 'p. ej. Proyecto A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': 'Shell predeterminada del sistema',
'localShell.field.shellDefaultDesc': 'Si se deja vacío, se usa la shell local predeterminada configurada en Ajustes → Terminal.',
'localShell.field.shellEmpty': 'No se encontraron shells',
'localShell.field.shellResolved': 'Ejecuta {command}',
'localShell.field.startDir': 'Directorio de inicio',
'localShell.field.startDirPlaceholder': 'Heredar directorio inicial global (personal si no se configura)',
'localShell.saveAndConnect': 'Guardar y conectar',
'hostDetails.plugin.title': 'Conexión de plugin',
'hostDetails.plugin.provider.placeholder': 'Selecciona un proveedor de conexión',
'hostDetails.plugin.provider.empty': 'No hay proveedores de conexión instalados',
-4
View File
@@ -469,8 +469,6 @@ export const ruAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.undoLastTurn': 'Отменить последний ход',
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
@@ -482,8 +480,6 @@ export const ruAiMessages: Messages = {
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.forkFromHere': 'Ветвление отсюда',
'ai.chat.forkCreated': 'Разговор перенесён в новый чат; оригинал остаётся в истории.',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.menuHosts': 'Хосты',
+1 -8
View File
@@ -65,10 +65,6 @@ export const ruTerminalMessages: Messages = {
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'history.action.delete': 'Удалить из истории',
'history.action.clearAll': 'Очистить всю историю',
'history.clearAll.dialog.title': 'Очистить всю историю команд?',
'history.clearAll.dialog.desc': 'Будет безвозвратно удалена вся локальная история команд и подсказки автодополнения. Это действие нельзя отменить.',
'history.clearAll.dialog.confirm': 'Очистить всё',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
@@ -332,11 +328,8 @@ export const ruTerminalMessages: Messages = {
'cloudSync.provider.connecting': 'Подключение...',
'cloudSync.provider.disconnect': 'Отключить',
'cloudSync.provider.disconnect.confirmTitle': 'Отключить "{name}"?',
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Сохранённый адрес и данные для входа будут удалены. Локальное хранилище, резервные копии и пароль шифрования синхронизации останутся здесь. Если системное хранилище учётных данных недоступно, данные для входа плагинов может потребоваться удалить вручную.',
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Локальное хранилище останется на этом компьютере.',
'cloudSync.provider.disconnect.confirmAction': 'Отключить',
'cloudSync.provider.disconnect.toastTitle': 'Отключено',
'cloudSync.provider.disconnect.cloudRemains': 'Зашифрованные копии синхронизации могут остаться в хранилище {name}. Чтобы удалить их, удалите там файлы синхронизации. Отключение не очищает устройство для передачи другому человеку.',
'cloudSync.provider.disconnect.failed': 'Не удалось завершить отключение от {name}. Сохранённые данные подключения могут остаться. Повторите попытку.',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
'cloudSync.provider.s3': 'Совместимое с S3',
-19
View File
@@ -102,7 +102,6 @@ export const ruVaultMessages: Messages = {
'vault.tree.expandAll': 'Развернуть все',
'vault.tree.collapseAll': 'Свернуть все',
'vault.hosts.newHost': 'Новый хост',
'vault.hosts.newLocalShell': 'Локальная оболочка (CMD)',
'vault.hosts.newGroup': 'Новая группа',
'vault.hosts.import': 'Импорт',
'vault.hosts.export': 'Экспорт',
@@ -128,9 +127,6 @@ export const ruVaultMessages: Messages = {
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
'vault.hosts.connectSelected': 'Подключить ({count})',
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
'vault.hosts.empty.title': 'Настройте свои хосты',
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
@@ -1055,21 +1051,6 @@ export const ruVaultMessages: Messages = {
'hostDetails.addTag': 'Добавить тег...',
'hostDetails.createTag': 'Создать тег',
'hostDetails.createGroup': 'Создать группу',
// Записи локальной оболочки (CMD) в хранилище
'localShell.subtitle': 'Локальная оболочка',
'localShell.panel.title': 'Новая локальная оболочка',
'localShell.panel.title.edit': 'Изменить локальную оболочку',
'localShell.field.label': 'Название',
'localShell.field.labelPlaceholder': 'напр. Проект A · CMD',
'localShell.field.shell': 'Оболочка',
'localShell.field.shellDefault': 'Системная оболочка по умолчанию',
'localShell.field.shellDefaultDesc': 'Если оставить пустым, используется локальная оболочка по умолчанию из Настройки → Терминал.',
'localShell.field.shellEmpty': 'Оболочки не найдены',
'localShell.field.shellResolved': 'Запускает {command}',
'localShell.field.startDir': 'Начальный каталог',
'localShell.field.startDirPlaceholder': 'Использовать общий начальный каталог (иначе домашний)',
'localShell.saveAndConnect': 'Сохранить и открыть',
'hostDetails.plugin.title': 'Подключение плагина',
'hostDetails.plugin.provider.placeholder': 'Выберите провайдер подключения',
'hostDetails.plugin.provider.empty': 'Провайдеры подключения не установлены',
@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const UNDO_KEYS = [
'ai.chat.undoLastTurn',
'ai.chat.undoLastTurnNotice',
] as const;
test('Undo-last-turn UI is localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = UNDO_KEYS.filter(key => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
}
});
-4
View File
@@ -522,8 +522,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.undoLastTurn': '撤销上一轮',
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
@@ -535,8 +533,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.forkFromHere': '从此处分叉',
'ai.chat.forkCreated': '已分叉到新对话,原对话保留在历史中。',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
'ai.chat.menuHosts': '主机',
-4
View File
@@ -740,7 +740,6 @@ export const zhCNCoreMessages: Messages = {
'vault.tree.expandAll': '展开全部',
'vault.tree.collapseAll': '折叠全部',
'vault.hosts.newHost': '新建主机',
'vault.hosts.newLocalShell': '本地终端 (CMD)',
'vault.hosts.newGroup': '新建分组',
'vault.hosts.import': '导入',
'vault.hosts.export': '导出',
@@ -766,9 +765,6 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.connectSelected': '连接 ({count})',
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
'vault.hosts.moveSelected': '移动到分组 ({count})',
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机',
@@ -63,10 +63,6 @@ export const zhCNTerminalMessages: Messages = {
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
'history.action.delete': '从历史记录中删除',
'history.action.clearAll': '清空全部历史',
'history.clearAll.dialog.title': '清空全部命令历史?',
'history.clearAll.dialog.desc': '这将永久删除所有本地记录的命令历史及自动补全建议,且无法撤销。',
'history.clearAll.dialog.confirm': '全部清空',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
+1 -19
View File
@@ -390,21 +390,6 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.addTag': '添加标签...',
'hostDetails.createTag': '创建标签',
'hostDetails.createGroup': '创建分组',
// 本地终端(CMD)主机条目
'localShell.subtitle': '本地终端',
'localShell.panel.title': '新建本地终端',
'localShell.panel.title.edit': '编辑本地终端',
'localShell.field.label': '名称',
'localShell.field.labelPlaceholder': '例如:项目 A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': '系统默认 Shell',
'localShell.field.shellDefaultDesc': '留空则使用设置 → 终端中配置的默认本地 Shell。',
'localShell.field.shellEmpty': '未发现可用 Shell',
'localShell.field.shellResolved': '启动 {command}',
'localShell.field.startDir': '启动目录',
'localShell.field.startDirPlaceholder': '继承全局起始目录(未设置时为主目录)',
'localShell.saveAndConnect': '保存并打开',
'hostDetails.plugin.title': '插件连接',
'hostDetails.plugin.provider.placeholder': '选择连接提供程序',
'hostDetails.plugin.provider.empty': '未安装连接提供程序',
@@ -717,11 +702,8 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.provider.connecting': '连接中...',
'cloudSync.provider.disconnect': '断开连接',
'cloudSync.provider.disconnect.confirmTitle': '断开 “{name}”?',
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步,并删除本机保存的该服务商地址与登录凭据。本地主机库、备份历史和同步加密密码仍会保留。若系统凭据存储不可用,插件凭据可能需要手动删除。',
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步。本地保险库仍保留在这台电脑上。',
'cloudSync.provider.disconnect.confirmAction': '断开连接',
'cloudSync.provider.disconnect.toastTitle': '已断开连接',
'cloudSync.provider.disconnect.cloudRemains': '云端的加密同步副本可能仍保存在 {name} 存储中。如需清除,请到该服务商删除同步文件。断开连接不代表设备已完成交接清理。',
'cloudSync.provider.disconnect.failed': '未能完成与 {name} 的断开连接,保存的连接资料可能仍在,请重试。',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': '连接到自建 WebDAV 端点',
'cloudSync.provider.s3': 'S3 兼容存储',
-4
View File
@@ -512,8 +512,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小時前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新對話',
'ai.chat.undoLastTurn': '復原上一輪',
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
@@ -525,8 +523,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.forkFromHere': '從此處分叉',
'ai.chat.forkCreated': '已分叉到新對話,原對話保留在歷史中。',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
'ai.chat.menuHosts': '主機',
-4
View File
@@ -742,7 +742,6 @@ export const zhTWCoreMessages: Messages = {
'vault.tree.expandAll': '展開全部',
'vault.tree.collapseAll': '摺疊全部',
'vault.hosts.newHost': '新增主機',
'vault.hosts.newLocalShell': '本地終端 (CMD)',
'vault.hosts.newGroup': '新增群組',
'vault.hosts.import': '匯入',
'vault.hosts.export': '匯出',
@@ -768,9 +767,6 @@ export const zhTWCoreMessages: Messages = {
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
'vault.hosts.connectSelected': '連線 ({count})',
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
'vault.hosts.moveSelected': '移動到群組 ({count})',
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
'vault.hosts.empty.title': '設定你的主機',
@@ -63,10 +63,6 @@ export const zhTWTerminalMessages: Messages = {
'history.action.run': '在終端執行',
'history.action.saveAsSnippet': '儲存為程式碼片段',
'history.action.delete': '從歷史記錄中刪除',
'history.action.clearAll': '清空全部歷史',
'history.clearAll.dialog.title': '清空全部指令歷史?',
'history.clearAll.dialog.desc': '這將永久刪除所有本機記錄的指令歷史及自動補全建議,且無法復原。',
'history.clearAll.dialog.confirm': '全部清空',
// SFTP File Opener
'sftp.context.copyPath': '複製檔案路徑',
'sftp.context.openWith': '開啟方式...',
+1 -19
View File
@@ -390,21 +390,6 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.addTag': '新增標籤...',
'hostDetails.createTag': '建立標籤',
'hostDetails.createGroup': '建立群組',
// 本地終端(CMD)主機項目
'localShell.subtitle': '本地終端',
'localShell.panel.title': '新增本地終端',
'localShell.panel.title.edit': '編輯本地終端',
'localShell.field.label': '名稱',
'localShell.field.labelPlaceholder': '例如:專案 A · CMD',
'localShell.field.shell': 'Shell',
'localShell.field.shellDefault': '系統預設 Shell',
'localShell.field.shellDefaultDesc': '留空則使用設定 → 終端中設定的預設本地 Shell。',
'localShell.field.shellEmpty': '未找到可用的 Shell',
'localShell.field.shellResolved': '啟動 {command}',
'localShell.field.startDir': '啟動目錄',
'localShell.field.startDirPlaceholder': '繼承全域起始目錄(未設定時為主目錄)',
'localShell.saveAndConnect': '儲存並開啟',
'hostDetails.plugin.title': '外掛程式連線',
'hostDetails.plugin.provider.placeholder': '選擇連線提供者',
'hostDetails.plugin.provider.empty': '尚未安裝連線提供者',
@@ -717,11 +702,8 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.provider.connecting': '連線中...',
'cloudSync.provider.disconnect': '中斷連線',
'cloudSync.provider.disconnect.confirmTitle': '中斷「{name}」?',
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步,並刪除本機保存的該服務商位址與登入憑證。本機主機庫、備份歷史和同步加密密碼仍會保留。若系統憑證儲存無法使用,外掛憑證可能需要手動刪除。',
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步。本機保險庫仍保留在這台電腦上。',
'cloudSync.provider.disconnect.confirmAction': '中斷連線',
'cloudSync.provider.disconnect.toastTitle': '已中斷連線',
'cloudSync.provider.disconnect.cloudRemains': '雲端的加密同步副本可能仍保存在 {name} 儲存空間中。如需清除,請到該服務商刪除同步檔案。中斷連線不代表裝置已完成交接清理。',
'cloudSync.provider.disconnect.failed': '未能完成與 {name} 的中斷連線,保存的連線資料可能仍在,請重試。',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': '連線到自建 WebDAV 端點',
'cloudSync.provider.s3': 'S3 相容儲存',
@@ -1,328 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
STORAGE_KEY_COMMAND_HISTORY,
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
} from "../../infrastructure/config/storageKeys";
type StorageEventLike = { key: string | null; newValue: string | null; oldValue: string | null };
type StorageListener = (event: StorageEventLike) => void;
type DeferredRead = { resolve: (value: string) => void };
const COMMAND_HISTORY_STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
function installLocalStorage(): Map<string, string> {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
},
configurable: true,
});
return store;
}
/**
* The store registers its cross-window `storage` listener at module load, so
* the DOM globals must exist before the dynamic imports below.
*/
function installWindow(): (key: string, newValue: string | null, oldValue?: string | null) => void {
const listeners = new Set<StorageListener>();
Object.defineProperty(globalThis, "window", {
value: {
addEventListener: (type: string, listener: StorageListener) => {
if (type === "storage") listeners.add(listener);
},
removeEventListener: (type: string, listener: StorageListener) => {
if (type === "storage") listeners.delete(listener);
},
netcatty: undefined as unknown,
},
configurable: true,
});
return (key: string, newValue: string | null, oldValue: string | null = null) => {
for (const listener of listeners) listener({ key, newValue, oldValue });
};
}
async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!condition()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
const storage = installLocalStorage();
const dispatchStorageEvent = installWindow();
const {
clearHistory,
flushCommandHistoryStore,
getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared,
markCommandHistoryCleared,
queryHistory,
recordCommand,
subscribeCommandHistoryCleared,
} = await import("./commandHistoryStore.ts");
const { seedLocalShellHistoryFromHistfiles } = await import(
"../../components/terminal/autocomplete/localShellHistorySeed.ts"
);
function readPersistedCommands(): string[] {
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as { entries: Array<{ command: string }> };
return parsed.entries.map((entry) => entry.command);
}
function readPersistedGeneration(): number {
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
if (!raw) return 0;
const parsed = JSON.parse(raw) as { clearGeneration?: number };
return typeof parsed.clearGeneration === "number" ? parsed.clearGeneration : 0;
}
test("a fresh popup keeps compose recall on an ordinary peer write after an earlier clear", async () => {
const baseline = JSON.stringify({ entries: [], version: 1, clearGeneration: 3 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, baseline);
const composeBar = await import("./composeBarHistoryStore.ts");
composeBar.recordComposeBarHistory("fresh-popup", "echo popup-current-command");
// No autocomplete query has loaded this renderer's command-history store.
const recorded = JSON.stringify({
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
version: 1, clearGeneration: 3,
});
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded, baseline);
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), ["echo popup-current-command"]);
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 4 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared, recorded);
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), []);
});
test("a stale pending write cannot overwrite a peer clear before its storage event arrives", () => {
clearHistory();
recordCommand("old-command", "host-1");
flushCommandHistoryStore();
recordCommand("pending-before-clear", "host-1");
// localStorage is already updated in the peer, but this renderer has not yet
// processed its queued storage event when the pending save runs.
const generation = readPersistedGeneration() + 1;
storage.set(COMMAND_HISTORY_STORAGE_KEY, JSON.stringify({
entries: [], version: 1, clearGeneration: generation,
}));
flushCommandHistoryStore();
assert.deepEqual(readPersistedCommands(), []);
assert.equal(readPersistedGeneration(), generation);
assert.deepEqual(queryHistory("old", { hostId: "host-1" }), []);
recordCommand("new-after-clear", "host-1");
flushCommandHistoryStore();
assert.deepEqual(readPersistedCommands(), ["new-after-clear"]);
});
test("an ordinary peer write does not cancel a locally pending command", async () => {
clearHistory();
recordCommand("local-pending-command", "host-1");
const before = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
const ordinary = JSON.stringify({
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
version: 1, clearGeneration: getCommandHistoryClearGeneration(),
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, ordinary);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, ordinary, before);
await new Promise((resolve) => setTimeout(resolve, 600));
assert.ok(readPersistedCommands().includes("local-pending-command"));
});
test("an external clear invalidates the cached history and cannot be resurrected", () => {
storage.clear();
clearHistory();
recordCommand("stale-command --flag", "host-1");
flushCommandHistoryStore();
assert.ok(queryHistory("stale", { hostId: "host-1" }).length > 0);
// Simulate another renderer (main window or a detached terminal popup)
// clearing the shared localStorage entry.
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 1 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
// The stale module-local cache must be dropped immediately.
assert.equal(queryHistory("stale", { hostId: "host-1" }).length, 0);
// A later recordCommand must persist only the new command, never the stale
// snapshot it still held in memory.
recordCommand("fresh-command", "host-1");
flushCommandHistoryStore();
assert.deepEqual(readPersistedCommands(), ["fresh-command"]);
});
test("clearHistory persists synchronously and cancels a pending debounced write", async () => {
storage.clear();
clearHistory();
recordCommand("pending-command", "host-1");
// No flush: the debounced write is still scheduled.
clearHistory();
assert.deepEqual(readPersistedCommands(), []);
// Wait past the debounce window to prove the cancelled write cannot restore
// the entries.
await new Promise((resolve) => setTimeout(resolve, 600));
assert.deepEqual(readPersistedCommands(), []);
});
test("a local clear bumps the generation and notifies subscribers", () => {
storage.clear();
clearHistory();
const before = getCommandHistoryClearGeneration();
let notified = 0;
const unsubscribe = subscribeCommandHistoryCleared(() => {
notified += 1;
});
recordCommand("notify-local", "host-1");
clearHistory();
assert.equal(getCommandHistoryClearGeneration(), before + 1);
assert.equal(readPersistedGeneration(), before + 1);
assert.equal(notified, 1);
unsubscribe();
clearHistory();
assert.equal(notified, 1);
});
test("an external clear notifies subscribers, while an external record does not", () => {
storage.clear();
clearHistory();
const baseline = getCommandHistoryClearGeneration();
let notified = 0;
const unsubscribe = subscribeCommandHistoryCleared(() => {
notified += 1;
});
// A normal record from another renderer keeps the generation: no clear event.
const recorded = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline });
storage.set(COMMAND_HISTORY_STORAGE_KEY, recorded);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded);
assert.equal(notified, 0);
// A clear from another renderer bumps the generation: subscribers fire.
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline + 1 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
assert.equal(notified, 1);
assert.equal(getCommandHistoryClearGeneration(), baseline + 1);
unsubscribe();
});
test("a histfile import that started before a clear is discarded", async () => {
storage.clear();
clearHistory();
const hostId = `local-seed-clear-${Date.now()}`;
const deferredReads: DeferredRead[] = [];
(window as unknown as { netcatty?: unknown }).netcatty = {
getHomeDir: async () => "/home/demo",
readLocalFile: () =>
new Promise<string>((resolve) => {
deferredReads.push({ resolve });
}),
};
const seedPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
// Let the seeder resolve getHomeDir and reach the histfile reads.
await waitFor(() => deferredReads.length === 4);
// The user clears history while the bridge reads are still pending.
clearHistory();
for (const deferred of deferredReads) {
deferred.resolve(": 1700000000:0;echo seeded\n");
}
assert.equal(await seedPromise, 0);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
// The host is marked seeded so a later open cannot re-import the deleted
// commands.
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
});
test("markCommandHistoryCleared persists a tombstone for histfile seeding", () => {
storage.clear();
assert.equal(hasCommandHistoryBeenCleared(), false);
markCommandHistoryCleared();
assert.equal(hasCommandHistoryBeenCleared(), true);
assert.equal(storage.get(STORAGE_KEY_COMMAND_HISTORY_CLEARED), "true");
});
test("a first-time histfile import is skipped once history has been cleared", async () => {
storage.clear();
clearHistory();
markCommandHistoryCleared();
const hostId = `local-seed-tombstone-${Date.now()}`;
(window as unknown as { netcatty?: unknown }).netcatty = {
getHomeDir: async () => "/home/demo",
readLocalFile: async () => ": 1700000000:0;echo resurrected\n",
};
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
});
test("an external clear also drops in-memory compose-bar recall", async () => {
const composeBar = await import("./composeBarHistoryStore.ts");
storage.clear();
clearHistory();
composeBar.pruneComposeBarHistory([]);
composeBar.recordComposeBarHistory("session-1", "echo compose");
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), ["echo compose"]);
let notified = 0;
const unsubscribe = composeBar.subscribeComposeBarHistoryCleared(() => {
notified += 1;
});
// Simulate another renderer clearing command history. The compose-bar store
// follows the same clear event, so a detached popup drops its recall too.
const cleared = JSON.stringify({
entries: [],
version: 1,
clearGeneration: getCommandHistoryClearGeneration() + 1,
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), []);
assert.equal(notified, 1);
unsubscribe();
});
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory, clearComposeBarHistory, subscribeComposeBarHistoryCleared } from './composeBarHistoryStore';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
test('compose history is isolated per terminal and removed when its session closes', () => {
pruneComposeBarHistory([]);
@@ -40,24 +40,3 @@ test('failed submissions unblock later successes in submission order', async ()
assert.deepEqual(getComposeBarHistory('a'), ['accepted']);
pruneComposeBarHistory([]);
});
test('clearComposeBarHistory drops every session and notifies live compose bars', () => {
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'echo a');
recordComposeBarHistory('b', 'echo b');
let notified = 0;
const unsubscribe = subscribeComposeBarHistoryCleared(() => {
notified += 1;
});
clearComposeBarHistory();
assert.deepEqual(getComposeBarHistory('a'), []);
assert.deepEqual(getComposeBarHistory('b'), []);
assert.equal(notified, 1);
unsubscribe();
clearComposeBarHistory();
assert.equal(notified, 1);
});
@@ -1,14 +1,10 @@
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
import { subscribeCommandHistoryCleared } from './commandHistoryStore';
// Session-only memory: survives closing the bar and moving a terminal into a
// workspace, but is discarded when that terminal session is removed.
type PendingSend = { settled: boolean; command?: string };
const histories = new Map<string, { entries: readonly string[]; pending: PendingSend[] }>();
type Listener = () => void;
const clearedListeners = new Set<Listener>();
export function getComposeBarHistory(sessionId: string): readonly string[] {
return histories.get(sessionId)?.entries ?? [];
}
@@ -48,36 +44,3 @@ export function pruneComposeBarHistory(sessionIds: readonly string[]): void {
if (!active.has(id)) histories.delete(id);
}
}
/**
* Subscribe to full compose-bar history clears. A live compose bar uses this to
* drop the history walk it cached, so ArrowUp cannot recall deleted commands.
*/
export function subscribeComposeBarHistoryCleared(listener: Listener): () => void {
clearedListeners.add(listener);
return () => {
clearedListeners.delete(listener);
};
}
/**
* Clear the in-memory compose-bar history for every session.
*
* This store is session-only (never persisted), so the "clear all command
* history" action must clear it explicitly. Otherwise a command sent through
* the compose bar stays recallable, and resending it would re-record it into
* shell history and autocomplete after the user deleted it.
*/
export function clearComposeBarHistory(): void {
histories.clear();
// Always notify: a live compose bar can still hold a cached walk even when
// the map is already empty (for example after its session was pruned).
for (const listener of [...clearedListeners]) listener();
}
// The compose bar holds the most recent commands sent through it, so clearing
// command history must drop it too. Subscribing to the command-history clear
// event covers every renderer: a detached terminal popup renders its own
// TerminalComposeBar and learns about a main-window clear through the shared
// clear generation, while the main window is notified synchronously.
subscribeCommandHistoryCleared(clearComposeBarHistory);
@@ -1,244 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createHostTerminalSession } from "./sessionFactories.ts";
import { createLocalTerminalSession } from "./sessionFactories.ts";
import { resolveHostDefaultLocalShell } from "../../domain/localShellHost";
import { resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
import type { Host } from "../../domain/models";
const localVaultHost = (overrides: Partial<Host> = {}): Host => ({
id: "local-vault-1",
label: "Project A CMD",
hostname: "localhost",
username: "",
group: "Projects/A",
tags: ["local"],
os: "linux",
protocol: "local",
createdAt: 0,
...overrides,
});
test("createHostTerminalSession copies local shell fields for local vault hosts", () => {
const host = localVaultHost({
localShell: "wsl-ubuntu",
localShellArgs: ["--cd", "/work"],
localShellName: "Ubuntu (WSL)",
localShellIcon: "ubuntu",
localStartDir: "/work",
});
const session = createHostTerminalSession("session-1", host);
assert.equal(session.protocol, "local");
assert.equal(session.hostId, "local-vault-1");
assert.equal(session.localShell, "wsl-ubuntu");
assert.deepEqual(session.localShellArgs, ["--cd", "/work"]);
assert.equal(session.localShellName, "Ubuntu (WSL)");
assert.equal(session.localShellIcon, "ubuntu");
assert.equal(session.localStartDir, "/work");
});
test("createHostTerminalSession does not add local shell fields to SSH hosts", () => {
const host: Host = {
...localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 }),
} as Host;
const session = createHostTerminalSession("session-2", host);
assert.equal(session.protocol, "ssh");
assert.equal(session.localShell, undefined);
assert.equal(session.localShellName, undefined);
assert.equal(session.shellType, undefined);
});
test("createHostTerminalSession classifies the shell for CMD/PowerShell local vault hosts", () => {
const cmd = createHostTerminalSession("session-cmd", localVaultHost({ localShell: "cmd", os: "windows" }));
assert.equal(cmd.shellType, "cmd");
const pwsh = createHostTerminalSession("session-pwsh", localVaultHost({ localShell: "pwsh", os: "windows" }));
assert.equal(pwsh.shellType, "powershell");
// Discovered shell IDs persisted by the local-shell editor are not
// executable basenames, but they still resolve to POSIX shells at spawn
// time — WSL distros, Git Bash, and Cygwin all run a POSIX shell.
const wslId = createHostTerminalSession("session-wsl-id", localVaultHost({ localShell: "wsl-ubuntu", os: "windows" }));
assert.equal(wslId.shellType, "posix");
const gitBash = createHostTerminalSession("session-git-bash", localVaultHost({ localShell: "git-bash", os: "windows" }));
assert.equal(gitBash.shellType, "posix");
const cygwin = createHostTerminalSession("session-cygwin", localVaultHost({ localShell: "cygwin", os: "windows" }));
assert.equal(cygwin.shellType, "posix");
// Unix discovery emits disambiguated path-slug IDs when multiple
// installations share a basename (e.g. /bin/bash vs
// /opt/homebrew/bin/bash); the backend launches the resolved command, so
// the session must classify as that command's shell type, not "unknown".
const slugBash = createHostTerminalSession("session-slug-bash", localVaultHost({ localShell: "bin-bash", os: "macos" }));
assert.equal(slugBash.shellType, "posix");
const slugHomebrewBash = createHostTerminalSession(
"session-slug-homebrew",
localVaultHost({ localShell: "opt-homebrew-bin-bash", os: "macos" }),
);
assert.equal(slugHomebrewBash.shellType, "posix");
const customPowershellPath = createHostTerminalSession(
"session-pwsh-path",
localVaultHost({ localShell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", os: "windows" }),
);
assert.equal(customPowershellPath.shellType, "powershell");
// Default-shell hosts (empty localShell) launch the machine's OS default
// shell (the backend's own detection), so classification must use the
// runtime platform — not the saved host.os, which may describe a machine
// the host was synced/imported from. Node 22's navigator.userAgent is
// always "Node.js/<version>", so this test environment always classifies
// as POSIX regardless of the CI runner's real OS.
const windowsSyncedDefault = createHostTerminalSession("session-win-default", localVaultHost({ os: "windows" }));
assert.equal(windowsSyncedDefault.shellType, "posix");
const posixDefault = createHostTerminalSession("session-posix-default", localVaultHost({ os: "linux" }));
assert.equal(posixDefault.shellType, "posix");
const unknownCustom = createHostTerminalSession(
"session-unknown",
localVaultHost({ localShell: "/usr/local/bin/exotic-shell", os: "linux" }),
);
assert.equal(unknownCustom.shellType, "unknown");
});
test("createLocalTerminalSession is unaffected by vault local hosts", () => {
const session = createLocalTerminalSession("session-3", { shellName: "CMD" });
assert.equal(session.hostId, "local-terminal");
assert.equal(session.localShellName, "CMD");
});
test("createHostTerminalSession snapshots an explicit no-args local shell as an empty array", () => {
// Legacy vault hosts can predate localShellArgs, so the field may be
// absent on the Host; the session snapshot must still distinguish
// "launched with no args" ([]) from "no metadata" (undefined).
const host = localVaultHost({
localShell: "cmd",
localShellName: "Command Prompt",
os: "windows",
});
const session = createHostTerminalSession("session-noargs", host);
assert.ok(Array.isArray(session.localShellArgs), "args snapshot must be present");
assert.deepEqual(session.localShellArgs, []);
});
test("resolveTerminalSessionHost does not merge edited host args into a no-args local snapshot", () => {
const launchTimeHost = localVaultHost({
localShell: "cmd",
localShellName: "Command Prompt",
os: "windows",
});
const session = createHostTerminalSession("session-1", launchTimeHost);
// The saved host was later edited to a shell with args; the snapshot taken
// at launch time must win so the clone relaunches CMD, not WSL.
const editedHost: Host = {
...launchTimeHost,
label: "Edited WSL",
localShell: "wsl-ubuntu",
localShellArgs: ["--cd", "/elsewhere"],
localShellName: "Ubuntu (WSL)",
localStartDir: "/elsewhere",
};
const resolved = resolveTerminalSessionHost({
session,
hosts: [editedHost],
groupConfigs: [],
proxyProfiles: [],
localOs: "windows",
});
assert.equal(resolved.localShell, "cmd");
assert.deepEqual(resolved.localShellArgs, []);
assert.equal(resolved.localShellName, "Command Prompt");
});
test("createHostTerminalSession snapshots an inherited start directory for Copy Tab", () => {
// The saved host has no directory of its own, so the starter launches in
// Settings > Terminal's localStartDir. That effective directory has to be
// recorded now; overlay falls back to the host or the current setting when
// the session field is missing.
const saved = localVaultHost({
localShell: "bash",
localShellName: "Bash",
localStartDir: "",
});
const effective = resolveHostDefaultLocalShell(saved, {
terminalSettings: { localStartDir: "/home/alice/global" },
});
const session = createHostTerminalSession("session-inherit", effective);
assert.equal(session.localStartDir, "/home/alice/global");
const changedSettings = {
terminalSettings: { localShell: "fish", localStartDir: "/home/alice/changed" },
};
const afterSettingsChange = resolveTerminalSessionHost({
session,
hosts: [saved],
groupConfigs: [],
proxyProfiles: [],
localOs: "linux",
defaultLocalShell: changedSettings,
});
assert.equal(afterSettingsChange.localStartDir, "/home/alice/global");
const afterHostEdit = resolveTerminalSessionHost({
session,
hosts: [{ ...saved, localStartDir: "/home/alice/edited" }],
groupConfigs: [],
proxyProfiles: [],
localOs: "linux",
defaultLocalShell: changedSettings,
});
assert.equal(afterHostEdit.localStartDir, "/home/alice/global");
});
test("createHostTerminalSession snapshots an inherited start directory for a default shell host", () => {
const saved = localVaultHost();
const effective = resolveHostDefaultLocalShell(saved, {
terminalSettings: { localShell: "bash", localStartDir: "/home/alice/global" },
resolveShellSetting: () => ({ command: "/bin/bash", args: ["-l"] }),
});
const session = createHostTerminalSession("session-default-shell", effective);
assert.equal(session.localShell, "/bin/bash");
assert.deepEqual(session.localShellArgs, ["-l"]);
assert.equal(session.localStartDir, "/home/alice/global");
});
test("resolveHostDefaultLocalShell keeps an explicit start directory and ignores non-local hosts", () => {
const explicit = localVaultHost({ localShell: "bash", localStartDir: "/work" });
const resolvedExplicit = resolveHostDefaultLocalShell(explicit, {
terminalSettings: { localStartDir: "/home/alice/global" },
});
assert.equal(resolvedExplicit.localStartDir, "/work");
assert.equal(createHostTerminalSession("session-explicit", resolvedExplicit).localStartDir, "/work");
const ssh = localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 });
const resolvedSsh = resolveHostDefaultLocalShell(ssh, {
terminalSettings: { localStartDir: "/home/alice/global" },
});
assert.equal(resolvedSsh.localStartDir, undefined);
assert.equal(resolvedSsh, ssh);
});
test("saved local sessions use the ordinary local username marker without changing vault or SSH credentials", () => {
const saved = localVaultHost({ username: "" });
const ordinary = createLocalTerminalSession("ordinary-local");
const opened = createHostTerminalSession("saved-local", saved);
assert.equal(opened.username, ordinary.username);
assert.equal(opened.username, "local");
assert.equal(saved.username, "");
const remote = { ...saved, protocol: "ssh" as const, username: "remote-user" };
assert.equal(createHostTerminalSession("ssh-session", remote).username, "remote-user");
const resolved = resolveTerminalSessionHost({ session: opened, hosts: [saved], groupConfigs: [], proxyProfiles: [], localOs: "linux" });
assert.equal(resolved.username, ordinary.username, "terminal-run scripts receive the same local marker");
});
+1 -14
View File
@@ -4,7 +4,7 @@ import test from "node:test";
import type { Host } from "../../domain/models";
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import {
createHostTerminalSession,
createSerialTerminalSession,
@@ -171,16 +171,3 @@ test("host session factories snapshot plugin connection configuration", () => {
assert.deepEqual(workspace.pluginConnection, pluginConnection);
assert.notEqual(workspace.pluginConnection, pluginConnection);
});
test("local sessions preserve intentionally absent display metadata when the saved shell changes", () => {
const original = host({ protocol: "local", hostname: "localhost", localShell: "/bin/bash" });
const source = createHostTerminalSession("original-bash", original);
const edited = { ...original, localShell: "/bin/zsh", localShellName: "Zsh", localShellIcon: "zsh" };
const copied = resolveTerminalSessionHost({ session: source, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: "macos" });
assert.equal(copied.localShell, "/bin/bash");
assert.equal(copied.localShellName, "");
assert.equal(copied.localShellIcon, "");
const named = createHostTerminalSession("named-zsh", edited);
assert.equal(named.localShellName, "Zsh");
assert.equal(named.localShellIcon, "zsh");
});
-47
View File
@@ -1,5 +1,4 @@
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
import { classifyLocalShellType } from "../../lib/localShell";
export interface LocalTerminalOptions {
shellType?: TerminalSession["shellType"];
@@ -19,28 +18,6 @@ export interface LocalTerminalOptions {
*/
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
/**
* [] captures the absence of custom arguments instead of inheriting them
* from a later host edit. The starter still applies backend defaults to []
* and undefined alike. Share the array to preserve pane equality checks.
*/
const EMPTY_LOCAL_SHELL_ARGS: string[] = [];
/**
* Platform string describing the machine that is launching the session.
*
* Vault local hosts can be synced/imported from another OS, so their saved
* `host.os` describes the source machine. When the host has no explicit
* shell (`localShell` empty), `startLocalSession` spawns the current
* machine's OS default shell, so classification must fall back to the
* runtime platform — not the persisted host OS — or a Windows host opened
* on Linux would be tagged "powershell" while Bash actually runs.
*/
const runtimePlatformLike = (): string | undefined => {
if (typeof navigator === "undefined") return undefined;
return navigator.userAgent || navigator.platform || undefined;
};
export const createLocalTerminalSession = (
sessionId: string,
options?: LocalTerminalOptions,
@@ -136,30 +113,6 @@ export const createHostTerminalSession = (
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
// Local-shell vault hosts carry the per-host shell selection into the
// session (the bridge resolves discovered shell ids at spawn time).
// shellType matches ordinary local-terminal creation so captured-CWD
// restore can reject POSIX injection into CMD/PowerShell sessions.
...(host.protocol === "local" ? {
username: "local",
// With an explicit shell the basename classifies it on any OS, so the
// saved host.os stays the fallback; a default-shell host launches the
// current machine's OS default, so classify against the runtime.
shellType: classifyLocalShellType(
host.localShell,
host.localShell?.trim() ? host.os : runtimePlatformLike(),
),
localShell: host.localShell ?? "",
// Preserve field presence: an edited host must not contribute its args
// to a session that was launched with an explicit shell and no args.
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
localShellName: host.localShellName ?? "",
localShellIcon: host.localShellIcon ?? "",
// Effective directory from resolveHostDefaultLocalShell, including a
// Settings directory the saved host inherits. Copy Tab overlays this
// snapshot, so a later settings or vault edit cannot move the relaunch.
localStartDir: host.localStartDir || "~",
} : {}),
...(host.ephemeral ? { ephemeralHost: true } : {}),
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
};
@@ -224,31 +224,3 @@ test("split and copy session clones preserve isolated plugin connection snapshot
assert.equal(split.reuseConnectionFromSessionId, undefined);
assert.equal(copied.reuseConnectionFromSessionId, undefined);
});
for (const clone of [createSplitTerminalSessionClone, createCopiedTerminalSessionClone]) {
test(`${clone.name} keeps the type of an explicit local shell despite global settings`, () => {
for (const [localShell, shellType] of [["cmd.exe", "cmd"], ["pwsh.exe", "powershell"]] as const) {
const source = session({ protocol: "local", localShell, shellType });
const copied = clone(source, { id: "clone", localShellType: "posix" });
assert.equal(copied.localShell, localShell);
assert.equal(copied.shellType, shellType);
assert.equal(clone({ ...source, shellType: undefined },
{ id: "legacy", localShellType: "posix" }).shellType, shellType);
}
const implicit = clone(session({ protocol: "local", shellType: "cmd" }),
{ id: "clone-default", localShellType: "posix" });
assert.equal(implicit.shellType, "posix", "implicit shells still follow global settings");
});
}
test("copied backend-default local snapshot retains its shell semantics after global settings change", () => {
const source = session({ protocol: "local", localShell: "", localShellArgs: [], shellType: "powershell", localStartDir: "~" });
for (const clone of [
createCopiedTerminalSessionClone(source, { id: "copy-default-snapshot", localShellType: "posix" }),
createSplitTerminalSessionClone(source, { id: "split-default-snapshot", localShellType: "posix" }),
]) {
assert.equal(clone.localShell, "");
assert.equal(clone.shellType, "powershell");
assert.equal(clone.localStartDir, "~");
}
});
+1 -7
View File
@@ -1,5 +1,4 @@
import type { TerminalSession } from "../../domain/models";
import { classifyLocalShellType } from "../../lib/localShell";
type SftpReuseCandidate = Pick<
TerminalSession,
@@ -45,12 +44,7 @@ function getClonedShellType(
session: TerminalSession,
localShellType?: TerminalSession["shellType"],
): TerminalSession["shellType"] {
if (session.protocol !== "local") return session.shellType;
// Clones retain an explicit per-session executable, so its shell type must
// survive a different global default (e.g. saved CMD with WSL in Settings).
return session.localShell !== undefined
? (session.shellType ?? classifyLocalShellType(session.localShell))
: localShellType;
return session.protocol === "local" ? localShellType : session.shellType;
}
function createTerminalSessionClone(
-134
View File
@@ -1,134 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Fork source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function ForkHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(ForkHarness, { capture }));
});
return { dom, container, root, capture };
}
test('fork retains the completed prefix, replay metadata, and source-owned outputs', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({ chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'saved output' });
runtime.getSessionStateStore().updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'running-command' },
JSON.stringify({ jobId: 'source-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.externalSessionId = 'original-provider-thread';
source.messages[1].content = `Preview handleId=${handle.id}`;
source.messages[1].providerContinuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
const { dom, container, root, capture } = await setupAiState([source]);
let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1', { type: 'terminal', targetId: 'reconnected' }); });
assert.ok(branch);
assert.notEqual(branch!.id, source.id);
assert.equal(branch!.externalSessionId, undefined);
assert.deepEqual(branch!.messages[1].providerContinuation, source.messages[1].providerContinuation);
assert.match(branch!.messages[1].content, /Preview/);
assert.doesNotMatch(branch!.messages[1].content, /handleId=/);
assert.deepEqual(branch!.scope, { type: 'terminal', targetId: 'reconnected', hostIds: undefined });
assert.equal(capture.activeSessionIdMap['terminal:reconnected'], branch!.id);
assert.deepEqual({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }, source);
assert.equal(outputStore.read({ handleId: handle.id }, branch!.id), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'saved output');
assert.deepEqual(runtime.getSessionStateStore().get(branch!.id).activeJobs, {});
assert.ok(runtime.getSessionStateStore().get(source.id).activeJobs['source-job']);
runtime.getSessionStateStore().clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount()); container.remove(); dom.window.close();
});
test('fork rejects an incomplete boundary without adding a chat', async () => {
const source = structuredClone(SOURCE_SESSION);
source.messages[1].executionStatus = 'failed';
const { dom, container, root, capture } = await setupAiState([source]);
let result: unknown;
await act(async () => { result = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1'); });
assert.equal(result, null);
assert.equal(capture.sessions.length, 1);
await act(async () => root.unmount()); container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`fork preserves the original on reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({ ...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index }));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
assert.ok(branch);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => root.unmount()); container.remove(); dom.window.close();
const reloaded = await setupAiState(saved);
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
await act(async () => reloaded.root.unmount()); reloaded.container.remove(); reloaded.dom.window.close();
});
}
-95
View File
@@ -55,7 +55,6 @@ import {
import { convertFilesToUploads } from './useFileUpload';
import { removeProviderReferences } from './aiProviderCleanup';
import { publishAISessionsSnapshot } from './aiSessionsStore';
import { planSessionFork } from '../../domain/aiSessionFork';
import {
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
@@ -87,11 +86,6 @@ import {
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
import {
buildUndoLastTurnBranch,
type UndoLastTurnRestoredDraft,
} from '../../domain/aiUndoLastTurn';
function providerPatchIsNoop(
current: ProviderConfig,
@@ -681,57 +675,6 @@ export function useAIState() {
};
}, [persistSessions]);
// "Fork from here": copy the conversation up to a completed assistant
// response into a new session and make it the scope's active chat. The
// original session stays untouched in history. Pure boundary validation and
// plan building live in domain/aiSessionFork; the fork has no original
// external session id or pending-turn state. Per-message replay metadata is
// preserved; output handles and running jobs remain owned by the source.
// Null is returned when the boundary is not forkable.
// `viewingScope` is the panel scope the fork was requested from; the branch
// is created there so the fork is active and visible in the very scope that
// is viewing it, even when the source session carries a stale scope (e.g. a
// terminal chat resumed in a reconnected terminal or a merged workspace).
const forkSessionFromMessage = useCallback(async (
sessionId: string,
messageId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
): Promise<AISession | null> => {
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const plan = planSessionFork(source, messageId);
if (!plan.ok) return null;
const now = Date.now();
const fork: AISession = {
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
title: plan.title,
agentId: source.agentId,
scope: {
...source.scope,
// Host membership stays with the source (the messages were produced
// there); the scope the fork belongs to is the viewing scope.
...(viewingScope ? { type: viewingScope.type, targetId: viewingScope.targetId } : {}),
hostIds: source.scope.hostIds ? [...source.scope.hostIds] : undefined,
},
messages: plan.messages,
createdAt: now,
updatedAt: now,
};
if (plan.contextCompaction) {
fork.contextCompaction = plan.contextCompaction;
}
setSessionsRaw(prev => {
// Keep the source alongside its branch within the existing history limit.
const next = [fork, { ...source, updatedAt: now }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${fork.scope.type}:${fork.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, fork.id);
return fork;
}, [persistSessions, setActiveSessionId]);
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
const now = Date.now();
const session: AISession = {
@@ -754,40 +697,6 @@ export function useAIState() {
return session;
}, [defaultAgentId, persistSessions, setActiveSessionId]);
// Non-destructive "undo last turn": branch the session at the boundary
// before the latest user message and keep the original in history intact.
// Returns the new branch id plus the undone user message for the composer.
const undoLastTurnInSession = useCallback(async (
sessionId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
if (!result) return null;
const branched = result.session;
// Like Fork, Undo belongs to the panel viewing the source, which may have
// resumed a chat from a closed terminal or inherited it after a merge.
if (viewingScope) {
branched.scope = { ...branched.scope, ...viewingScope };
}
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, branched.id);
return { sessionId: branched.id, restored: result.restored };
}, [persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupDeletedAIChatSessions([sessionId]);
if (persistTimerRef.current) {
@@ -1366,7 +1275,6 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1379,7 +1287,6 @@ export function useAIState() {
seedWorkspaceActiveSessionFromMembers,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
forkSessionFromMessage,
}), [
providers,
setProviders,
@@ -1428,7 +1335,6 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1441,6 +1347,5 @@ export function useAIState() {
seedWorkspaceActiveSessionFromMembers,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
forkSessionFromMessage,
]);
}
@@ -1,231 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Undo source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function UndoHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(UndoHarness, { capture }));
});
return { dom, container, root, capture };
}
test('undoLastTurnInSession branches and restores the last user turn', async () => {
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
assert.ok(capture.ai);
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
async function runUndo() {
return capture.ai!.undoLastTurnInSession('chat-source');
}
await act(async () => {
result = await runUndo();
});
assert.ok(result);
assert.notEqual(result!.sessionId, 'chat-source');
const sessions = capture.sessions;
const branch = sessions.find((session) => session.id === result!.sessionId);
assert.ok(branch);
// Branch keeps the conversation up to the boundary before the last turn.
assert.deepEqual(
branch!.messages.map((message) => message.content),
['first prompt', 'first answer'],
);
// Original session stays intact (non-destructive).
const original = sessions.find((session) => session.id === 'chat-source');
assert.equal(original?.messages.length, 4);
// Branch becomes the active session for the scope.
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
const attachments = result!.restored.attachments as UploadedFile[];
assert.equal(result!.restored.text, 'bad last prompt');
assert.equal(attachments.length, 1);
assert.equal(attachments[0].filename, 'note.txt');
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
const emptySession: AISession = {
...SOURCE_SESSION,
id: 'chat-empty',
messages: [],
};
const { dom, container, root, capture } = await setupAiState([emptySession]);
assert.ok(capture.ai);
let result: unknown;
await act(async () => {
result = await capture.ai!.undoLastTurnInSession('chat-empty');
});
assert.equal(result, null);
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undo copies chat only, preserving original outputs and job ownership', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
});
const stateStore = runtime.getSessionStateStore();
stateStore.updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'long-running-command' },
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.messages[1].content = `Preview with handleId=${handle.id}`;
const { dom, container, root, capture } = await setupAiState([source]);
const original = JSON.stringify(source);
let result: { sessionId: string } | null = null;
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
assert.ok(result);
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
stateStore.clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
}));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branchId: string | undefined;
await act(async () => {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
});
assert.ok(branchId);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount());
container.remove(); dom.window.close();
// Remount the actual hook from the persisted records, as on app reload.
const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => reloaded.root.unmount());
reloaded.container.remove(); reloaded.dom.window.close();
});
}
for (const viewingScope of [
{ type: 'terminal' as const, targetId: 'terminal-b' },
{ type: 'workspace' as const, targetId: 'workspace-b' },
]) {
test(`undo stays visible when a ${viewingScope.type} views a chat from a closed terminal`, async () => {
const source = {
...structuredClone(SOURCE_SESSION),
scope: { type: 'terminal' as const, targetId: 'terminal-closed', hostIds: ['host-a'] },
};
const { dom, container, root, capture } = await setupAiState([source]);
const sourceBefore = structuredClone(source);
let branchId: string | undefined;
await act(async () => {
capture.ai!.setActiveSessionId('terminal:terminal-b', source.id);
branchId = (await capture.ai!.undoLastTurnInSession(source.id, viewingScope))?.sessionId;
});
assert.ok(branchId);
const branch = capture.sessions.find(item => item.id === branchId)!;
assert.deepEqual(branch.messages, source.messages.slice(0, 2));
assert.deepEqual(branch.scope, { ...source.scope, ...viewingScope });
assert.equal(capture.activeSessionIdMap[`${viewingScope.type}:${viewingScope.targetId}`], branch.id);
// The real history predicate must still include the selected branch after
// the source terminal no longer belongs to the current workspace.
assert.equal(getSessionScopeMatchRank(
branch, viewingScope.type, viewingScope.targetId, ['host-a'],
new Set([branch.id]), new Set(['terminal-b']), new Set([source.id]),
), 3);
const original = capture.sessions.find(item => item.id === source.id)!;
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
}
+1 -33
View File
@@ -3,11 +3,7 @@ import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useComposeBarHistory } from './useComposeBarHistory';
import {
clearComposeBarHistory,
pruneComposeBarHistory,
recordComposeBarHistory,
} from './composeBarHistoryStore';
import { pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
test('changing workspace focus resets navigation without replacing the draft', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -51,31 +47,3 @@ test('an in-progress history walk stays stable when a late send evicts the oldes
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
test('clearing history restores the pre-walk draft instead of leaving the recalled command', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'deleted-command');
const restoredDrafts: string[] = [];
let history!: ReturnType<typeof useComposeBarHistory>;
function Harness() {
history = useComposeBarHistory('a', (draft) => { restoredDrafts.push(draft); });
return null;
}
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
// Enter a history walk: the textarea now shows the recalled command while the
// hook holds the pre-walk draft.
assert.equal(history.navigate('my unsent draft', 'up'), 'deleted-command');
clearComposeBarHistory();
// The pre-walk draft is put back, so the deleted command is no longer in the
// textarea ready for Enter.
assert.deepEqual(restoredDrafts, ['my unsent draft']);
assert.equal(history.navigate('my unsent draft', 'up'), undefined);
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
+2 -24
View File
@@ -1,10 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { useCallback, useLayoutEffect, useRef } from 'react';
import { navigateComposeBarHistory, type ComposeBarHistoryDirection } from '../../domain/composeBarHistory';
import {
getComposeBarHistory,
createComposeBarHistoryRecorder,
subscribeComposeBarHistoryCleared,
} from './composeBarHistoryStore';
import { getComposeBarHistory, createComposeBarHistoryRecorder } from './composeBarHistoryStore';
export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: string) => void) {
const cursor = useRef<{ index: number; draft: string; entries?: readonly string[] }>({ index: Infinity, draft: '' });
@@ -20,24 +16,6 @@ export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: s
reset();
}, [reset, restoreDraft, sessionId]);
/**
* Restore the pre-walk draft and drop the cached history walk.
*
* While the user is in an ArrowUp walk the textarea shows the recalled
* command, so resetting the cursor alone would leave a deleted command ready
* for Enter. Put the saved draft back first, exactly like the session-change
* path above.
*/
const handleHistoryCleared = useCallback(() => {
if (cursor.current.entries) restoreDraft?.(cursor.current.draft);
reset();
}, [reset, restoreDraft]);
useEffect(
() => subscribeComposeBarHistoryCleared(handleHistoryCleared),
[handleHistoryCleared],
);
const prepareRecord = useCallback(() => createComposeBarHistoryRecorder(sessionId), [sessionId]);
const navigate = useCallback((currentValue: string, direction: ComposeBarHistoryDirection) => {
@@ -6,9 +6,7 @@ import type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../..
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
import { resolveShellSetting } from '../../lib/useDiscoveredShells';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const effective = (host: Host) => host;
@@ -233,14 +231,14 @@ for (const [field, value] of [
});
}
test('local default shell, cwd and empty args match the renderer launch contract', async (t) => {
test('local default shell and cwd treat empty values as omitted but explicit empty args stay distinct', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost' });
const api = await harness(original);
t.after(api.close);
await api.update({ host: { ...original, localShell: '', localStartDir: '', label: 'Renamed' } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, localShellArgs: [] } });
assert.equal((await api.find())?.sessionId, 'tab', 'the renderer omits empty args so backend login defaults still apply');
assert.equal(await api.find(), undefined, 'empty args bypass backend login-shell defaults');
});
for (const [label, proxy] of [
@@ -274,110 +272,3 @@ test('Telnet inheriting a proxy profile cannot reuse its previous direct session
await api.update({ host: { ...original } });
assert.equal(await api.find(), undefined);
});
for (const owner of ['saved', 'global'] as const) {
for (const [field, changed] of [
['localShell', '/bin/zsh'], ['localShellArgs', ['--norc']], ['localStartDir', '/project-b'],
] as const) {
test(`local launch snapshot survives ${owner} ${field} edits and unchanged defaults reuse`, async (t) => {
const settings = { localShell: '/bin/bash', localShellArgs: undefined as string[] | undefined, localStartDir: '/project-a' };
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
const context = {
terminalSettings: settings,
resolveShellSetting: (command: string, _shells: DiscoveredShell[], args?: string[]) => ({ command, args }),
};
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('launched', resolveEffectiveHost(original)), status: 'connected' as const };
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id, 'undefined settings args and [] snapshot launch identically');
const edited = owner === 'saved' ? { ...original, [field]: changed } : original;
if (owner === 'global') Object.assign(settings, { [field]: changed });
await api.update({ host: edited });
assert.equal(await api.find(), undefined, 'an edited launch must not return the old process');
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
assert.equal(copy.localShell, '/bin/bash');
assert.deepEqual(copy.localShellArgs, []);
assert.equal(copy.localStartDir, '/project-a');
});
}
}
test('home and backend-default snapshots do not inherit later global or saved settings', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost' });
const settings: { localShell?: string; localStartDir?: string } = {};
const context = { terminalSettings: settings, resolveShellSetting: (command: string) => command ? { command } : null };
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('home-default', resolveEffectiveHost(original)), status: 'connected' as const };
assert.equal(launched.localShell, '');
assert.equal(launched.localStartDir, '~');
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id);
Object.assign(settings, { localShell: '/bin/bash', localStartDir: '/new-global' });
const edited = { ...original, localShell: '/bin/zsh', localStartDir: '/new-saved' };
await api.update({ host: edited });
assert.equal(await api.find(), undefined);
const copied = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
assert.equal(copied.localShell, '', 'empty captured executable still requests backend default');
assert.equal(copied.localStartDir, '~', 'home remains home after a new global directory');
});
test('saved discovery IDs resolve before snapshot while backend default stays independent of discovery', async (t) => {
const saved = host({ protocol: 'local', hostname: 'localhost', localShell: 'bash' });
const context = {
discoveredShells: [{ id: 'bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash', isDefault: true }],
resolveShellSetting: (command: string) => command === 'bash' ? { command: '/bin/bash', args: ['-l'] } : null,
};
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('discovered', resolveEffectiveHost(saved)), status: 'connected' as const };
assert.equal(launched.localShell, '/bin/bash');
const api = await harness(saved, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id);
const defaultLaunch = resolveEffectiveHost({ ...saved, localShell: undefined });
assert.equal(defaultLaunch.localShell, '');
assert.equal(defaultLaunch.localShellArgs, undefined);
});
for (const discovered of [
{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' },
{ id: 'wsl-ubuntu', name: 'Ubuntu', command: 'C:\\Windows\\System32\\wsl.exe', args: ['-d', 'Ubuntu'], icon: 'ubuntu' },
]) {
for (const owner of ['saved', 'global'] as const) {
test(`cold ${owner} ${discovered.id} reuses after discovery without masking edits`, async (t) => {
const settings = { localShell: discovered.id, localStartDir: '/project-a' };
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
const context = { terminalSettings: settings, discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const launched = { ...createHostTerminalSession('cold', resolveEffectiveHost(original)), status: 'connected' as const };
assert.equal(launched.localShell, discovered.id);
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, launched.id);
context.discoveredShells = [discovered];
await api.update({ host: { ...original } });
assert.equal((await api.find())?.sessionId, launched.id, 'discovery resolves the same executable and default arguments');
const edited = { ...original, localShell: discovered.command, localShellArgs: ['--edited'], localStartDir: '/project-b' };
await api.update({ host: edited });
assert.equal(await api.find(), undefined, 'a real path/argument/directory edit still opens a new session');
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
assert.equal(copy.localShell, discovered.id);
assert.deepEqual(copy.localShellArgs, []);
assert.equal(copy.localStartDir, '/project-a');
});
}
}
test('discovery does not replace an explicit executable or its custom arguments with an alias', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/bash', localShellArgs: ['--norc'] });
const context = { discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
const api = await harness(original, { resolveEffectiveHost });
t.after(api.close);
context.discoveredShells = [{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' }];
await api.update({ host: { ...original } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, localShell: 'bin-bash', localShellArgs: undefined } });
assert.equal(await api.find(), undefined, 'selecting discovered login defaults changes the actual launch');
});
+14 -31
View File
@@ -17,11 +17,9 @@ type HostOpenSessionReuseInput = {
resolveEffectiveHost: (host: Host) => Host;
};
type OpeningConnection = { localHost: Host } | { key: Promise<string | undefined> };
/** Retain the opening connection identity, never credentials in session storage. */
export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) {
const openingKeys = useRef(new Map<string, OpeningConnection>());
const openingKeys = useRef(new Map<string, Promise<string | undefined>>());
const connectionKey = useCallback((host: Host) => {
const protocol = resolveEffectiveTerminalProtocol(host);
if (protocol === 'local') {
@@ -29,10 +27,9 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
hostname: host.hostname,
username: '',
localShell: host.localShell || undefined,
// The renderer starter omits both absent and empty args so the
// backend chooses its discovered/login defaults for either form.
localShellArgs: host.localShellArgs?.length ? host.localShellArgs : undefined,
localStartDir: host.localStartDir || '~',
// undefined lets the backend choose login arguments; [] explicitly does not.
localShellArgs: host.localShellArgs,
localStartDir: host.localStartDir || undefined,
};
return buildTransferPoolKey({ hostname: host.hostname, hostId: host.id, protocol, connectionOptions: options });
}
@@ -98,9 +95,8 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
const host = hosts.find((candidate) => candidate.id === session.hostId);
if (!host) continue;
try {
const effectiveHost = resolveEffectiveHost(host);
const openingHost = {
...effectiveHost,
openingKeys.current.set(session.id, connectionKey({
...resolveEffectiveHost(host),
hostname: session.hostname,
username: session.protocol === 'serial' ? resolveEffectiveHost(host).username : session.username,
port: session.port ?? host.port,
@@ -109,18 +105,12 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
etEnabled: session.etEnabled ?? host.etEnabled,
serialConfig: session.serialConfig ?? host.serialConfig,
pluginConnection: session.pluginConnection ?? host.pluginConnection,
localShell: session.localShell ?? effectiveHost.localShell,
localShellArgs: session.localShellArgs ?? effectiveHost.localShellArgs,
localStartDir: session.localStartDir ?? effectiveHost.localStartDir,
};
// Local IDs may only resolve after renderer discovery completes. Keep
// the opening settings so that later discovery can identify the same
// executable without replacing them with edited vault/global settings.
openingKeys.current.set(session.id, resolveEffectiveTerminalProtocol(openingHost) === 'local'
? { localHost: openingHost }
: { key: connectionKey(openingHost).catch(() => undefined) });
localShell: session.localShell ?? host.localShell,
localShellArgs: session.localShellArgs ?? host.localShellArgs,
localStartDir: session.localStartDir ?? host.localStartDir,
}).catch(() => undefined));
} catch {
openingKeys.current.set(session.id, { key: Promise.resolve(undefined) });
openingKeys.current.set(session.id, Promise.resolve(undefined));
}
}
}, [sessions, hosts, connectionKey, resolveEffectiveHost]);
@@ -130,16 +120,9 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
if (!host) return undefined;
try {
const requestedKey = await connectionKey(resolveEffectiveHost(host));
const matches = await Promise.all(sessions.map(async (session) => {
if (session.hostId !== hostId) return undefined;
const opening = openingKeys.current.get(session.id);
const openingKey = opening && ('localHost' in opening
// An explicit empty selector means backend default, not today's
// global setting. Nonempty captured IDs can safely resolve now.
? connectionKey(opening.localHost.localShell ? resolveEffectiveHost(opening.localHost) : opening.localHost)
: opening.key);
return await openingKey === requestedKey ? session : undefined;
}));
const matches = await Promise.all(sessions.map(async (session) => (
session.hostId === hostId && await openingKeys.current.get(session.id) === requestedKey ? session : undefined
)));
return findReusableHostSession(matches.filter((session) => session !== undefined), hostId);
} catch {
// An unresolved/changed credential must never select an old transport.
+11 -6
View File
@@ -1098,12 +1098,17 @@ export const useSessionState = ({
if (targetHosts.length === 0) return;
const resolvedCommand = commandOverride ?? snippet.command;
// Create sessions for each target host. createHostTerminalSession copies
// protocol, shellType, and local-shell metadata, so local vault hosts keep
// their transport info (session consumers treat a missing protocol as SSH).
const newSessions: TerminalSession[] = targetHosts.map(host =>
createHostTerminalSession(crypto.randomUUID(), host),
);
// Create sessions for each target host
const newSessions: TerminalSession[] = targetHosts.map(host => ({
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting' as const,
charset: host.charset,
// workspaceId will be set after workspace is created
}));
const sessionIds = newSessions.map(s => s.id);
-22
View File
@@ -82,10 +82,6 @@ import {
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
import { rebasePendingVaultNotes } from "../../domain/vaultNotesReconciliation";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import {
clearHistory as clearAutocompleteHistory,
markCommandHistoryCleared,
} from "./commandHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import { notify } from "../notification";
import {
@@ -1254,23 +1250,6 @@ export const useVaultState = () => {
publishShellHistorySnapshot([]);
}, []);
/**
* Clear every locally recorded command history: the global shell history, the
* terminal autocomplete history, and the in-memory compose-bar recall.
*
* The autocomplete store persists the clear synchronously and fans it out to
* other renderers through its own `storage` listener. The compose-bar store
* subscribes to that same clear event, so detached terminal popups drop their
* recall too without a separate broadcast.
*/
const clearAllCommandHistory = useCallback(() => {
clearShellHistory();
clearAutocompleteHistory();
// Tombstone so a later first-time histfile import cannot restore the
// commands the user just deleted.
markCommandHistoryCleared();
}, [clearShellHistory]);
const removeShellHistoryEntry = useCallback((entryId: string) => {
setShellHistory((prev) => {
const updated = removeGlobalHistoryEntry(prev, entryId);
@@ -2222,7 +2201,6 @@ export const useVaultState = () => {
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
clearAllCommandHistory,
removeShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
@@ -86,7 +86,6 @@ test('vault actions register and unregister', () => {
updateHostLastConnected: noop,
addShellHistoryEntry: noop,
removeShellHistoryEntry: noop,
clearAllCommandHistory: noop,
});
getVaultSnapshotActions()?.updateHosts([]);
assert.deepEqual(calls, ['hosts']);
-1
View File
@@ -63,7 +63,6 @@ export type VaultSnapshotActions = Pick<
| 'updateHostLastConnected'
| 'addShellHistoryEntry'
| 'removeShellHistoryEntry'
| 'clearAllCommandHistory'
>;
const EMPTY_HOSTS: readonly Host[] = Object.freeze([]);
+1 -28
View File
@@ -1,5 +1,5 @@
import React, { type Dispatch, type SetStateAction } from 'react';
import { History, Plus, Undo2 } from 'lucide-react';
import { History, Plus } from 'lucide-react';
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
import type { Host, VaultNote } from '../types';
import type { UserSkillOption } from './ai/userSkillsState';
@@ -47,8 +47,6 @@ interface AIChatPanelContentProps {
activeSessionId: string | null;
handleSelectSession: (sessionId: string) => void;
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
/** Fork the conversation up to a completed assistant response into a new session. */
onForkFromMessage?: (messageId: string) => void;
messages: ChatMessage[];
isStreaming: boolean;
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
@@ -57,9 +55,6 @@ interface AIChatPanelContentProps {
setInputValue: (value: string) => void;
handleSend: () => void;
handleCompact: () => void;
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
canUndoLastTurn?: boolean;
handleUndoLastTurn?: () => void;
canCompact?: boolean;
handleSteer: () => void;
handleStop: () => void;
@@ -129,7 +124,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
activeSessionId,
handleSelectSession,
handleDeleteSession,
onForkFromMessage,
messages,
isStreaming,
activeCompaction = null,
@@ -138,8 +132,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setInputValue,
handleSend,
handleCompact,
canUndoLastTurn = false,
handleUndoLastTurn,
canCompact = false,
handleSteer,
handleStop,
@@ -240,23 +232,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
{handleUndoLastTurn && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
aria-label={t('ai.chat.undoLastTurn')}
disabled={sending || isStreaming || !canUndoLastTurn}
onClick={handleUndoLastTurn}
>
<Undo2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
</Tooltip>
)}
<ConversationExport
session={activeSession}
onExport={handleExport}
@@ -316,8 +291,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
isStreaming={isStreaming}
activeSessionId={activeSessionId}
activeCompaction={activeCompaction}
contextCompaction={activeSession?.contextCompaction}
onForkFromMessage={onForkFromMessage}
notes={notes}
hosts={hosts}
onOpenVaultNote={onOpenVaultNote}
@@ -120,9 +120,7 @@ test('send preflight aborts after the panel unmounts or the scope changes', () =
test('send preflight locks header agent and new-chat controls', () => {
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
// `sending` also covers an in-flight undo-last-turn so the header controls
// (and a quick send) stay locked while the branch materializes.
assert.match(panel, /sending=\{isSending \|\| isUndoingLastTurn\}/);
assert.match(panel, /sending=\{isSending\}/);
assert.match(content, /disabled=\{sending\}/);
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
+4 -131
View File
@@ -65,7 +65,6 @@ import {
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
@@ -270,7 +269,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
@@ -278,7 +276,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
updateLastMessage,
updateMessageById,
persistContextCompaction,
forkSessionFromMessage,
providers,
activeProviderId,
activeModelId,
@@ -322,11 +319,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [isSending, setIsSending] = useState(false);
// Undo-last-turn is still
// running. Unlike the send flow this is not reflected in `isSending`, so it
// is tracked separately to block sends until the branch settles: a turn
// submitted meanwhile would run against the (soon hidden) source session.
const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
// Tracks SDK runtime model catalogs currently loading (per agent scope):
@@ -829,83 +821,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
&& Boolean(effectiveActiveProvider)
&& Boolean(effectiveActiveModelId.trim());
// Undo last turn: offered only at a safe boundary (never inside a compacted
// prefix, never between an assistant tool call and its result).
const canUndoLastTurn = useMemo(() => Boolean(
activeSession
&& !isStreaming
&& resolveUndoLastTurnBoundary(
activeSession.messages,
activeSession.contextCompaction?.compactedMessageCount ?? 0,
) != null,
), [activeSession, isStreaming]);
const undoInFlightRef = useRef(false);
const handleUndoLastTurn = useCallback(async () => {
const sessionId = activeSessionRef.current?.id;
if (!sessionId || !undoLastTurnInSession) return;
if (isStreaming || isAIChatSessionStreaming(sessionId)) return;
// Serialize: while a branch is still being created, `canUndoLastTurn` stays stale, so a quick second
// click must not start a parallel undo of the same session — that would
// publish duplicate branches and restore the same prompt twice.
if (undoInFlightRef.current) return;
undoInFlightRef.current = true;
setIsUndoingLastTurn(true);
try {
const result = await undoLastTurnInSession(sessionId, {
type: scopeType,
targetId: scopeTargetId,
});
if (!result) return;
ensureScopeDraft(currentAgentId);
// Text typed since the last flush lives only in the live composer buffer
// (pendingComposerTextRef/currentDraftRef), not in the persisted draft;
// merge from the live buffer so it is not lost, then drop the pending
// buffer so the later flushDraftText() cannot overwrite the merged text.
const liveDraft = currentDraftRef.current;
updateScopeDraft(currentAgentId, (draft) => {
const liveText = liveDraft?.text ?? draft.text;
const liveSkillSlugs = liveDraft?.selectedUserSkillSlugs ?? draft.selectedUserSkillSlugs;
return {
...draft,
// Keep anything the user is still typing; the undone prompt goes first.
text: liveText.trim()
? `${result.restored.text}\n\n${liveText}`
: result.restored.text,
attachments: [
...result.restored.attachments,
...(liveDraft?.attachments ?? draft.attachments),
],
// The undone turn was selected with these pills; keep any pills the
// user picked since the send on top of them.
selectedUserSkillSlugs: [
...result.restored.selectedUserSkillSlugs,
...liveSkillSlugs.filter((slug) => !result.restored.selectedUserSkillSlugs.includes(slug)),
],
};
});
discardPendingComposerText();
showScopeSessionView(result.sessionId);
// Tool side effects live outside the conversation and cannot be rolled back.
toast.info(t('ai.chat.undoLastTurnNotice'));
} finally {
undoInFlightRef.current = false;
setIsUndoingLastTurn(false);
}
}, [
discardPendingComposerText,
ensureScopeDraft,
isStreaming,
showScopeSessionView,
t,
undoLastTurnInSession,
updateScopeDraft,
currentAgentId,
scopeType,
scopeTargetId,
]);
const providersRef = useRef(providers);
providersRef.current = providers;
@@ -1262,8 +1177,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending, isUndoingLastTurn],
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
@@ -1399,10 +1314,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}));
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
// While undo-last-turn is creating the branch, the target is still the source; a
// turn submitted now would run in the soon-hidden source session, so
// block it until the branch settles.
if (isUndoingLastTurn) return;
// Note-only sends (empty text + a mentioned note) still need a usable
// session title: fall back to the first mentioned note's title so these
// conversations don't all show up as "Untitled" in history.
@@ -1527,7 +1438,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
@@ -1542,7 +1452,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
@@ -1556,8 +1465,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
// Kept on the user message so undo last turn can restore the pills.
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
@@ -1639,7 +1546,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}, [
validateNoteMentions, loadCodexConfigModel,
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
@@ -1860,36 +1767,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
[setActiveSessionId, showScopeSessionView],
);
// "Fork from here": copy the viewed conversation up to a completed assistant
// response into a new session, switch to it, and confirm via toast. The
// original stays in history. Boundary validation is re-run by the state
// hook; a null result means the fork was refused (already hidden in the UI).
// This panel's scope is passed so the branch is created in the scope viewing
// it, even when the source session's own scope went stale (e.g. a terminal
// chat resumed after reconnecting under a new terminal id).
const handleForkFromMessage = useCallback(async (messageId: string) => {
const sourceSessionId = activeSessionRef.current?.id ?? activeSessionId;
if (!sourceSessionId) return;
// Reject a double-click while the new session is being selected so one
// interaction cannot create two forks.
if (!tryBeginSendForKey(`fork:${sourceSessionId}`)) return;
try {
const fork = await forkSessionFromMessage?.(sourceSessionId, messageId, {
type: scopeType,
targetId: scopeTargetId,
});
if (!fork) return;
applyHistorySessionSelection(fork.id, {
showSessionView: showScopeSessionView,
setActiveSessionId,
closeHistory: () => setShowHistory(false),
});
toast.success(t('ai.chat.forkCreated'));
} finally {
endSendForKey(`fork:${sourceSessionId}`);
}
}, [activeSessionId, forkSessionFromMessage, scopeTargetId, scopeType, setActiveSessionId, showScopeSessionView, t]);
const handleDeleteSession = useCallback(
async (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
@@ -1957,7 +1834,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
>
<AIChatPanelContent
parked={!isVisible}
sending={isSending || isUndoingLastTurn}
sending={isSending}
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
@@ -1976,7 +1853,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
activeSessionId={activeSessionId}
handleSelectSession={handleSelectSession}
handleDeleteSession={handleDeleteSession}
onForkFromMessage={handleForkFromMessage}
messages={messages}
isStreaming={isStreaming}
activeCompaction={
@@ -1988,8 +1864,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setInputValue={setInputValue}
handleSend={handleSend}
handleCompact={handleCompact}
canUndoLastTurn={canUndoLastTurn}
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
handleSteer={handleSteer}
handleStop={handleStop}
canSteer={canSteerCurrentTurn}
@@ -2067,7 +1941,6 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'updateLastMessage',
'updateMessageById',
'persistContextCompaction',
'forkSessionFromMessage',
'providers',
'activeProviderId',
'activeModelId',
-10
View File
@@ -37,11 +37,6 @@ export interface AIChatSidePanelProps {
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
createSession: (scope: AISessionScope, agentId?: string) => AISession;
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
undoLastTurnInSession?: (
sessionId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
@@ -59,11 +54,6 @@ export interface AIChatSidePanelProps {
sessionId: string,
compaction: import('../infrastructure/ai/types').AISessionContextCompaction,
) => void;
forkSessionFromMessage?: (
sessionId: string,
messageId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
) => Promise<AISession | null>;
// Provider config
providers: ProviderConfig[];
activeProviderId: string;
@@ -1,94 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
test('provider disconnect waits for completion, reports failure and permits retry', async () => {
const dom = installDomEnvironment();
const globals = {
localStorage: dom.window.localStorage,
MutationObserver: dom.window.MutationObserver,
NodeFilter: dom.window.NodeFilter,
HTMLInputElement: dom.window.HTMLInputElement,
};
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
const { ProviderCard } = await import('./cloud-sync/CloudSyncControls');
const { toast } = await import('./ui/toast');
const renderer = await createDomRenderer(dom.document);
const notices: string[] = [];
const info = toast.info;
const error = toast.error;
toast.info = (message) => { notices.push(`info:${message}`); };
toast.error = (message) => { notices.push(`error:${message}`); };
let finish: (() => void) | undefined;
let fail = true;
let calls = 0;
try {
await renderer.render(
<I18nProvider locale="en">
<ProviderCard provider="webdav" name="WebDAV" icon={null} isConnected isSyncing={false}
onConnect={() => {}} onSync={() => {}}
onDisconnect={() => {
calls += 1;
if (fail) throw new Error('fixture failure');
return new Promise<void>((resolve) => { finish = resolve; });
}} />
</I18nProvider>,
);
const click = async (node: Element) => {
await dispatchDomEvent(node, new dom.window.MouseEvent('click', { bubbles: true }));
await flushEffects();
};
const button = dom.document.querySelector('button[aria-label="Disconnect"]');
assert.ok(button);
assert.equal(button.textContent, 'Disconnect');
await click(button);
let dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
assert.match(dialog.textContent ?? '', /backup history and sync encryption password stay here/);
const confirm = () => {
const node = [...dom.document.querySelectorAll('[role="dialog"] button')].find((el) => el.textContent === 'Disconnect');
assert.ok(node);
return node as HTMLButtonElement;
};
await click(confirm());
assert.equal(calls, 1);
assert.equal(notices.length, 1);
assert.match(notices[0], /^error:Could not finish disconnecting from WebDAV/);
assert.ok(dom.document.querySelector('[role="dialog"]'));
assert.equal(confirm().disabled, false);
fail = false;
await click(confirm());
assert.equal(calls, 2);
assert.equal(confirm().disabled, true);
assert.equal(notices.length, 1);
await runWithAct(async () => { finish?.(); });
await flushEffects();
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
assert.equal(notices.length, 2);
assert.match(notices[1], /^info:Encrypted sync copies may still exist in your WebDAV storage/);
assert.doesNotMatch(notices[1], /\{name\}/);
// Cancelling a new confirmation never calls the disconnect callback.
await click(button);
dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
const cancel = [...dialog.querySelectorAll('button')].find((el) => el.textContent === 'Cancel');
assert.ok(cancel);
await click(cancel);
assert.equal(calls, 2);
} finally {
toast.info = info;
toast.error = error;
await renderer.unmount();
// Radix restores focus on a timer after unmount. Keep the DOM globals
// installed until that cleanup has dispatched its final event.
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
+1 -7
View File
@@ -25,7 +25,6 @@ import {
formatProxyConfigType,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
import {
EnvVar,
GroupConfig,
@@ -334,12 +333,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
const availableHostsForChain = useMemo(() => {
const chainedIds = new Set(form.hostChain?.hostIds || []);
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
// entries (e.g. saved local shells) as jump hosts.
return allHosts.filter((h) =>
!chainedIds.has(h.id)
&& canServeAsSshJumpHost(h),
);
return allHosts.filter((h) => !chainedIds.has(h.id));
}, [allHosts, form.hostChain?.hostIds]);
const addHostToChain = (hostId: string) => {
+1 -35
View File
@@ -27,9 +27,8 @@ import {
toGlobalHistoryDisplayEntries,
} from '../domain/globalHistory';
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
import { removeCommandHistoryEntry } from '../application/state/commandHistoryStore';
import { removeCommandHistoryEntry } from './terminal/autocomplete/commandHistoryStore';
import { cn } from '../lib/utils';
import { ConfirmDialog } from './ui/confirm-dialog';
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
import {
VariableSizeVirtualList,
@@ -47,7 +46,6 @@ export interface HistorySidePanelProps {
globalEntries: ShellHistoryEntry[];
onFetch: (sessionId: string, hostId: string) => void;
onDeleteGlobalEntry?: (entryId: string) => void;
onClearGlobalHistory?: () => void;
/** Paste into the terminal without executing (no trailing Enter). */
onPasteToTerminal: (command: string) => void;
/** Write to the terminal and execute (append Enter). */
@@ -117,7 +115,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
globalEntries,
onFetch,
onDeleteGlobalEntry,
onClearGlobalHistory,
onPasteToTerminal,
onRunInTerminal,
isVisible = true,
@@ -126,7 +123,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
const [scope, setScope] = useState<HistoryPanelScope>('host');
const [search, setSearch] = useState('');
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [clearAllOpen, setClearAllOpen] = useState(false);
const listRef = useRef<VariableSizeVirtualListHandle>(null);
const protocol = focusedHost?.protocol;
@@ -212,14 +208,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
setSelectedEntryId(null);
}, [globalEntries, onDeleteGlobalEntry, scope, sourceEntries]);
const handleClearAll = useCallback(() => {
// The application-state action clears both the global shell history and the
// autocomplete history, and fans the clear out to every renderer.
onClearGlobalHistory?.();
setClearAllOpen(false);
setSelectedEntryId(null);
}, [onClearGlobalHistory]);
const handleRowClick = useCallback((entryId: string) => {
setSelectedEntryId((current) => {
const next = current === entryId ? null : entryId;
@@ -306,18 +294,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
<RefreshCw size={14} className={cn(state.loading && 'animate-spin')} />
</button>
)}
{scope === 'global' && (
<button
type="button"
onClick={() => setClearAllOpen(true)}
disabled={!onClearGlobalHistory}
title={t('history.action.clearAll')}
aria-label={t('history.action.clearAll')}
className="shrink-0 h-6 w-6 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
>
<Trash2 size={14} />
</button>
)}
</div>
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/30 min-h-[28px]">
@@ -435,16 +411,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
/>
)}
</div>
<ConfirmDialog
open={clearAllOpen}
onOpenChange={setClearAllOpen}
title={t('history.clearAll.dialog.title')}
message={t('history.clearAll.dialog.desc')}
confirmLabel={t('history.clearAll.dialog.confirm')}
destructive
onConfirm={handleClearAll}
/>
</div>
);
};
+1 -8
View File
@@ -23,7 +23,6 @@ import {
updateProxyConfigField,
} from "../domain/proxyProfiles";
import { hasRequiredHostAuthCredential, resolveHostAuth, resolveHostAuthMethodForPersistence } from "../domain/sshAuth";
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
import { customThemeStore } from "../application/state/customThemeStore";
import {
hasHostFontSizeOverride,
@@ -750,13 +749,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const availableHostsForChain = useMemo(() => {
const chainedIds = new Set(form.hostChain?.hostIds || []);
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
// entries (e.g. saved local shells) as jump hosts.
return allHosts.filter((h) =>
h.id !== form.id
&& !chainedIds.has(h.id)
&& canServeAsSshJumpHost(h),
);
return allHosts.filter((h) => h.id !== form.id && !chainedIds.has(h.id));
}, [allHosts, form.id, form.hostChain?.hostIds]);
const chainedHosts = useMemo(() => {
+1 -5
View File
@@ -16,7 +16,6 @@ import {
type HostClickBehavior,
} from '../domain/hostClickBehavior';
import { sortByVaultOrder } from '../domain/vaultOrder';
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
@@ -727,7 +726,6 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
initialTreeItemKey,
onActiveTreeItemChange,
}) => {
const { t } = useI18n();
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
const displayDetails = useMemo(
@@ -845,9 +843,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<HostNotesIndicator notes={host.notes} />
</div>
<div className="truncate text-[11px] leading-4 text-muted-foreground">
{displayProtocol === 'local'
? getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })
: `${displayUsername}@${host.hostname}:${displayPort}`}
{displayUsername}@{host.hostname}:{displayPort}
</div>
</div>
)}
@@ -1,101 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { LocalShellHostDetailsPanel } from "./LocalShellHostDetailsPanel.tsx";
import type { Host } from "../types.ts";
const panelSource = readFileSync(
new URL("./LocalShellHostDetailsPanel.tsx", import.meta.url),
"utf8",
);
const existingLocalHost = {
id: "local-existing",
label: "Project A CMD",
hostname: "localhost",
username: "",
tags: ["local"],
group: "Projects/A",
protocol: "local",
os: "linux",
createdAt: 0,
localShell: "powershell",
localShellName: "Windows PowerShell",
localShellIcon: "powershell",
} as unknown as Host;
const noop = () => {};
test("renderToStaticMarkup: create mode shows title, default-shell option and Save & Connect", () => {
const markup = renderToStaticMarkup(
<LocalShellHostDetailsPanel
defaultGroup="Projects/A"
groups={["Projects/A", "Infra"]}
onSave={noop}
onSaveAndConnect={noop}
onCancel={noop}
layout="inline"
/>,
);
assert.match(markup, /localShell\.panel\.title/);
assert.match(markup, /localShell\.field\.shellDefault/);
assert.match(markup, /localShell\.saveAndConnect/);
assert.match(markup, /localShell\.saveAndConnect|common\.save/);
});
test("renderToStaticMarkup: edit mode hides Save & Connect and shows existing shell", () => {
const markup = renderToStaticMarkup(
<LocalShellHostDetailsPanel
initialData={existingLocalHost}
groups={["Projects/A"]}
onSave={noop}
onCancel={noop}
layout="inline"
/>,
);
assert.match(markup, /localShell\.panel\.title\.edit/);
assert.doesNotMatch(markup, /localShell\.saveAndConnect/);
// Existing label is carried into the form
assert.match(markup, /Project A CMD/);
assert.match(markup, /local-shell-start-dir/);
});
test("buildHost preserves the saved OS on edits and detects it only for new entries", () => {
// Editing must retain initialData.os: entries imported/synced on one
// platform must not be re-classified as the editor machine's OS on save.
assert.match(
panelSource,
/os:\s*initialData\?\.os \?\? detectLocalOs\(navigator\.userAgent \|\| navigator\.platform\)/,
);
});
test("unchanged shell selection keeps stored metadata over discovery", () => {
// A host with custom localShellArgs for a shell id that discovery also
// matches must keep its stored args/name/icon when an unrelated edit
// (label, tag, group) is saved; discovered metadata applies only after
// the user changes the shell selection.
const unchanged = panelSource.match(
/const shellMeta =\s*\n[\s\S]*?shell === initialData\?\.localShell\s*\n\s*\?\s*\{[\s\S]*?\}\s*\n\s*: matchedShell;/,
);
assert.ok(unchanged, "shellMeta must branch on an unchanged shell selection");
// Inside the unchanged branch, stored metadata wins per field and
// matchedShell is only a fallback (never the primary source).
assert.match(
unchanged[0],
/args:\s*initialData\?\.localShellArgs \?\? matchedShell\?\.args/,
);
assert.match(
unchanged[0],
/name:\s*initialData\?\.localShellName \?\? matchedShell\?\.name/,
);
assert.match(
unchanged[0],
/icon:\s*initialData\?\.localShellIcon \?\? matchedShell\?\.icon/,
);
assert.doesNotMatch(unchanged[0], /matchedShell \?\?/);
});
-255
View File
@@ -1,255 +0,0 @@
/**
* Local Shell Host Details Panel
* A dedicated editor for local shell / CMD vault hosts (protocol 'local'),
* distinct from the SSH HostDetailsPanel. Lets users save named local shells
* into vault groups next to their SSH hosts (issue #3615).
*/
import { Save, TerminalSquare, X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useDiscoveredShells } from '../lib/useDiscoveredShells';
import { detectLocalOs } from '../lib/localShell';
import type { Host } from '../domain/models';
import { createLocalShellHost } from '../domain/localShellHost';
import { Button } from './ui/button';
import { Combobox, ComboboxOption } from './ui/combobox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
type AsidePanelResizeProps,
} from './ui/aside-panel';
import { cn } from '../lib/utils';
interface LocalShellHostDetailsPanelProps {
/** Existing host when editing; null/undefined when creating a new entry. */
initialData?: Host | null;
defaultGroup?: string;
groups?: string[];
onSave: (host: Host) => void;
/** Saves the host and immediately opens a terminal for it. */
onSaveAndConnect?: (host: Host) => void;
onCancel: () => void;
layout?: AsidePanelLayout;
className?: string;
}
type LocalShellHostDetailsPanelPropsWithResize =
LocalShellHostDetailsPanelProps & AsidePanelResizeProps;
export const LocalShellHostDetailsPanel: React.FC<LocalShellHostDetailsPanelPropsWithResize> = ({
initialData,
defaultGroup,
groups = [],
onSave,
onSaveAndConnect,
onCancel,
layout = 'overlay',
className,
resizable,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
const [label, setLabel] = useState(initialData?.label ?? '');
const [shell, setShell] = useState(initialData?.localShell ?? '');
const [startDir, setStartDir] = useState(initialData?.localStartDir ?? '');
// On creation, initialize from defaultGroup so the field reflects what a
// plain save would persist; clearing it then intentionally saves at the
// vault root. Edits keep the host's own group (possibly empty).
const [group, setGroup] = useState(
initialData ? initialData.group ?? '' : defaultGroup ?? '',
);
const matchedShell = useMemo(
() => discoveredShells.find((candidate) => candidate.id === shell),
[discoveredShells, shell],
);
// While the shell selection is unchanged from the saved host, prefer the
// host's stored metadata: an imported/synced host may carry custom startup
// arguments for a shell id that also exists in discovery, and saving an
// unrelated edit (label, tag, group) must not overwrite them with the
// current discovery result. Discovered metadata applies only after the user
// changes the selection — or per-field when the host stores none.
const shellMeta =
shell && shell === initialData?.localShell
? {
args: initialData?.localShellArgs ?? matchedShell?.args,
name: initialData?.localShellName ?? matchedShell?.name,
icon: initialData?.localShellIcon ?? matchedShell?.icon,
}
: matchedShell;
const shellOptions: ComboboxOption[] = useMemo(() => {
const options: ComboboxOption[] = [
{ value: '', label: t('localShell.field.shellDefault') },
];
for (const candidate of discoveredShells) {
options.push({
value: candidate.id,
label: candidate.name,
sublabel: candidate.command,
});
}
return options;
}, [discoveredShells, t]);
const groupOptions: ComboboxOption[] = useMemo(() => {
const allGroups = new Set(groups);
if (group && !allGroups.has(group)) {
allGroups.add(group);
}
return Array.from(allGroups).map((g) => ({
value: g,
label: g,
}));
}, [groups, group]);
const buildHost = (): Host => {
const host = createLocalShellHost({
id: initialData?.id,
// Keep the entry's saved OS on edits: importing/syncing on one platform
// then editing elsewhere must not silently re-classify the host (shell
// defaults, CSV round trips, dedup) as the editor machine's OS.
os: initialData?.os ?? detectLocalOs(navigator.userAgent || navigator.platform),
label,
shell,
shellArgs: shellMeta?.args,
shellName: shellMeta?.name,
shellIcon: shellMeta?.icon,
startDir,
// Save the controlled value directly: '' means the user explicitly
// cleared the group, which persists at the vault root.
group: group,
tags: initialData?.tags,
notes: initialData?.notes,
});
// On edits, merge over the existing host so fields this form does not
// expose (pinned, lastConnectedAt, order, ...) and the original
// creation timestamp survive a save.
return initialData
? { ...initialData, ...host, createdAt: initialData.createdAt ?? host.createdAt }
: host;
};
const handleSave = () => onSave(buildHost());
const handleSaveAndConnect = () => {
if (!onSaveAndConnect) return;
onSaveAndConnect(buildHost());
};
return (
<AsidePanel
open={true}
onClose={onCancel}
title={initialData ? t('localShell.panel.title.edit') : t('localShell.panel.title')}
subtitle={initialData?.label}
className={cn('z-40', className)}
layout={layout}
dataSection="local-shell-host-details-panel"
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
>
<AsidePanelContent>
{/* Label */}
<div className="space-y-2">
<Label htmlFor="local-shell-label">{t('localShell.field.label')}</Label>
<Input
id="local-shell-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('localShell.field.labelPlaceholder')}
/>
</div>
{/* Shell */}
<div className="space-y-2">
<Label>{t('localShell.field.shell')}</Label>
<Combobox
options={shellOptions}
value={shell}
onValueChange={setShell}
placeholder={t('localShell.field.shellDefault')}
emptyText={t('localShell.field.shellEmpty')}
allowCreate
createText={t('common.use')}
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
/>
<p className="text-xs text-muted-foreground">
{matchedShell
? t('localShell.field.shellResolved', { command: matchedShell.command })
: t('localShell.field.shellDefaultDesc')}
</p>
</div>
{/* Group */}
<div className="space-y-2">
<Label>{t('hostDetails.group')}</Label>
<Combobox
options={groupOptions}
value={group}
onValueChange={setGroup}
placeholder={t('hostDetails.selectGroup')}
allowCreate
createText={t('hostDetails.createGroup')}
/>
</div>
{/* Start directory */}
<div className="space-y-2">
<Label htmlFor="local-shell-start-dir">{t('localShell.field.startDir')}</Label>
<div className="relative">
<Input
id="local-shell-start-dir"
value={startDir}
onChange={(e) => setStartDir(e.target.value)}
placeholder={t('localShell.field.startDirPlaceholder')}
className="pr-8"
/>
{startDir && (
<button
type="button"
onClick={() => setStartDir('')}
aria-label={t('common.reset')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X size={14} />
</button>
)}
</div>
</div>
</AsidePanelContent>
<AsidePanelFooter>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} className="flex-1">
{t('common.cancel')}
</Button>
{onSaveAndConnect && !initialData && (
<Button
onClick={handleSaveAndConnect}
className="flex-1"
>
<TerminalSquare size={14} className="mr-2" />
{t('localShell.saveAndConnect')}
</Button>
)}
<Button onClick={handleSave} className="flex-1">
<Save size={14} className="mr-2" />
{t('common.save')}
</Button>
</div>
</AsidePanelFooter>
</AsidePanel>
);
};
export default LocalShellHostDetailsPanel;
-4
View File
@@ -30,8 +30,6 @@ interface SelectHostPanelProps {
resizable?: boolean;
persistWidthStorageKey?: string;
resizeAriaLabel?: string;
/** Allow saved local-shell (CMD/PowerShell/...) hosts as targets, e.g. snippet/script runners. */
allowLocalHosts?: boolean;
}
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
@@ -58,7 +56,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
resizable = false,
persistWidthStorageKey,
resizeAriaLabel,
allowLocalHosts = false,
}) => {
const { t } = useI18n();
const [newHostPanelOpen, setNewHostPanelOpen] = useState(false);
@@ -106,7 +103,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
onNewHostPanelOpenChange={setNewHostPanelOpen}
allowLocalHosts={allowLocalHosts}
className="flex-1 min-h-0"
/>
</AsidePanel>
+2 -10
View File
@@ -58,8 +58,6 @@ export interface SelectHostPanelContentProps {
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
onNewHostPanelOpenChange?: (open: boolean) => void;
/** Allow saved local-shell (CMD/PowerShell/...) hosts as targets, e.g. snippet/script runners. */
allowLocalHosts?: boolean;
className?: string;
}
@@ -87,7 +85,6 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
onSaveHost,
onCreateGroup,
onNewHostPanelOpenChange,
allowLocalHosts = false,
className,
}) => {
const { t } = useI18n();
@@ -109,14 +106,9 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
onNewHostPanelOpenChange?.(showNewHostPanel);
}, [onNewHostPanelOpenChange, showNewHostPanel]);
// This panel picks hosts for SSH-backed capabilities (tunnels, keychain
// key export targets); serial hosts have no SSH session to target and, by
// default, neither do local-shell hosts. Snippet/script target pickers pass
// `allowLocalHosts` because their domain treats every non-serial host
// (including saved CMD/PowerShell shells) as a runnable target.
const selectableHosts = useMemo(
() => hosts.filter((host) => host.protocol !== 'serial' && (allowLocalHosts || host.protocol !== 'local')),
[hosts, allowLocalHosts],
() => hosts.filter((host) => host.protocol !== 'serial'),
[hosts],
);
const selectedHostIdSet = useMemo(
() => new Set(selectedHostIds),
-1
View File
@@ -132,7 +132,6 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
customGroups={customGroups}
selectedHostIds={targetSelection}
multiSelect={true}
allowLocalHosts={true}
onSelect={handleTargetSelect}
onSelectionChange={handleTargetSelectionChange}
onBack={handleTargetPickerBack}
+3 -9
View File
@@ -50,7 +50,6 @@ import {
resolveTerminalChainHosts,
resolveTerminalSessionHost,
} from '../domain/terminalHostResolution';
import { useDefaultLocalShellContext } from '../lib/useDiscoveredShells';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { toast } from './ui/toast';
import { useI18n } from '../application/i18n/I18nProvider';
@@ -183,7 +182,7 @@ function buildScriptSessionMeta(
connected: session.status === 'connected',
name: session.customName || session.hostLabel || host?.label,
hostname: host?.hostname ?? session.hostname,
username: session.protocol === 'local' ? session.username : host?.username ?? session.username,
username: host?.username ?? session.username,
};
}
@@ -239,7 +238,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onAddKnownHost,
onCommandExecuted,
onDeleteShellHistoryEntry,
onClearCommandHistory,
onTerminalDataCapture,
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
@@ -1197,16 +1195,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
const effectiveHosts = useMemo(
() => hosts.map((host) => resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
validProxyProfileIds: proxyProfileIdSet,
defaultLocalShell,
})),
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles, defaultLocalShell],
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles],
);
// Pre-compute fallback hosts to avoid creating new objects on every render
@@ -1219,12 +1215,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
groupConfigs,
proxyProfiles,
localOs: detectLocalOs(navigator.userAgent || navigator.platform),
defaultLocalShell,
});
map.set(session.id, applySessionFontSizeToHost(hostForSession, session));
}
return map;
}, [sessions, hosts, groupConfigs, proxyProfiles, defaultLocalShell]);
}, [sessions, hosts, groupConfigs, proxyProfiles]);
const resolvedSessionHostIds = useMemo(() => {
const ids = new Set<string>();
for (const session of sessions) {
@@ -2534,7 +2529,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleCloseSidePanel,
handleCommandExecuted,
handleHistoryDelete: onDeleteShellHistoryEntry,
handleHistoryClear: onClearCommandHistory,
handleCommandSubmitted,
handleComposeSend,
handleHistoryPaste,
-31
View File
@@ -5,7 +5,6 @@ import { readFileSync } from 'node:fs';
import type { Host, TerminalSession } from '../types';
import type { TerminalPopupPayload } from '../domain/systemManager/types';
import { resolveTerminalPopupHost, resolveTerminalPopupReuseId } from './TerminalPopupPage';
import { resolveShellSetting } from '../lib/useDiscoveredShells';
const source = readFileSync(new URL('./TerminalPopupPage.tsx', import.meta.url), 'utf8');
const terminalSource = readFileSync(new URL('./Terminal.tsx', import.meta.url), 'utf8');
@@ -54,36 +53,6 @@ test('resolveTerminalPopupHost honors source session transport over saved Mosh h
assert.equal(host.etEnabled, false);
});
test('resolveTerminalPopupHost resolves the settings shell for saved local hosts with a system default shell', () => {
const localHost = vaultHost({
protocol: 'local',
hostname: 'localhost',
username: '',
port: undefined,
localShell: undefined,
});
const defaultLocalShell = {
discoveredShells: [],
resolveShellSetting,
terminalSettings: { localShell: 'fish', localShellArgs: [] },
};
const withContext = resolveTerminalPopupHost(
popupPayload(sourceSession({ protocol: 'local', port: undefined })),
[localHost],
{ defaultLocalShell },
);
assert.equal(withContext.localShell, 'fish');
// Without the context (e.g. callers that cannot resolve it) the vault host
// keeps its saved (empty) shell — the caller decides how to handle it.
const withoutContext = resolveTerminalPopupHost(
popupPayload(sourceSession({ protocol: 'local', port: undefined })),
[localHost],
);
assert.equal(withoutContext.localShell, undefined);
});
test('resolveTerminalPopupHost still falls back to source session details when the host is missing', () => {
const host = resolveTerminalPopupHost(popupPayload(sourceSession({ hostId: 'missing' })), []);
+1 -8
View File
@@ -12,14 +12,12 @@ import {
} from '../application/state/resolveTerminalSessionExitIntent';
import { upsertKnownHost } from '../domain/knownHosts';
import { isPluginHostProtocol, sanitizePluginConnection } from '../domain/pluginConnection';
import type { ResolveDefaultLocalShellContext } from '../domain/localShellHost';
import { resolveTerminalChainHosts, resolveTerminalSessionHost } from '../domain/terminalHostResolution';
import type { TerminalPopupPayload } from '../domain/systemManager/types';
import type { GroupConfig, Host, ProxyProfile, TerminalTheme } from '../domain/models';
import type { KnownHost } from '../types';
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
import { detectLocalOs } from '../lib/localShell';
import { useDefaultLocalShellContext } from '../lib/useDiscoveredShells';
import { cn } from '../lib/utils';
import type { AppLockGateRenderContext } from './AppLockGate';
import { PluginAuthenticationHost } from './plugins/PluginAuthenticationHost';
@@ -235,7 +233,6 @@ export function resolveTerminalPopupHost(
groupConfigs?: GroupConfig[];
proxyProfiles?: ProxyProfile[];
localOs?: Host['os'];
defaultLocalShell?: ResolveDefaultLocalShellContext;
} = {},
): Host {
const resolvedHost = resolveTerminalSessionHost({
@@ -244,7 +241,6 @@ export function resolveTerminalPopupHost(
groupConfigs: options.groupConfigs ?? [],
proxyProfiles: options.proxyProfiles ?? [],
localOs: options.localOs ?? 'linux',
defaultLocalShell: options.defaultLocalShell,
});
return applySourceSessionConnectionOverrides(
resolvedHost,
@@ -370,17 +366,14 @@ function TerminalPopupPageInner({
});
}, [attachAuthorization, attachSessionId, handleClose, onPrepareClose]);
const defaultLocalShell = useDefaultLocalShellContext(settings.terminalSettings);
const host = useMemo(() => {
if (!config) return null;
return resolveTerminalPopupHost(config, hosts, {
groupConfigs,
proxyProfiles,
localOs: detectLocalOs(navigator.userAgent || navigator.platform),
defaultLocalShell,
});
}, [config, groupConfigs, hosts, proxyProfiles, defaultLocalShell]);
}, [config, groupConfigs, hosts, proxyProfiles]);
const chainHosts = useMemo(() => resolveTerminalChainHosts({
host,
-111
View File
@@ -1,111 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import ts from "typescript";
import { upsertHostById } from "../domain/host";
import { saveWorkSurfaceHostDraft } from "../application/state/useWorkSurfaceHostEditor";
import { createLocalShellHost } from "../domain/localShellHost";
import type { Host } from "../types";
// Execute the actual Vault save callback with its persistence boundary stubbed.
const source = readFileSync(new URL("./VaultView.tsx", import.meta.url), "utf8");
const callback = source.match(/const handleSaveLocalShellHost = useCallback\((\(host: Host, connectNow\?: boolean\) => \{[\s\S]*?)\}, \[/)?.[1];
assert.ok(callback);
const compiled = ts.transpile(`const save = ${callback}};`, { target: ts.ScriptTarget.ES2022 });
for (const editing of [false, true]) {
for (const connectNow of [false, true]) {
test(`local shell save: editing=${editing}, connect=${connectNow}`, () => {
const opened = createLocalShellHost({ id: "local-1", label: "old", tags: ["old"], notes: "clear me" });
const latest = { ...opened, showLineTimestamps: true, lastConnectedAt: 42 };
const draft = { ...opened, tags: ["edited"], notes: undefined };
let saved: Host[] = [];
let connected: Host | undefined;
// Mirror useVaultState.updateHosts: hostsRef holds the freshest host
// array and accepts an array or functional updater argument.
const hostsRef: { current: Host[] } = { current: editing ? [latest] : [] };
const save = new Function(
"hosts", "editingLocalShellHost", "onUpdateHosts", "onConnect",
"setIsLocalShellHostPanelOpen", "setEditingLocalShellHost",
"upsertHostById", "saveWorkSurfaceHostDraft",
`${compiled} return save;`,
)(
editing ? [latest] : [], editing ? opened : null,
(data: Host[] | ((prev: Host[]) => Host[])) => {
const next = typeof data === "function" ? data(hostsRef.current) : data;
if (next) hostsRef.current = next;
saved = next ?? hostsRef.current;
},
(host: Host) => { connected = host; },
() => {}, () => {}, upsertHostById, saveWorkSurfaceHostDraft,
);
save(draft, connectNow);
assert.equal(saved.length, 1);
assert.deepEqual(saved[0], editing ? { ...latest, tags: ["edited"], notes: undefined } : draft);
assert.deepEqual(connected, connectNow ? saved[0] : undefined);
});
}
}
// Saving must apply the draft against the freshest host array, not the array
// captured at render time — a concurrent vault write between render and Save
// would otherwise be replaced wholesale by the stale snapshot.
test("local shell save: updater merges against the latest host array", () => {
const opened = createLocalShellHost({ id: "local-1", label: "old", tags: ["old"] });
const draft = { ...opened, tags: ["edited"] };
// A concurrent write lands after the render that captured `hosts`.
const concurrent = createLocalShellHost({ id: "local-2", label: "later" });
const renderedHosts = [opened];
const actualHosts = [...renderedHosts, concurrent];
// Mirror useVaultState.updateHosts: hostsRef holds the freshest host array
// (including the concurrent write) and accepts an array or updater argument.
let saved: Host[] = [];
const hostsRef: { current: Host[] } = { current: actualHosts };
const save = new Function(
"hosts", "editingLocalShellHost", "onUpdateHosts", "onConnect",
"setIsLocalShellHostPanelOpen", "setEditingLocalShellHost",
"upsertHostById", "saveWorkSurfaceHostDraft",
`${compiled} return save;`,
)(
renderedHosts, opened,
(data: Host[] | ((prev: Host[]) => Host[])) => {
const next = typeof data === "function" ? data(hostsRef.current) : data;
if (next) hostsRef.current = next;
saved = next ?? hostsRef.current;
},
() => {}, () => {}, () => {}, upsertHostById, saveWorkSurfaceHostDraft,
);
save(draft, false);
assert.equal(saved.length, 2, "concurrently added host must survive the save");
assert.ok(saved.some((host) => host.id === "local-2"));
assert.ok(saved.some((host) => host.id === "local-1" && host.tags.includes("edited")));
});
// Duplicating a local-shell host opens its copy under a newly generated ID
// that is not in the vault yet; it must be saved as a new entry, not as an
// edit (which would no-op and silently discard the duplicate).
test("local shell save: duplicated host with a new id saves as a new entry", () => {
const opened = createLocalShellHost({ label: "cmd (copy)" });
const draft = { ...opened, notes: "tweaked" };
let saved: Host[] = [];
let connected: Host | undefined;
const save = new Function(
"hosts", "editingLocalShellHost", "onUpdateHosts", "onConnect",
"setIsLocalShellHostPanelOpen", "setEditingLocalShellHost",
"upsertHostById", "saveWorkSurfaceHostDraft",
`${compiled} return save;`,
)(
[], opened,
// Mirror useVaultState.updateHosts: accepts an array or a functional
// updater applied against the freshest host array (here, the vault is
// empty at render time and stays empty in prev too).
(data: Host[] | ((prev: Host[]) => Host[])) => { saved = typeof data === "function" ? data(saved) : data; },
(host: Host) => { connected = host; },
() => {}, () => {}, upsertHostById, saveWorkSurfaceHostDraft,
);
save(draft, false);
assert.equal(saved.length, 1);
assert.equal(saved[0].id, draft.id);
assert.equal(saved[0].notes, "tweaked");
assert.equal(connected, undefined);
});
+12 -103
View File
@@ -73,7 +73,6 @@ import {
upsertHostById,
} from "../domain/host";
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
import { saveWorkSurfaceHostDraft, type WorkSurfaceHostEditorTarget } from "../application/state/useWorkSurfaceHostEditor";
import { upsertKnownHost } from "../domain/knownHosts";
import {
remapSnippetTargetGroupPaths,
@@ -239,9 +238,7 @@ interface VaultViewProps {
onDeleteHost: (id: string) => void;
onConnect: (host: Host) => void;
onOpenHostFromNote?: (host: Host, source?: { noteId: string }) => void;
onUpdateHosts: (
hosts: Host[] | ((prev: Host[]) => Host[]),
) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
onReadPersistedHosts: () => Promise<Host[]>;
onUpdateKeys: (keys: SSHKey[]) => void;
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
@@ -461,9 +458,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [isHostPanelOpen, setIsHostPanelOpen] = useState(false);
const [editingHost, setEditingHost] = useState<Host | null>(null);
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
// Local shell (CMD) host panel state — a vault host entry with protocol 'local'
const [isLocalShellHostPanelOpen, setIsLocalShellHostPanelOpen] = useState(false);
const [editingLocalShellHost, setEditingLocalShellHost] = useState<Host | null>(null);
const [pendingOpenNoteId, setPendingOpenNoteId] = useState<string | null>(
null,
);
@@ -479,17 +473,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setCurrentSection("hosts");
setSelectedGroupPath(null);
setNewHostGroupPath(null);
if (deepLinkHostDraft.protocol === "local") {
setEditingHost(null);
setIsHostPanelOpen(false);
setEditingLocalShellHost(deepLinkHostDraft);
setIsLocalShellHostPanelOpen(true);
} else {
setIsLocalShellHostPanelOpen(false);
setEditingLocalShellHost(null);
setEditingHost(deepLinkHostDraft);
setIsHostPanelOpen(true);
}
setEditingHost(deepLinkHostDraft);
setIsHostPanelOpen(true);
onDeepLinkHostDraftHandled?.();
}, [deepLinkHostDraft, onDeepLinkHostDraftHandled]);
@@ -522,17 +507,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
useEffect(() => {
const currentIds = new Set(hosts.map((h) => h.id));
// Check against previous IDs before updating the ref
const editingDeleted = (openHost: Host | null) =>
Boolean(openHost && knownHostIdsRef.current.has(openHost.id) && !currentIds.has(openHost.id));
if (editingDeleted(editingHost) || editingDeleted(editingLocalShellHost)) {
if (
editingHost &&
knownHostIdsRef.current.has(editingHost.id) &&
!currentIds.has(editingHost.id)
) {
setIsHostPanelOpen(false);
setEditingHost(null);
setIsLocalShellHostPanelOpen(false);
setEditingLocalShellHost(null);
setNewHostGroupPath(null);
}
knownHostIdsRef.current = currentIds;
}, [hosts, editingHost, editingLocalShellHost]);
}, [hosts, editingHost]);
// Group panel state
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
@@ -614,12 +599,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{},
{ validProxyProfileIds: proxyProfileIdSet },
);
// Only prompt when Telnet is available but isn't the host's default
// protocol, and only for SSH hosts: local-shell and serial hosts must
// always launch their own transport, never an inherited group Telnet
// flag. Hosts without an explicit protocol are legacy SSH entries.
const effectiveProtocol = effective.protocol ?? "ssh";
if (effectiveProtocol === "ssh" && effective.telnetEnabled) {
// Only prompt when Telnet is available but isn't the host's default protocol.
if (effective.telnetEnabled && effective.protocol !== "telnet") {
setProtocolSelectHost(effective);
} else if (effective.protocol === "telnet") {
// Telnet-as-default wins over a stray moshEnabled flag.
@@ -673,67 +654,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
setEditingHost(null);
setIsLocalShellHostPanelOpen(false);
setEditingLocalShellHost(null);
setNewHostGroupPath(null);
setIsHostPanelOpen(true);
}, []);
// "Local shell (CMD)" entry under the New Host menu — opens the dedicated
// local-shell host editor with the current vault group preselected.
const handleNewLocalShellHost = useCallback(() => {
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
setEditingHost(null);
setIsHostPanelOpen(false);
setEditingLocalShellHost(null);
setNewHostGroupPath(null);
setIsLocalShellHostPanelOpen(true);
}, []);
const handleCancelLocalShellHost = useCallback(() => {
setIsLocalShellHostPanelOpen(false);
setEditingLocalShellHost(null);
}, []);
const handleSaveLocalShellHost = useCallback((host: Host, connectNow?: boolean) => {
// A duplicated local host carries a freshly generated ID that is not in the
// vault yet, so it must be saved as a new entry (edit mode would no-op).
// (Note: the mode is derived from the rendered hosts snapshot only, so the
// updater below can still merge against the up-to-date vault array.)
const editingExistingHost = hosts.some((entry) => entry.id === editingLocalShellHost?.id);
const target: WorkSurfaceHostEditorTarget = editingExistingHost && editingLocalShellHost
? { mode: "edit", openedHost: editingLocalShellHost, requestId: 0 }
: { mode: "new", defaultGroup: null, requestId: 0 };
// Apply the draft against the freshest host array, not the one captured at
// render time: another vault write (terminal metadata, sync) may have
// updated hostsRef between render and Save, and submitting the captured
// array would replace it wholesale.
let savedHost: Host | null = null;
onUpdateHosts((prevHosts) => {
const updatedHosts = saveWorkSurfaceHostDraft(prevHosts, target, host);
if (!updatedHosts) return prevHosts;
savedHost = updatedHosts.find((entry) => entry.id === host.id) ?? null;
return updatedHosts;
});
setIsLocalShellHostPanelOpen(false);
setEditingLocalShellHost(null);
if (connectNow && savedHost) onConnect(savedHost);
}, [hosts, editingLocalShellHost, onConnect, onUpdateHosts]);
const handleEditHost = useCallback((host: Host) => {
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
// Local shell hosts route to their dedicated editor (no SSH auth fields).
if (host.protocol === "local") {
setEditingHost(null);
setIsHostPanelOpen(false);
setEditingLocalShellHost(host);
setIsLocalShellHostPanelOpen(true);
return;
}
setIsLocalShellHostPanelOpen(false);
setEditingLocalShellHost(null);
setEditingHost(host);
setIsHostPanelOpen(true);
}, []);
@@ -760,15 +687,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
lastConnectedAt: undefined,
};
// Open the edit panel with the duplicated host for modification
if (duplicatedHost.protocol === "local") {
setEditingHost(null);
setIsHostPanelOpen(false);
setEditingLocalShellHost(duplicatedHost);
setIsLocalShellHostPanelOpen(true);
return;
}
setEditingLocalShellHost(null);
setIsLocalShellHostPanelOpen(false);
setEditingHost(duplicatedHost);
setIsHostPanelOpen(true);
},
@@ -1358,7 +1276,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
handleUnmanageGroup,
managedGroupPaths,
moveHostToGroup,
moveHostsToGroup,
setDragOverDropTarget,
setGroupDragOverDropTarget,
} = useVaultGroupDragHandlers({
@@ -1438,9 +1355,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const isHostsSectionActive = currentSection === "hosts";
const hasHostsSidePanel =
isHostsSectionActive &&
((isGroupPanelOpen && !!editingGroupPath)
|| isHostPanelOpen
|| isLocalShellHostPanelOpen);
((isGroupPanelOpen && !!editingGroupPath) || isHostPanelOpen);
// Every host grid uses the same container-based fixed column count. This keeps
// pinned/recent cards aligned with the virtualized main grid and prevents a
// single card from stretching across the full row.
@@ -1554,9 +1469,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
handleHostConnect,
handleImportFileSelected,
handleNewHost,
handleNewLocalShellHost,
handleCancelLocalShellHost,
handleSaveLocalShellHost,
handleProtocolSelect,
handleQuickConnect,
handleQuickConnectSaveHost,
@@ -1576,8 +1488,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
isDeleteGroupOpen,
isGroupPanelOpen,
isHostPanelOpen,
isLocalShellHostPanelOpen,
editingLocalShellHost,
isHostsSectionActive,
isImportOpen,
isMultiSelectMode,
@@ -1603,7 +1513,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
managedSources,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
Network,
newFolderName,
newHostGroupPath,
+2 -4
View File
@@ -20,10 +20,8 @@ test("vault header collapsed actions cannot retain hidden focus", () => {
assert.match(vaultViewLayoutSource, /newHostActionsRef\.current\?\.contains\(activeElement\)/);
assert.match(vaultViewLayoutSource, /sessionActionsRef\.current\?\.contains\(activeElement\)/);
assert.match(vaultViewLayoutSource, /activeElement\.blur\(\)/);
// Both the SSH/new-host panel and the local-shell host panel collapse the
// header actions so hidden buttons cannot retain focus.
assert.match(vaultViewLayoutSource, /aria-hidden=\{isHostAsideOpen \? true : undefined\}/);
assert.match(vaultViewLayoutSource, /inert=\{isHostAsideOpen \? true : undefined\}/);
assert.match(vaultViewLayoutSource, /aria-hidden=\{isHostPanelOpen \? true : undefined\}/);
assert.match(vaultViewLayoutSource, /inert=\{isHostPanelOpen \? true : undefined\}/);
});
test("vault sidebar toggle keeps an accessible action label", () => {
-17
View File
@@ -585,20 +585,3 @@ test("Codex approval render plan preserves every approval for the same item", ()
["approval-3"],
);
});
test('fork is offered only for safe completed replies and hidden while streaming', () => {
const messages: ChatMessage[] = [
{ id: 'u', role: 'user', content: 'hello', timestamp: 1 },
{ id: 'done', role: 'assistant', content: 'completed', timestamp: 2, executionStatus: 'completed' },
{ id: 'failed', role: 'assistant', content: 'failed', timestamp: 3, executionStatus: 'failed' },
];
const render = (isStreaming: boolean) => renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<ChatMessageList messages={messages} isStreaming={isStreaming} onForkFromMessage={() => {}} />
</TooltipProvider>
</I18nProvider>,
);
assert.equal((render(false).match(/Fork from here/g) ?? []).length, 1);
assert.equal((render(true).match(/Fork from here/g) ?? []).length, 0);
});
+2 -37
View File
@@ -6,11 +6,10 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle, BookOpen, FileText, GitFork, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { AISessionContextCompaction, ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
import { getForkableMessageIds } from '../../domain/aiSessionFork';
import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
import {
Conversation,
@@ -93,10 +92,6 @@ interface ChatMessageListProps {
/** Active chat session ID — used to filter standalone MCP approval blocks */
activeSessionId?: string | null;
activeCompaction?: ActiveCompactionUi | null;
/** Stored compaction summary for the viewed session — fork boundaries must not fall inside its coverage. */
contextCompaction?: AISessionContextCompaction | null;
/** Present when the panel supports forking a conversation from a completed assistant response. */
onForkFromMessage?: (messageId: string) => void;
notes?: VaultNote[];
hosts?: Host[];
snippets?: Snippet[];
@@ -165,8 +160,6 @@ export function buildCodexApprovalRenderPlan(
const MESSAGE_RENDER_BATCH = 50;
const MESSAGE_RENDER_STEP = 50;
const EMPTY_FORKABLE_SET: ReadonlySet<string> = new Set();
export function pruneResolvedApprovals(
previous: ReadonlyMap<string, boolean>,
messages: readonly ChatMessage[],
@@ -187,8 +180,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
isStreaming,
activeSessionId,
activeCompaction = null,
contextCompaction,
onForkFromMessage,
notes = [],
hosts = [],
snippets = [],
@@ -408,14 +399,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
[messages],
);
// "Fork from here" targets: completed assistant responses whose whole
// prefix is a safe fork boundary (fully resolved tool calls, stored
// compaction summary still accurate). Hidden entirely while a turn streams.
const forkableMessageIds = useMemo(() => {
if (!onForkFromMessage || isStreaming) return EMPTY_FORKABLE_SET;
return getForkableMessageIds({ messages, contextCompaction: contextCompaction ?? undefined });
}, [onForkFromMessage, isStreaming, messages, contextCompaction]);
// While a jump target is active, re-resolve the tail against the current list
// so streaming appends cannot slide the window past the selected message.
const effectiveTailCount = activeJumpMessageId
@@ -815,22 +798,6 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
</div>
</div>
)}
{/* Fork from here — copies the conversation up to this completed
response into a new session. Only on safe boundaries; hidden
while a turn streams (forkableMessageIds empty). */}
{!isUser && onForkFromMessage && forkableMessageIds.has(message.id) && (
<div className="flex -mt-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={() => onForkFromMessage(message.id)}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground/40 hover:text-foreground hover:bg-white/[0.05] transition-colors cursor-pointer"
>
<GitFork size={11} />
<span>{t('ai.chat.forkFromHere')}</span>
</button>
</div>
)}
</MessageContent>
</Message>
);
@@ -1075,8 +1042,6 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
if (prev.onOpenVaultHost !== next.onOpenVaultHost) return false;
if (prev.onOpenVaultSnippet !== next.onOpenVaultSnippet) return false;
if (prev.onOpenVaultSection !== next.onOpenVaultSection) return false;
if (prev.onForkFromMessage !== next.onForkFromMessage) return false;
if (prev.contextCompaction !== next.contextCompaction) return false;
if (prev.messages.length !== next.messages.length) return false;
if (prev.messages === next.messages) return true;
+5 -23
View File
@@ -279,7 +279,7 @@ interface ProviderCardProps {
onEdit?: () => void;
onConnect: () => void;
onCancelConnect?: () => void;
onDisconnect: () => void | Promise<void>;
onDisconnect: () => void;
onSync: () => void;
extraActions?: React.ReactNode;
}
@@ -304,7 +304,6 @@ export const ProviderCard: React.FC<ProviderCardProps> = ({
}) => {
const { t } = useI18n();
const [disconnectConfirmOpen, setDisconnectConfirmOpen] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
const formatLastSyncLabel = (timestamp?: number): string => {
if (!timestamp) return t('cloudSync.lastSync.never');
const now = Date.now();
@@ -412,12 +411,10 @@ export const ProviderCard: React.FC<ProviderCardProps> = ({
size="sm"
variant="ghost"
onClick={() => setDisconnectConfirmOpen(true)}
disabled={disconnecting}
className="gap-1 text-muted-foreground hover:text-red-500"
className="text-muted-foreground hover:text-red-500"
aria-label={t('cloudSync.provider.disconnect')}
>
<CloudOff size={14} />
{t('cloudSync.provider.disconnect')}
</Button>
</>
) : isConnecting && onCancelConnect ? (
@@ -448,25 +445,10 @@ export const ProviderCard: React.FC<ProviderCardProps> = ({
message={t('cloudSync.provider.disconnect.confirmMessage', { name })}
confirmLabel={t('cloudSync.provider.disconnect.confirmAction')}
destructive
busy={disconnecting}
onOpenChange={(open) => {
if (!disconnecting) setDisconnectConfirmOpen(open);
}}
onOpenChange={setDisconnectConfirmOpen}
onConfirm={() => {
setDisconnecting(true);
void Promise.resolve()
.then(() => onDisconnect())
.then(() => {
setDisconnectConfirmOpen(false);
toast.info(t('cloudSync.provider.disconnect.cloudRemains', { name }), {
title: t('cloudSync.provider.disconnect.toastTitle'),
duration: 8000,
});
})
.catch(() => {
toast.error(t('cloudSync.provider.disconnect.failed', { name }));
})
.finally(() => setDisconnecting(false));
setDisconnectConfirmOpen(false);
onDisconnect();
}}
/>
</div>
-1
View File
@@ -200,7 +200,6 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
customGroups={customGroups}
selectedHostIds={selectedHostIds}
multiSelect
allowLocalHosts={true}
onSelect={onSelectHost}
onSelectionChange={onSelectionChange}
onConfirm={handleTargetsConfirm}
+4 -6
View File
@@ -10,7 +10,7 @@ import {
sftpSourceSessionIdForHost,
type SftpConnectedHostEntry,
} from '../../domain/sftpConnectedHosts';
import { canOpenDualPaneSftp } from '../../domain/sftpDualPaneOpen';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { getQuickSwitcherRowStateClass, shouldUseQuickSwitcherPointerNavigation } from '../QuickSwitcher';
@@ -95,11 +95,9 @@ const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
const filteredHosts = useMemo(() => {
return hosts.filter((h) => {
// SFTP is an SSH-specific host capability. Reuse the dual-pane
// opener's predicate so serial, local-shell (no SSH channel —
// selecting one would SSH into localhost) and plugin protocols
// are excluded the same way everywhere.
if (!canOpenDualPaneSftp(h)) return false;
// SFTP is an SSH-specific host capability. Plugin protocols may
// provide arbitrary transports and cannot be treated as SSH.
if (h.protocol === "serial" || isPluginHostProtocol(h.protocol)) return false;
// Hide a saved host only when Connected already shows the same endpoint.
// If the vault host was edited after connect, keep both: Live (old) + Saved (new).
const connected = connectedByHostId.get(h.id);
@@ -2,19 +2,11 @@
* Persistent command history store for terminal autocomplete.
* Stores commands per host with frequency tracking and timestamp ordering.
* Uses localStorageAdapter as the persistence layer (works in renderer process).
*
* Lives in application state rather than the UI layer so the vault hook and the
* terminal UI depend on one lower-layer API, and so every renderer (main window
* and detached terminal popups) shares the same clear semantics.
*/
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
STORAGE_KEY_COMMAND_HISTORY,
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
const STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
const STORAGE_KEY = "netcatty:commandHistory";
const MAX_ENTRIES = 10000;
const MAX_ENTRIES_PER_HOST = 5000;
@@ -34,21 +26,9 @@ export interface HistoryEntry {
interface HistoryStore {
entries: HistoryEntry[];
version: number;
/**
* Monotonic counter bumped by every global clear. Other renderers compare it
* to detect a clear performed elsewhere, and in-flight histfile imports use
* it to discard work that started before the clear.
*/
clearGeneration?: number;
}
let cachedStore: HistoryStore | null = null;
/** Last clear generation this renderer has observed. */
let knownClearGeneration: number | null = null;
function readClearGeneration(store: HistoryStore): number {
return typeof store.clearGeneration === "number" ? store.clearGeneration : 0;
}
function loadStore(): HistoryStore {
if (cachedStore) return cachedStore;
@@ -56,29 +36,18 @@ function loadStore(): HistoryStore {
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (parsed) {
cachedStore = parsed;
knownClearGeneration = readClearGeneration(parsed);
return parsed;
}
} catch {
// Corrupted data, reset
}
cachedStore = { entries: [], version: 1, clearGeneration: 0 };
knownClearGeneration = 0;
cachedStore = { entries: [], version: 1 };
return cachedStore;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function persistStoreNow(store: HistoryStore): boolean {
// A peer clear is visible in storage before its queued event is handled.
// Never flush a cached pre-clear snapshot over that newer empty store.
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
}
const ok = localStorageAdapter.write(STORAGE_KEY, store);
if (ok) return true;
// Storage full — evict lowest scored entries (not just oldest by insertion)
@@ -98,13 +67,6 @@ function saveStore(store: HistoryStore): void {
}, 500);
}
function cancelPendingSave(): void {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
}
/**
* Flush any pending debounced history write immediately.
* Used after bulk imports (e.g. local histfile seeding) so a seed-complete
@@ -113,112 +75,13 @@ function cancelPendingSave(): void {
*/
export function flushCommandHistoryStore(): boolean {
if (!cachedStore) return true;
cancelPendingSave();
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
return persistStoreNow(cachedStore);
}
/**
* React to a write performed by another renderer (main window or a detached
* terminal popup).
*
* A bumped clear generation drops the cache and pending save, then notifies
* live autocomplete controllers. Ordinary peer writes must not cancel a local
* command that is still waiting for its debounced save.
*/
function applyExternalCommandHistoryWrite(
rawNewValue: string | null,
rawOldValue: string | null,
): void {
let incomingGeneration: number | null = null;
if (rawNewValue) {
try {
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
} catch {
incomingGeneration = null;
}
}
let previousGeneration = knownClearGeneration;
if (previousGeneration === null) {
// A fresh popup can have compose recall before its first autocomplete query.
// The event's old value is its baseline, even after an earlier global clear.
try {
previousGeneration = rawOldValue
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
: 0;
} catch {
previousGeneration = 0;
}
}
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
cancelPendingSave();
cachedStore = null;
knownClearGeneration = incomingGeneration;
notifyCommandHistoryCleared();
} else {
if (!saveTimer) cachedStore = null;
if (incomingGeneration !== null && knownClearGeneration === null) {
knownClearGeneration = incomingGeneration;
}
}
}
// The native `storage` event fires in *other* same-origin windows (main window
// and detached terminal popups) when a key changes there.
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY) applyExternalCommandHistoryWrite(event.newValue, event.oldValue);
});
}
/**
* Current clear generation. Callers capture this before async work and compare
* afterwards to detect a clear that happened while they were waiting.
*/
export function getCommandHistoryClearGeneration(): number {
if (knownClearGeneration === null) loadStore();
return knownClearGeneration ?? 0;
}
/**
* Record that the user explicitly cleared all command history.
*
* Histfile seeding honors this tombstone: a clear is a user decision to delete
* locally recorded history, so a later first-time import must not restore the
* commands the user deleted (a pre-clear in-flight check alone cannot catch a
* clear that happened before the import started).
*/
export function markCommandHistoryCleared(): void {
localStorageAdapter.writeBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED, true);
}
/** True once the user has cleared all command history at least once. */
export function hasCommandHistoryBeenCleared(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED) === true;
}
type CommandHistoryClearedListener = () => void;
const clearedListeners = new Set<CommandHistoryClearedListener>();
/**
* Subscribe to command-history clears, including clears performed in another
* renderer. Live autocomplete controllers use this to drop visible suggestions
* and ghost text and abort in-flight completions, so a stale row cannot be
* accepted and recorded after the history was cleared.
*/
export function subscribeCommandHistoryCleared(
listener: CommandHistoryClearedListener,
): () => void {
clearedListeners.add(listener);
return () => {
clearedListeners.delete(listener);
};
}
function notifyCommandHistoryCleared(): void {
for (const listener of [...clearedListeners]) listener();
}
/**
* Record a command execution. Updates frequency if the command already exists
* for this host, otherwise creates a new entry.
@@ -286,7 +149,10 @@ export function removeCommandHistoryEntry(command: string, hostId: string): bool
if (nextEntries.length === store.entries.length) return false;
store.entries = nextEntries;
cancelPendingSave();
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
return persistStoreNow(store);
}
@@ -561,8 +427,6 @@ function fuzzyScore(query: string, target: string): number {
/**
* Clear all history for a specific host, or all history if no hostId given.
* A global clear also bumps the shared clear generation so other renderers and
* in-flight histfile imports can detect it.
*/
export function clearHistory(hostId?: string): void {
const store = loadStore();
@@ -570,12 +434,6 @@ export function clearHistory(hostId?: string): void {
store.entries = store.entries.filter((e) => e.hostId !== hostId);
} else {
store.entries = [];
store.clearGeneration = readClearGeneration(store) + 1;
}
// Destructive clears must be durable: cancel any pending debounced write and
// persist synchronously so a later stale flush cannot restore the entries.
cancelPendingSave();
persistStoreNow(store);
knownClearGeneration = readClearGeneration(store);
if (!hostId) notifyCommandHistoryCleared();
saveStore(store);
}
@@ -14,7 +14,7 @@ import {
queryRecentHistoryByCommand,
fuzzyQueryHistory,
type HistoryQueryOptions,
} from "../../../application/state/commandHistoryStore";
} from "./commandHistoryStore";
import {
loadSpec,
hasSpec,
+1 -1
View File
@@ -2,5 +2,5 @@ export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTer
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
export { default as AutocompletePopup } from "./AutocompletePopup";
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "../../../application/state/commandHistoryStore";
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "./commandHistoryStore";
export { shellEscape } from "./completionEngine";
@@ -54,7 +54,7 @@ Object.defineProperty(globalThis, "window", {
configurable: true,
});
const { clearHistory, queryHistory } = await import("../../../application/state/commandHistoryStore.ts");
const { clearHistory, queryHistory } = await import("./commandHistoryStore.ts");
const { seedLocalShellHistoryFromHistfiles } = await import("./localShellHistorySeed.ts");
const { getCompletions } = await import("./completionEngine.ts");
@@ -20,12 +20,7 @@ import {
parseZshHistory,
} from "../../../domain/remoteHistory";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import {
flushCommandHistoryStore,
getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared,
recordCommand,
} from "../../../application/state/commandHistoryStore";
import { flushCommandHistoryStore, recordCommand } from "./commandHistoryStore";
const SEED_FLAG_PREFIX = "netcatty:localHistSeeded:";
const MAX_SEED_COMMANDS = 500;
@@ -98,18 +93,6 @@ async function seedLocalShellHistoryFromHistfilesOnce(
): Promise<number> {
if (!hostId || alreadySeeded(hostId)) return 0;
// A clear is a user decision to delete locally recorded history. Never
// re-import histfiles for a host that was not seeded before that clear, or
// the deleted commands would reappear on the next Local Terminal open.
if (hasCommandHistoryBeenCleared()) {
markSeeded(hostId);
return 0;
}
// Capture the clear generation before the bridge reads so a clear that lands
// while they are in flight can be detected afterwards.
const startClearGeneration = getCommandHistoryClearGeneration();
const bridge = getBridge();
if (!bridge?.getHomeDir || !bridge.readLocalFile) return 0;
@@ -128,17 +111,6 @@ async function seedLocalShellHistoryFromHistfilesOnce(
readTextFile(bridge, joinHomePath(home, ".config/fish/fish_history")),
]);
// The user cleared history while the histfile reads were in flight. Discard
// the import and mark this host seeded so a later Local Terminal open cannot
// resurrect the commands that were just deleted.
if (
hasCommandHistoryBeenCleared()
|| getCommandHistoryClearGeneration() !== startClearGeneration
) {
markSeeded(hostId);
return 0;
}
const lists = [
zshText ? parseZshHistory(zshText) : [],
bashText ? parseBashHistory(bashText) : [],
@@ -3,7 +3,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { GhostTextAddon } from "./GhostTextAddon";
import type { AutocompleteSettings } from "./useTerminalAutocomplete";
import { getAlignedPrompt } from "./promptDetector";
import { recordCommand } from "../../../application/state/commandHistoryStore";
import { recordCommand } from "./commandHistoryStore";
import { getCommandToRecordOnEnter } from "./terminalAutocompletePrompt";
interface TerminalAutocompleteInputContext {
@@ -17,7 +17,7 @@ import {
} from "./promptDetector";
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
import type { Snippet } from "../../../domain/models";
import { recordCommand, subscribeCommandHistoryCleared } from "../../../application/state/commandHistoryStore";
import { recordCommand } from "./commandHistoryStore";
import { seedLocalShellHistoryFromHistfiles } from "./localShellHistorySeed";
import { shellEscape } from "./completionEngine";
import { preloadCommonSpecs } from "./figSpecLoader";
@@ -1313,32 +1313,6 @@ export function useTerminalAutocomplete(
});
}, [termRef, writeToTerminal, repositionPopup]);
/**
* Drop every trace of the popup when command history is cleared, including a
* clear performed in another renderer (e.g. the main window while this
* detached popup is open).
*
* Mirrors the Escape path: live preview writes the highlighted candidate into
* the shell line, so hiding the popup alone would leave the deleted command
* ready for Enter, with `lastAcceptedCommandRef` still pointing at it.
* Restore the typed baseline and reset the refs first.
*/
const handleCommandHistoryCleared = useCallback(() => {
if (previewActiveRef.current) {
renderPreviewSelection(-1); // restore the typed baseline
}
clearState();
previewActiveRef.current = false;
// Drop a command accepted before the clear so a fast Enter cannot record it
// through the accepted-command path (the line buffer is authoritative now).
lastAcceptedCommandRef.current = null;
}, [clearState, renderPreviewSelection]);
useEffect(
() => subscribeCommandHistoryCleared(handleCommandHistoryCleared),
[handleCommandHistoryCleared],
);
/** Accept a snippet: clear the user's typed input, then run it via the
* host-canonical send path (onAcceptSnippet). */
const acceptSnippet = useCallback((snippet: Snippet): boolean => {
+1 -1
View File
@@ -114,7 +114,7 @@ const {
clearHistory,
recordCommand,
removeCommandHistoryEntry,
} = await import("../../application/state/commandHistoryStore.ts");
} = await import("./autocomplete/commandHistoryStore.ts");
const {
normalizePathTokenForLookup,
shouldPreferRemoteShellCwd,
@@ -1,104 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
const noop = () => undefined;
const makeCtx = (
host: Record<string, unknown>,
startLocalSession: (options: Record<string, unknown>) => Promise<string>,
) => ({
host: {
id: "host-1",
label: "Local Shell",
hostname: "localhost",
username: "",
protocol: "local",
...host,
},
keys: [],
identities: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend: {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
etAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startLocalSession,
onSessionData: () => noop,
onSessionExit: () => noop,
writeToSession: noop,
resizeSession: noop,
},
sessionRef: { current: null },
hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
});
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
test("startLocal omits an empty no-args snapshot so backend defaults apply", async () => {
// Vault local hosts snapshot absent args as [] to keep launch-time no-args
// semantics for clone/popup relaunch, but the snapshot must not be passed to
// the bridge: an empty array is truthy and would suppress the bridge's
// discovered-shell args and getLocalShellArgs fallbacks (login flags,
// PowerShell -NoLogo).
let started: Record<string, unknown> | null = null;
const ctx = makeCtx(
{ localShell: "fish", localShellArgs: [] },
async (options) => {
started = options;
return "local-session";
},
);
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
assert.ok(started);
assert.equal((started as { shell?: string }).shell, "fish");
assert.equal((started as { shellArgs?: string[] }).shellArgs, undefined);
});
test("startLocal passes explicit local shell args through", async () => {
let started: Record<string, unknown> | null = null;
const ctx = makeCtx(
{ localShell: "bash", localShellArgs: ["--login", "-i"] },
async (options) => {
started = options;
return "local-session";
},
);
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
assert.ok(started);
assert.equal((started as { shell?: string }).shell, "bash");
assert.deepEqual((started as { shellArgs?: string[] }).shellArgs, ["--login", "-i"]);
});
@@ -3653,13 +3653,7 @@ test("restored local reconnect runs the host startup command while automatic ret
assert.deepEqual(sessionWrites, []);
});
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
{ name: "inherits global directory when cleared", hostDirectory: "", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
{ name: "leaves directory unset for backend home default", hostDirectory: undefined, globalDirectory: undefined, expectedDirectory: undefined },
]) {
test(`local session start ${name}`, async () => {
test("local session start uses per-session directory before global default", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
@@ -3691,17 +3685,16 @@ test(`local session start ${name}`, async () => {
hostname: "local",
username: "",
protocol: "local",
localStartDir: hostDirectory,
localStartDir: "/Users/alice/project",
},
terminalSettings: { localStartDir: globalDirectory },
terminalSettings: { localStartDir: "/Users/alice/default" },
terminalBackend,
});
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
assert.equal(capturedOptions?.cwd, expectedDirectory);
assert.equal(capturedOptions?.cwd, "/Users/alice/project");
});
}
test("local session restores cwd before startup command after attaching", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
@@ -1835,14 +1835,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// which was already resolved to command+args and stored on the session object by App.tsx.
// Only pass shell/shellArgs when we have concrete per-session values;
// otherwise omit them so the backend uses its own default shell detection.
// An empty array is truthy, so `|| undefined` does not filter it out.
// Vault local hosts snapshot absent args as `[]` (to preserve
// launch-time no-args semantics for clone/popup relaunch), but the
// snapshot must still be omitted here: the bridge only applies its
// discovered-shell args and getLocalShellArgs fallbacks when shellArgs
// is absent (#1221).
const sessionShell = ctx.host.localShell;
const sessionShellArgs = ctx.host.localShellArgs?.length ? ctx.host.localShellArgs : undefined;
const sessionShellArgs = ctx.host.localShellArgs;
const localStartDir = ctx.host.localStartDir || ctx.terminalSettings?.localStartDir;
const id = await ctx.terminalBackend.startLocalSession({
@@ -1850,7 +1844,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
cols: term.cols,
rows: term.rows,
shell: sessionShell || undefined,
shellArgs: sessionShellArgs,
shellArgs: sessionShellArgs || undefined,
cwd: localStartDir,
env: {
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
@@ -81,21 +81,3 @@ test("single-channel / network-device sessions skip remote path listings", () =>
assert.match(source, /skipPathCompletion: isNetworkDeviceRef\.current,/);
});
test("clearing command history restores the live-preview baseline (#3632)", () => {
const source = readFileSync(
fileURLToPath(new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url)),
"utf8",
);
// A live-previewed candidate is written into the shell line, so the clear
// handler must mirror the Escape path: restore the typed baseline, reset the
// preview/accepted refs, and only then hide the popup.
assert.match(
source,
/const handleCommandHistoryCleared = useCallback\(\(\) => \{\s*if \(previewActiveRef\.current\) \{\s*renderPreviewSelection\(-1\);[\s\S]*?clearState\(\);\s*previewActiveRef\.current = false;[\s\S]*?lastAcceptedCommandRef\.current = null;\s*\}, \[clearState, renderPreviewSelection\]\);/,
);
assert.match(
source,
/useEffect\(\s*\(\) => subscribeCommandHistoryCleared\(handleCommandHistoryCleared\),\s*\[handleCommandHistoryCleared\],\s*\);/,
);
});
@@ -584,7 +584,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
addDraftFiles={aiConfig.addDraftFiles}
removeDraftFile={aiConfig.removeDraftFile}
createSession={aiConfig.createSession}
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
deleteSession={aiConfig.deleteSession}
updateSessionTitle={aiConfig.updateSessionTitle}
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
@@ -592,7 +591,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
updateLastMessage={aiConfig.updateLastMessage}
updateMessageById={aiConfig.updateMessageById}
persistContextCompaction={aiConfig.persistContextCompaction}
forkSessionFromMessage={aiConfig.forkSessionFromMessage}
providers={aiConfig.providers}
activeProviderId={aiConfig.activeProviderId}
activeModelId={aiConfig.activeModelId}
@@ -695,7 +693,6 @@ export interface TerminalLayerProps {
onAddKnownHost?: (knownHost: KnownHost) => void;
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onDeleteShellHistoryEntry?: (entryId: string) => void;
onClearCommandHistory?: () => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
@@ -461,7 +461,6 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
followAppTerminalTheme: s.followAppTerminalTheme,
handleHistoryPaste: s.handleHistoryPaste,
handleHistoryDelete: s.handleHistoryDelete,
handleHistoryClear: s.handleHistoryClear,
handleHistoryRun: s.handleHistoryRun,
handleFocusSidePanelPane: s.handleFocusSidePanelPane,
handleMagnifySidePanelPane: s.handleMagnifySidePanelPane,
@@ -493,7 +493,6 @@ function SidePanelHistorySlotInner({
HistorySidePanel,
handleHistoryPaste,
handleHistoryDelete,
handleHistoryClear,
handleHistoryRun,
} = ctx;
@@ -508,7 +507,6 @@ function SidePanelHistorySlotInner({
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
onFetch={remoteHistory.fetch}
onDeleteGlobalEntry={handleHistoryDelete}
onClearGlobalHistory={handleHistoryClear}
onPasteToTerminal={handleHistoryPaste}
onRunInTerminal={handleHistoryRun}
isVisible
@@ -234,7 +234,6 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'themeMountedTabIds',
'handleHistoryPaste',
'handleHistoryDelete',
'handleHistoryClear',
'handleHistoryRun',
'handleOpenHistory',
'HistorySidePanel',
-1
View File
@@ -66,7 +66,6 @@ export const terminalLayerAreEqual = (
prev.onUpdateHost === next.onUpdateHost &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.onDeleteShellHistoryEntry === next.onDeleteShellHistoryEntry &&
prev.onClearCommandHistory === next.onClearCommandHistory &&
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
+1 -1
View File
@@ -86,7 +86,7 @@ const SessionTabIcon: React.FC<{
// Local protocol → shell-specific icon if available, else OS-specific icon
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
// Use shell icon from discovery when available
const iconId = shellIcon ?? host?.localShellIcon;
const iconId = shellIcon || host?.localShellIcon;
if (iconId) {
return (
<img
+4 -5
View File
@@ -27,7 +27,6 @@ import {
import type { GroupNode, Host } from "../../domain/models";
import { isPluginHostProtocol } from "../../domain/pluginConnection";
import { OpenDualPaneSftpMenuItem } from "../host/HostTreeContextMenus";
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
type VaultHostListSectionContext = Record<string, any>;
@@ -599,7 +598,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "grid" && renderHostEditButton(host)}
@@ -719,7 +718,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "grid" && renderHostEditButton(host)}
@@ -1098,7 +1097,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "grid" && renderHostEditButton(host)}
@@ -1248,7 +1247,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "grid" && renderHostEditButton(host)}
+12 -102
View File
@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Combobox } from "../ui/combobox";
import React, { useCallback } from "react";
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
import { VaultGroupDeletionConfirmationChangedError } from "../../application/state/useVaultGroupDeletion";
@@ -13,7 +12,6 @@ import {
} from "../../domain/vaultGroupSelection";
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
import { VaultHostListSection } from "./VaultHostListSection";
import { LocalShellHostDetailsPanel } from "../LocalShellHostDetailsPanel";
import { VaultImportProgressPanel } from "./ImportVaultDialog";
import {
VaultHeaderSearch,
@@ -174,7 +172,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
Edit2,
editingGroupPath,
editingHost,
editingLocalShellHost,
editingHostGroupDefaults,
FileCode,
FileSymlink,
@@ -198,9 +195,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
handleHostConnect,
handleImportFileSelected,
handleNewHost,
handleNewLocalShellHost,
handleCancelLocalShellHost,
handleSaveLocalShellHost,
handleProtocolSelect,
handleQuickConnect,
handleQuickConnectSaveHost,
@@ -220,7 +214,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
isDeleteGroupOpen,
isGroupPanelOpen,
isHostPanelOpen,
isLocalShellHostPanelOpen,
isHostsSectionActive,
isImportOpen,
isMultiSelectMode,
@@ -244,7 +237,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
managedSources,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
Network,
newFolderName,
newHostGroupPath,
@@ -388,9 +380,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
} = ctx;
const { importProgress, onCommitPluginImporterData, resetImportProgress } =
ctx;
// Host aside panel (SSH/new host) or the local-shell host panel occupies the
// aside area — both collapse the header action buttons the same way.
const isHostAsideOpen = isHostPanelOpen || isLocalShellHostPanelOpen;
const pendingDeleteGroupPaths =
bulkDeleteGroupPaths.length > 0
? bulkDeleteGroupPaths
@@ -447,8 +436,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
resizeAriaLabel: t("vault.panel.resizeWidth"),
};
const [isMoveHostsOpen, setIsMoveHostsOpen] = React.useState(false);
const [moveTargetGroup, setMoveTargetGroup] = React.useState("");
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
const keyListRef = React.useRef(keys);
keyListRef.current = keys;
@@ -534,7 +521,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
);
React.useEffect(() => {
if (!isHostAsideOpen) return;
if (!isHostPanelOpen) return;
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) return;
if (
@@ -543,7 +530,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
) {
activeElement.blur();
}
}, [isHostAsideOpen]);
}, [isHostPanelOpen]);
return (
<div
@@ -974,12 +961,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
ref={newHostActionsRef}
className={cn(
"flex items-center app-no-drag overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostAsideOpen
isHostPanelOpen
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
: "max-w-[260px] opacity-100",
)}
aria-hidden={isHostAsideOpen ? true : undefined}
inert={isHostAsideOpen ? true : undefined}
aria-hidden={isHostPanelOpen ? true : undefined}
inert={isHostPanelOpen ? true : undefined}
>
<Dropdown>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
@@ -987,7 +974,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
size="sm"
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
onClick={handleNewHost}
tabIndex={isHostAsideOpen ? -1 : 0}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Plus size={14} className="mr-2" />{" "}
{t("vault.hosts.newHost")}
@@ -996,20 +983,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
<Button
size="sm"
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
tabIndex={isHostAsideOpen ? -1 : 0}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="end" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={handleNewLocalShellHost}
>
<TerminalSquare size={14} /> {t("vault.hosts.newLocalShell")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
@@ -1049,19 +1029,19 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
ref={sessionActionsRef}
className={cn(
"flex items-center gap-3 overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
isHostAsideOpen
isHostPanelOpen
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
: "max-w-[320px] opacity-100",
)}
aria-hidden={isHostAsideOpen ? true : undefined}
inert={isHostAsideOpen ? true : undefined}
aria-hidden={isHostPanelOpen ? true : undefined}
inert={isHostPanelOpen ? true : undefined}
>
<Button
size="sm"
variant="secondary"
className={vaultHeaderSecondaryButtonClass}
onClick={onCreateLocalTerminal}
tabIndex={isHostAsideOpen ? -1 : 0}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<TerminalSquare size={14} className="mr-2" />{" "}
{t("common.terminal")}
@@ -1071,7 +1051,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
variant="secondary"
className={vaultHeaderSecondaryButtonClass}
onClick={() => setIsSerialModalOpen(true)}
tabIndex={isHostAsideOpen ? -1 : 0}
tabIndex={isHostPanelOpen ? -1 : 0}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
@@ -1134,19 +1114,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
count: selectedHostIds.size,
})}
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 px-2 text-xs"
disabled={selectedHostIds.size === 0}
onClick={() => {
setMoveTargetGroup("");
setIsMoveHostsOpen(true);
}}
>
<FolderTree size={12} className="mr-1" />
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
</Button>
<Button
variant="destructive"
size="sm"
@@ -1560,26 +1527,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
{...vaultHostPanelResizeProps}
/>
)}
{/* Local Shell Host Details Panel - for editing/saving local shell (CMD) hosts */}
{currentSection === "hosts" &&
isLocalShellHostPanelOpen && (
<LocalShellHostDetailsPanel
key={editingLocalShellHost?.id ?? "local-shell-new"}
initialData={editingLocalShellHost}
groups={allGroupPaths}
defaultGroup={
editingLocalShellHost
? undefined
: newHostGroupPath || selectedGroupPath
}
onSave={(host) => handleSaveLocalShellHost(host)}
onSaveAndConnect={(host) => handleSaveLocalShellHost(host, true)}
onCancel={handleCancelLocalShellHost}
layout="inline"
{...vaultHostPanelResizeProps}
/>
)}
</div>
</div>
@@ -1811,43 +1758,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</DialogContent>
</Dialog>
<Dialog open={isMoveHostsOpen} onOpenChange={setIsMoveHostsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}</DialogTitle>
<DialogDescription>{t("vault.hosts.moveSelected.description")}</DialogDescription>
</DialogHeader>
<Combobox
options={[
{ value: "", label: t("vault.hosts.allHosts") },
...allGroupPaths
.filter((path: string) => path !== "General"
|| customGroups.includes("General")
|| allGroupPaths.some((group: string) => group.startsWith("General/")))
.map((path: string) => ({ value: path, label: path })),
]}
value={moveTargetGroup}
onValueChange={setMoveTargetGroup}
ariaLabel={t("hostDetails.group.placeholder")}
placeholder={t("vault.hosts.allHosts")}
clearable={false}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsMoveHostsOpen(false)}>{t("common.cancel")}</Button>
<Button
disabled={selectedHostIds.size === 0}
onClick={() => {
moveHostsToGroup(selectedHostIds, moveTargetGroup || null);
setIsMoveHostsOpen(false);
clearHostSelection();
}}
>
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ImportVaultDialog
open={isImportOpen}
onOpenChange={setIsImportOpen}
@@ -1,85 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host, ManagedSource } from '../../types';
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
const host = (id: string, extra: Partial<Host> = {}): Host => ({
id, label: `Host ${id}`, hostname: `${id}.example`, username: 'user', port: 22,
tags: ['keep'], group: 'Old', ...extra,
});
test('bulk group move publishes all selected hosts together and preserves unselected hosts', async () => {
const original = [host('a'), host('b', { protocol: 'telnet' }), host('c')];
const writes: Host[][] = [];
await withActions(original, [], actions => {
act(() => actions.moveHostsToGroup(new Set(['a', 'b']), 'New/Nested'));
assert.equal(writes.length, 1);
assert.deepEqual(writes[0].map(h => h.group), ['New/Nested', 'New/Nested', 'Old']);
assert.equal(writes[0][2], original[2]);
assert.deepEqual(writes[0][0].tags, ['keep']);
assert.equal(writes[0][0].hostname, original[0].hostname);
}, writes);
});
test('mixed protocols entering and leaving managed groups retain single-host move rules', async () => {
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
const writes: Host[][] = [];
await withActions(original, sources, actions => {
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), 'Managed/Nested'));
assert.equal(writes.length, 1);
assert.equal(writes[0][0].managedSourceId, 'child');
assert.equal(writes[0][0].label, 'Hostssh');
assert.equal(writes[0][1].managedSourceId, undefined);
assert.equal(writes[0][1].label, 'Host telnet');
assert.equal(writes[0][2].managedSourceId, undefined);
assert.equal(writes[0][2].label, 'Host local');
}, writes);
await withActions(writes[0], sources, actions => {
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), null));
assert.deepEqual(writes[1].map(h => h.group), ['', '', '']);
assert.ok(writes[1].every(h => h.managedSourceId === undefined));
}, writes);
});
test('same-group, missing and empty selections do not publish changes', async () => {
const writes: Host[][] = [];
await withActions([host('a')], [], actions => {
act(() => actions.moveHostsToGroup(new Set(['a']), 'Old'));
act(() => actions.moveHostsToGroup(new Set(['missing']), 'New'));
act(() => actions.moveHostsToGroup(new Set(), 'New'));
assert.equal(writes.length, 0);
act(() => actions.moveHostToGroup('a', 'New'));
assert.equal(writes.length, 1);
assert.equal(writes[0][0].group, 'New');
}, writes);
});
async function withActions(
hosts: Host[], managedSources: ManagedSource[],
run: (actions: ReturnType<typeof useVaultGroupDragHandlers>) => void,
writes: Host[][],
) {
const previousWindow = globalThis.window;
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = { setTimeout: () => 1, clearTimeout: () => {} } as unknown as Window & typeof globalThis;
let renderer: ReactTestRenderer | undefined;
let actions: ReturnType<typeof useVaultGroupDragHandlers>;
function Probe() {
actions = useVaultGroupDragHandlers({ hosts, managedSources,
onUpdateHosts: next => writes.push(next), onUpdateManagedSources: () => {}, t: key => key });
return null;
}
try {
await act(async () => { renderer = create(<Probe />); });
run(actions!);
} finally {
await act(async () => renderer?.unmount());
globalThis.window = previousWindow;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
}
+40 -28
View File
@@ -66,40 +66,53 @@ export function useVaultGroupDragHandlers({
setDragOverDropTarget(path ? { kind: "group", path } : null);
}, []);
const moveHostsToGroup = useCallback((hostIds: ReadonlySet<string>, groupPath: string | null) => {
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
const targetGroup = groupPath || "";
const hostToMove = hosts.find((h) => h.id === hostId);
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
setDragOverDropTarget(null);
return;
}
// Find the most specific (deepest) managed source that matches the target group
const targetManagedSource = managedSources
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
let movedCount = 0;
const updatedHosts = hosts.map(host => {
if (!hostIds.has(host.id) || (host.group || "") === targetGroup) return host;
movedCount += 1;
const canBeManaged = !host.protocol || host.protocol === "ssh";
return {
...host,
group: targetGroup,
label: targetManagedSource && canBeManaged
? host.label.replace(/\s/g, '') : host.label,
managedSourceId: targetManagedSource && canBeManaged ? targetManagedSource.id : undefined,
};
});
const movedHost = {
...hostToMove,
group: targetGroup,
};
const updatedHost = (() => {
const h = movedHost;
// Only SSH hosts can be managed (SSH config only supports SSH)
const canBeManaged = !h.protocol || h.protocol === "ssh";
// Sanitize label if moving to a managed group (SSH config requires no spaces in Host alias)
let label = h.label;
if (targetManagedSource && canBeManaged && label) {
label = label.replace(/\s/g, '');
}
return {
...h,
label,
group: targetGroup,
managedSourceId: (targetManagedSource && canBeManaged) ? targetManagedSource.id : undefined,
};
})();
onUpdateHosts(hosts.map((host) => (host.id === hostId ? updatedHost : host)));
setDragOverDropTarget(null);
if (movedCount === 0) return;
// Publish the entire selection once; repeated single-host updates would
// each start from the same render snapshot and overwrite earlier moves.
onUpdateHosts(updatedHosts);
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
toast.success(t("vault.hosts.moveMultiple.success", {
count: movedCount,
group: groupPath || t("vault.hosts.allHosts"),
}));
toast.success(
t("vault.hosts.moveToGroup.success", {
host: hostToMove.label,
group: groupPath || t("vault.hosts.allHosts"),
}),
);
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
moveHostsToGroup(new Set([hostId]), groupPath);
}, [moveHostsToGroup]);
const getDropTargetClasses = (target: DropTarget) =>
cn(
isSameDropTarget(dragOverDropTarget, target) &&
@@ -138,7 +151,6 @@ export function useVaultGroupDragHandlers({
handleUnmanageGroup,
managedGroupPaths,
moveHostToGroup,
moveHostsToGroup,
setDragOverDropTarget,
setGroupDragOverDropTarget,
};
-46
View File
@@ -1,46 +0,0 @@
# Job startup diagnostics
This opt-in trace helps investigate issue #3443. It does not establish or repair
that issue's underlying cause, and does not change retries, recovery or job routing.
Set `NETCATTY_JOB_DIAG=1` before launching Netcatty (the existing
`NETCATTY_TERMINAL_DEBUG=1` also enables it). `[Netcatty JobDiag]` records from the
terminal worker are forwarded over its existing IPC channel to the main process
console, alongside the main-process routing records. The worker does not also
write these records to its own console. Collect the main process log from the
launch, including both stdout and stderr. For a development checkout on macOS or
Linux, for example:
```sh
NETCATTY_JOB_DIAG=1 npm run dev > job-startup.log 2>&1
```
For Windows PowerShell development launches:
```powershell
$env:NETCATTY_JOB_DIAG = "1"
npm run dev *> job-startup.log
```
The IPC collection path is covered by automated tests and a real macOS Electron
worker run. Native Windows capture still needs verification on that platform.
Correlate records using `sessionId` and `jobId` (and `chatSessionId` where present).
`probe-write` and `cmd-write` mark entry into a paced input attempt, not confirmed
transport delivery. `probe-output-arrived` means output reached the job while it
was probing, even if a partial character has not decoded yet; only
`probe-reply-parsed` proves a valid reply was parsed. Write records use `bytes`
for the outbound command size, separately from received output counters.
`probeBytes` counts raw bytes delivered to the job during probing. A zero count
does not prove the remote shell sent nothing. On a paused SSH stream,
`session.stream.readableLength` reports queued readable bytes without consuming
or resuming them; compare it with `isPaused` and the delivered-byte counters.
A `job-missing` lookup means no local job was found; it alone cannot prove a lost
worker route. Correlate it with earlier `job-start-accepted` / `route-removed` records.
`scope-mismatch`, `worker-unavailable` and `worker-job-missing` retain their
separate meanings. No new route provenance or retention is introduced.
Session snapshots include `hostname`. Redact hostnames, session/chat identifiers
and any other sensitive content from logs before sharing them. Disable the flag
after collection.
-318
View File
@@ -1,318 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildForkTitle,
canForkFromMessage,
getForkableMessageIds,
planSessionFork,
stripMessageContinuationState,
} from './aiSessionFork.ts';
import type { ForkMessage } from './aiSessionFork.ts';
let nextId = 0;
const msg = (overrides: Partial<ForkMessage>): ForkMessage => ({
id: `m${nextId++}`,
role: 'assistant',
content: '',
timestamp: 0,
...overrides,
} as ForkMessage);
const session = (messages: ForkMessage[], contextCompaction?: { summary: string; compactedMessageCount: number }) => ({
title: 'Debug deploy',
messages,
contextCompaction,
});
test('planSessionFork keeps the original untouched and plans a copy through the boundary', () => {
const user = msg({ role: 'user', content: 'run tests' });
const assistant = msg({ content: 'done, all green' });
const source = session([user, assistant]);
const plan = planSessionFork(source, assistant.id);
assert.equal(plan.ok, true);
if (!plan.ok) return;
assert.equal(plan.boundaryIndex, 1);
assert.deepEqual(plan.messages, [user, assistant]);
assert.equal(plan.title, 'Debug deploy (fork)');
});
test('planSessionFork rejects non-assistant and missing boundaries', () => {
const source = session([
msg({ role: 'user', content: 'hi' }),
]);
assert.deepEqual(planSessionFork(source, source.messages[0].id), { ok: false, reason: 'not-assistant' });
assert.deepEqual(planSessionFork(source, 'missing'), { ok: false, reason: 'message-not-found' });
});
test('planSessionFork rejects failed, cancelled, running, and streaming boundaries', () => {
for (const executionStatus of ['failed', 'cancelled', 'running', 'pending']) {
const source = session([msg({ content: 'x', executionStatus })]);
assert.deepEqual(planSessionFork(source, source.messages[0].id), {
ok: false,
reason: 'not-completed',
});
}
const streamingSource = session([msg({ statusText: 'Waiting for response…' })]);
assert.deepEqual(planSessionFork(streamingSource, streamingSource.messages[0].id), {
ok: false,
reason: 'not-completed',
});
const failedSource = session([msg({ errorInfo: { type: 'provider', message: 'boom', retryable: true } })]);
assert.deepEqual(planSessionFork(failedSource, failedSource.messages[0].id), {
ok: false,
reason: 'not-completed',
});
const pendingSource = session([msg({ pendingApproval: {} })]);
assert.deepEqual(planSessionFork(pendingSource, pendingSource.messages[0].id), {
ok: false,
reason: 'not-completed',
});
});
test('planSessionFork rejects boundaries with dangling tool calls', () => {
const source = session([
msg({ role: 'user', content: 'hi' }),
msg({ content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ content: '', toolCalls: [{ id: 'call-2' }] }),
msg({
role: 'tool',
content: 'ok',
toolResults: [{ toolCallId: 'call-1', content: 'out' }],
}),
msg({ content: 'final answer' }),
]);
// call-2 never got a result inside the retained prefix
assert.deepEqual(planSessionFork(source, source.messages[4].id), {
ok: false,
reason: 'dangling-tool-call',
});
// forking at the tool-issuing assistant turns is not possible anyway
assert.deepEqual(planSessionFork(source, source.messages[2].id), {
ok: false,
reason: 'dangling-tool-call',
});
});
test('planSessionFork forks at the first turn boundary after results resolve', () => {
const source = session([
msg({ role: 'user', content: 'hi' }),
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'tool', content: 'ok', toolResults: [{ toolCallId: 'call-1', content: 'out' }] }),
msg({ role: 'assistant', content: 'all done' }),
]);
assert.deepEqual(planSessionFork(source, source.messages[1].id), {
ok: false,
reason: 'dangling-tool-call',
});
const plan = planSessionFork(source, source.messages[3].id);
assert.equal(plan.ok, true);
});
test('planSessionFork rejects boundaries with dangling tool results', () => {
const source = session([
msg({ role: 'assistant', content: 'x' }),
msg({ role: 'tool', content: 'ok', toolResults: [{ toolCallId: 'orphan', content: 'out' }] }),
msg({ role: 'assistant', content: 'answer' }),
]);
assert.deepEqual(planSessionFork(source, source.messages[2].id), {
ok: false,
reason: 'dangling-tool-result',
});
});
test('planSessionFork lets the compaction summary ride along while it stays accurate', () => {
const source = session(
[msg({ role: 'user', content: 'hi' }), msg({ content: 'done' })],
{ summary: 'earlier talk', compactedMessageCount: 1 },
);
const plan = planSessionFork(source, source.messages[1].id);
assert.equal(plan.ok, true);
if (plan.ok) {
assert.deepEqual(plan.contextCompaction, { summary: 'earlier talk', compactedMessageCount: 1 });
}
});
test('planSessionFork refuses when the compaction summary would cover discarded messages', () => {
const early = msg({ content: 'early turn' });
const source = session(
[early, msg({ content: 'later turn' })],
{ summary: 'covers both turns', compactedMessageCount: 2 },
);
assert.deepEqual(planSessionFork(source, early.id), {
ok: false,
reason: 'compaction-covers-discarded',
});
});
test('planSessionFork preserves provider replay metadata but strips turn state', () => {
const continuation = { reasoningParts: [{ text: 'chain of thought' }] };
const user = msg({ role: 'user', content: 'hi', providerContinuation: continuation, statusText: 'streaming…' });
const assistant = msg({ content: 'answer', providerContinuation: continuation });
const source = session([user, assistant]);
const plan = planSessionFork(source, assistant.id);
assert.equal(plan.ok, true);
if (plan.ok) {
for (const message of plan.messages) {
assert.equal(message.providerContinuation, continuation);
assert.equal(message.statusText, undefined);
}
}
// untouched source keeps its original message objects and turn state
assert.equal(source.messages[0].providerContinuation, continuation);
assert.equal(source.messages[0].statusText, 'streaming…');
});
test('planSessionFork pairs repeated tool-call ids by occurrence', () => {
const source = session([
msg({ role: 'user', content: 'hi' }),
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'tool', content: 'first', toolResults: [{ toolCallId: 'call-1', content: 'out 1' }] }),
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'assistant', content: 'answer' }),
]);
// The provider reused call-1; the second occurrence never got a result.
assert.deepEqual(planSessionFork(source, source.messages[4].id), {
ok: false,
reason: 'dangling-tool-call',
});
const resolved = session([
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'tool', content: 'first', toolResults: [{ toolCallId: 'call-1', content: 'out 1' }] }),
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'tool', content: 'second', toolResults: [{ toolCallId: 'call-1', content: 'out 2' }] }),
msg({ role: 'assistant', content: 'answer' }),
]);
assert.equal(planSessionFork(resolved, resolved.messages[4].id).ok, true);
// One result cannot satisfy two pending calls with the same id.
const underResolved = session([
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }, { id: 'call-1' }] }),
msg({ role: 'tool', content: 'single', toolResults: [{ toolCallId: 'call-1', content: 'out' }] }),
msg({ role: 'assistant', content: 'answer' }),
]);
assert.deepEqual(planSessionFork(underResolved, underResolved.messages[2].id), {
ok: false,
reason: 'dangling-tool-call',
});
});
test('stripMessageContinuationState preserves values and clears transient state', () => {
const plain = msg({ content: 'x' });
assert.deepEqual(stripMessageContinuationState(plain), plain);
const withStatusText = msg({ content: 'x', statusText: 'streaming…' });
assert.notEqual(stripMessageContinuationState(withStatusText), withStatusText);
const withPendingApproval = msg({ content: 'x', pendingApproval: {} });
assert.notEqual(stripMessageContinuationState(withPendingApproval), withPendingApproval);
const continuationOnly = msg({ content: 'x', providerContinuation: {} });
assert.deepEqual(stripMessageContinuationState(continuationOnly), continuationOnly);
});
test('buildForkTitle appends a fork marker without double-suffixing', () => {
assert.equal(buildForkTitle('Debug deploy'), 'Debug deploy (fork)');
assert.equal(buildForkTitle('Debug deploy (fork)'), 'Debug deploy (fork)');
assert.equal(buildForkTitle(''), 'New Chat (fork)');
});
test('canForkFromMessage mirrors planSessionFork success', () => {
const source = session([msg({ role: 'user', content: 'hi' }), msg({ content: 'done' })]);
assert.equal(canForkFromMessage(source, source.messages[1].id), true);
assert.equal(canForkFromMessage(source, source.messages[0].id), false);
assert.equal(canForkFromMessage(source, 'nope'), false);
});
test('fork keeps output previews but removes source-only saved-output references', () => {
const assistant = msg({ content: 'preview handleId=tool-output-abc', toolResults: [{ toolCallId: 'call', content: '{"handleId":"tool-output-xyz","preview":"retained"}' }] });
const source = session([msg({ toolCalls: [{ id: 'call' }] }), assistant], { summary: 'archive handleId=tool-output-summary', compactedMessageCount: 1 });
const plan = planSessionFork(source, assistant.id);
assert.ok(plan.ok);
assert.doesNotMatch(JSON.stringify(plan), /tool-output-/);
assert.match(plan.messages[1].content, /preview/);
assert.match(plan.messages[1].toolResults![0].content!, /retained/);
assert.equal(JSON.parse(plan.messages[1].toolResults![0].content!).handleId, null);
assert.match(source.messages[1].content, /tool-output-abc/);
});
test('fork removes source output handles only from tool_output_read arguments', () => {
const read = msg({ toolCalls: [{ id: 'read', name: 'tool_output_read', arguments: { handleId: 'tool-output-source', offset: 12 } }] });
const done = msg({ toolResults: [{ toolCallId: 'read', content: 'preview' }], content: 'done' });
const source = session([read, done]);
const plan = planSessionFork(source, done.id);
assert.ok(plan.ok);
assert.equal(plan.messages[0].toolCalls![0].arguments!.handleId, undefined);
assert.equal(plan.messages[0].toolCalls![0].arguments!.offset, 12);
assert.equal(read.toolCalls![0].arguments!.handleId, 'tool-output-source');
const other = msg({ toolCalls: [{ id: 'other', name: 'custom_tool', arguments: { handleId: 'tool-output-not-owned-here' } }] });
assert.deepEqual(stripMessageContinuationState(other).toolCalls, other.toolCalls);
});
test('fork refuses source output references in provider reasoning without changing signed metadata', () => {
const continuation = { reasoningParts: [{ text: 'Read handleId=tool-output-source for details', providerOptions: { anthropic: { signature: 'valid-signature' } } }] };
const source = session([msg({ content: 'done', providerContinuation: continuation })]);
assert.deepEqual(planSessionFork(source, source.messages[0].id), { ok: false, reason: 'source-output-reasoning' });
assert.equal(canForkFromMessage(source, source.messages[0].id), false);
assert.equal(source.messages[0].providerContinuation, continuation);
assert.equal(continuation.reasoningParts[0].text, 'Read handleId=tool-output-source for details');
});
test('fork eligibility does not clone or scan retained message bodies', () => {
const assistant = msg({ content: 'completed' });
Object.defineProperty(assistant, 'content', { get() { throw new Error('eligibility should not build a transcript'); } });
assert.equal(canForkFromMessage(session([assistant]), assistant.id), true);
});
test('one-pass fork eligibility matches clicked-boundary validation', () => {
const messages = [
msg({ role: 'user', content: 'start' }),
msg({ content: 'first completed reply' }),
msg({ toolCalls: [{ id: 'reused' }, { id: 'reused' }] }),
msg({ role: 'tool', toolResults: [{ toolCallId: 'reused', content: 'one' }] }),
msg({ content: 'still unresolved' }),
msg({ role: 'tool', toolResults: [{ toolCallId: 'reused', content: 'two' }] }),
msg({ content: 'complete exchange' }),
msg({ executionStatus: 'failed' }),
msg({ content: 'unsafe reasoning', providerContinuation: { reasoningParts: [{ text: 'Read tool-output-source' }] } }),
msg({ content: 'after unsafe reasoning' }),
];
for (const compactedMessageCount of [0, 3, messages.length]) {
const source = session(messages, { summary: 'summary', compactedMessageCount });
assert.deepEqual([...getForkableMessageIds(source)], messages.filter(message => planSessionFork(source, message.id).ok).map(message => message.id));
}
const orphan = session([msg({ role: 'tool', toolResults: [{ toolCallId: 'orphan' }] }), msg({ content: 'reply' })]);
assert.equal(getForkableMessageIds(orphan).size, 0);
});
test('fork removes bare output identifiers in transcript previews while preserving JSON', () => {
const assistant = msg({ content: 'Read tool-output-source with tool_output_read', thinking: 'Output tool-output-source is saved', toolResults: [{ toolCallId: 'call', content: '{"handleId":"tool-output-source","preview":"Read tool-output-source"}' }] });
const plan = planSessionFork(session([msg({ toolCalls: [{ id: 'call' }] }), assistant]), assistant.id);
assert.ok(plan.ok);
assert.doesNotMatch(JSON.stringify(plan.messages), /tool-output-source/);
const parsed = JSON.parse(plan.messages[1].toolResults![0].content!);
assert.equal(parsed.handleId, null);
assert.match(parsed.preview, /Read/);
assert.match(assistant.content, /tool-output-source/);
});
test('fork sanitizes attachment previews without changing attachment data or identity', () => {
const attachment = { base64Data: 'SEVMTE8=', mediaType: 'text/plain', filename: 'terminal-selection.log', terminalSelection: true, lineCount: 3, filePath: '/original/selection.log', previewText: 'Read tool-output-selection with tool_output_read' };
const image = { base64Data: 'UE5H', mediaType: 'image/png', filename: 'original.png' };
const user = msg({ role: 'user', content: 'Inspect', attachments: [attachment, image], images: [attachment] });
const assistant = msg({ content: 'done' });
const plan = planSessionFork(session([user, assistant]), assistant.id);
assert.ok(plan.ok);
assert.doesNotMatch(plan.messages[0].attachments![0].previewText!, /tool-output-selection/);
assert.deepEqual({ ...plan.messages[0].attachments![0], previewText: attachment.previewText }, attachment);
assert.equal(plan.messages[0].attachments![1], image);
assert.doesNotMatch(plan.messages[0].images![0].previewText!, /tool-output-selection/);
assert.equal(user.attachments![0], attachment);
assert.equal(attachment.previewText, 'Read tool-output-selection with tool_output_read');
});
test('fork still rejects an unresolved retained call even when compaction covers it', () => {
const unresolved = msg({ toolCalls: [{ id: 'pending' }], executionStatus: 'cancelled' });
const done = msg({ content: 'later completed answer' });
const source = session([unresolved, done], { summary: 'the earlier call was interrupted', compactedMessageCount: 1 });
assert.deepEqual(planSessionFork(source, done.id), { ok: false, reason: 'dangling-tool-call' });
assert.equal(getForkableMessageIds(source).has(done.id), false);
});
-276
View File
@@ -1,276 +0,0 @@
import type { ChatMessageAttachment } from '../infrastructure/ai/types';
import type { ProviderContinuation } from '../infrastructure/ai/providerContinuation';
/**
* Pure planning logic for "Fork from here": copy an AI chat session up to a
* completed assistant response into a new session, leaving the original
* conversation untouched in history.
*
* Refusals (per the feature contract):
* - only completed assistant-response turn boundaries fork — streaming, failed,
* cancelled, or in-progress turns are rejected,
* - every tool call in the retained prefix must have a paired result and
* vice versa (no dangling calls or results on the branch),
* - a stored compaction summary that would cover messages discarded by the
* branch is refused (the summary must stay accurate for the fork),
* - incomplete-turn state (status text, pending approvals) is stripped while
* the per-message provider replay metadata is preserved, so the fresh
* provider thread replays the retained history.
*/
export type ForkRefusalReason =
| 'message-not-found'
| 'not-assistant'
| 'not-completed'
| 'dangling-tool-call'
| 'dangling-tool-result'
| 'compaction-covers-discarded'
| 'source-output-reasoning';
/** A message type that carries the fork-relevant subset (e.g. ChatMessage). */
export type ForkMessageLike = {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
thinking?: string;
providerContinuation?: ProviderContinuation;
attachments?: ReadonlyArray<ChatMessageAttachment>;
images?: ReadonlyArray<ChatMessageAttachment>;
toolCalls?: ReadonlyArray<{ id: string; name?: string; arguments?: Record<string, unknown> }>;
toolResults?: ReadonlyArray<{ toolCallId: string; content?: string; isError?: boolean }>;
errorInfo?: unknown;
statusText?: string;
pendingApproval?: unknown;
executionStatus?: string;
};
/** Structural subset of ChatMessage the fork planner needs. */
export type ForkMessage = ForkMessageLike & { timestamp?: number };
export type ForkPlan<M extends ForkMessageLike = ForkMessage> =
| {
ok: true;
boundaryIndex: number;
title: string;
messages: M[];
contextCompaction?: ForkContextCompaction;
}
| { ok: false; reason: ForkRefusalReason };
export type ForkContextCompaction = {
summary: string;
compactedMessageCount: number;
};
const INCOMPLETE_EXECUTION_STATUSES = new Set([
'pending',
'running',
'failed',
'cancelled',
]);
function isCompletedResponse(message: ForkMessageLike): boolean {
return message.role === 'assistant'
&& message.errorInfo == null
&& message.statusText == null
&& message.pendingApproval == null
&& !INCOMPLETE_EXECUTION_STATUSES.has(message.executionStatus ?? '');
}
function hasSourceOutputReasoning(message: ForkMessageLike): boolean {
return (message.providerContinuation?.reasoningParts ?? []).some(
part => /\btool-output-[A-Za-z0-9-]+/.test(part.text),
);
}
/** Eligibility only: never copy/sanitize transcript content while rendering. */
export function getForkableMessageIds(source: {
messages: readonly ForkMessageLike[];
contextCompaction?: ForkContextCompaction;
}): Set<string> {
const ids = new Set<string>();
const pending = new Map<string, number>();
let pendingCount = 0;
let orphanResult = false;
let sourceOutputReasoning = false;
for (const [index, message] of source.messages.entries()) {
sourceOutputReasoning ||= hasSourceOutputReasoning(message);
for (const call of message.toolCalls ?? []) {
if (!call.id) continue;
pending.set(call.id, (pending.get(call.id) ?? 0) + 1);
pendingCount += 1;
}
for (const result of message.toolResults ?? []) {
if (!result.toolCallId) continue;
const count = pending.get(result.toolCallId) ?? 0;
if (count === 0) orphanResult = true;
else {
pending.set(result.toolCallId, count - 1);
pendingCount -= 1;
}
}
if (isCompletedResponse(message) && pendingCount === 0
&& !orphanResult && !sourceOutputReasoning
&& (source.contextCompaction?.compactedMessageCount ?? 0) <= index + 1) {
ids.add(message.id);
}
}
return ids;
}
/**
* Validate that every tool call in the retained prefix is paired with a
* result and vice versa. Providers may reuse a tool-call id across turns, so
* each result is consumed by the nearest preceding unresolved call with the
* same id (same pairing rule as buildHistoricalToolReplayMaps): a result with
* no pending call before it is dangling, and so is a call still pending after
* the walk.
*/
function validateToolCallResultPairing(
messages: readonly ForkMessageLike[],
): 'dangling-tool-call' | 'dangling-tool-result' | null {
const pendingCallCounts = new Map<string, number>();
for (const message of messages) {
for (const toolCall of message.toolCalls ?? []) {
if (!toolCall?.id) continue;
pendingCallCounts.set(toolCall.id, (pendingCallCounts.get(toolCall.id) ?? 0) + 1);
}
for (const toolResult of message.toolResults ?? []) {
const id = toolResult?.toolCallId;
if (!id) continue;
const pendingCount = pendingCallCounts.get(id) ?? 0;
if (pendingCount === 0) return 'dangling-tool-result';
pendingCallCounts.set(id, pendingCount - 1);
}
}
for (const pendingCount of pendingCallCounts.values()) {
if (pendingCount > 0) return 'dangling-tool-call';
}
return null;
}
/**
* Strip incomplete-turn state so the branch starts clean; per-message provider
* continuation metadata (Responses reasoning items, OpenAI Chat assistant
* fields, tool-call wire options) is preserved because the fork's fresh
* provider thread replays the retained history through it and safely discards
* exchanges whose metadata no longer matches the active provider.
*/
/** Keep the preview but remove unusable source-session output references. */
function stripSourceOutputHandles(value: string): string {
return value
.replace(/\bhandleId=tool-output-[A-Za-z0-9-]+/g, '[saved output remains in the original chat]')
.replace(/"handleId"\s*:\s*"tool-output-[A-Za-z0-9-]+"/g, '"handleId":null,"note":"Saved output remains in the original chat"')
.replace(/\btool-output-[A-Za-z0-9-]+/g, '[saved output remains in the original chat]');
}
function stripAttachmentPreview<A extends { previewText?: string }>(attachment: A): A {
return attachment.previewText
? { ...attachment, previewText: stripSourceOutputHandles(attachment.previewText) }
: attachment;
}
export function stripMessageContinuationState<M extends ForkMessageLike>(message: M): M {
const next = {
...message,
content: stripSourceOutputHandles(message.content),
...(message.attachments ? { attachments: message.attachments.map(stripAttachmentPreview) } : {}),
...(message.images ? { images: message.images.map(stripAttachmentPreview) } : {}),
...(message.thinking ? { thinking: stripSourceOutputHandles(message.thinking) } : {}),
...(message.toolCalls ? {
toolCalls: message.toolCalls.map(call => {
if (call.name !== 'tool_output_read'
|| typeof call.arguments?.handleId !== 'string'
|| !call.arguments.handleId.startsWith('tool-output-')) return call;
const args = { ...call.arguments };
delete args.handleId;
return { ...call, arguments: args };
}),
} : {}),
...(message.toolResults ? {
toolResults: message.toolResults.map(result => ({
...result,
...(result.content ? { content: stripSourceOutputHandles(result.content) } : {}),
})),
} : {}),
};
delete next.statusText;
delete next.pendingApproval;
return next;
}
export function buildForkTitle(title: string): string {
const base = (title ?? '').trim() || 'New Chat';
return base.endsWith('(fork)') ? base : `${base} (fork)`;
}
/** Structural subset of AISession the fork planner needs. */
export type ForkSourceSession<M extends ForkMessageLike = ForkMessage> = {
title: string;
messages: readonly M[];
contextCompaction?: ForkContextCompaction;
};
/**
* Plan a fork at `messageId`. Returns a refusal reason when the boundary is
* not a safe completed turn, or the retained message list (trimmed of
* incomplete-turn state, keeping provider replay metadata) plus the
* carried-over compaction (when still accurate).
*/
export function planSessionFork<M extends ForkMessageLike>(
source: ForkSourceSession<M>,
messageId: string,
): ForkPlan<M> {
const boundaryIndex = source.messages.findIndex(
(message) => message.id === messageId,
);
if (boundaryIndex === -1) return { ok: false, reason: 'message-not-found' };
const boundary = source.messages[boundaryIndex];
if (boundary.role !== 'assistant') return { ok: false, reason: 'not-assistant' };
if (!isCompletedResponse(boundary)) return { ok: false, reason: 'not-completed' };
const retained = source.messages.slice(0, boundaryIndex + 1);
// Provider reasoning can be signed: replacing text or dropping only that
// block can invalidate its tool exchange. Refuse this cut without changing
// the source metadata rather than leaking an unusable source-only handle.
if (retained.some(hasSourceOutputReasoning)) {
return { ok: false, reason: 'source-output-reasoning' };
}
const pairingRefusal = validateToolCallResultPairing(retained);
if (pairingRefusal) return { ok: false, reason: pairingRefusal };
// A stored compaction summary covers the earliest `compactedMessageCount`
// persisted messages. The summary must never cover messages discarded by
// the branch, so refuse when its coverage would exceed the retained prefix.
const compaction = source.contextCompaction;
let nextCompaction: ForkContextCompaction | undefined;
if (compaction) {
if (compaction.compactedMessageCount > retained.length) {
return { ok: false, reason: 'compaction-covers-discarded' };
}
nextCompaction = {
summary: stripSourceOutputHandles(compaction.summary),
compactedMessageCount: compaction.compactedMessageCount,
};
}
return {
ok: true,
boundaryIndex,
title: buildForkTitle(source.title),
messages: retained.map(stripMessageContinuationState),
...(nextCompaction ? { contextCompaction: nextCompaction } : {}),
} as ForkPlan<M>;
}
export function canForkFromMessage(
source: {
messages: readonly ForkMessageLike[];
contextCompaction?: ForkContextCompaction;
},
messageId: string,
): boolean {
return getForkableMessageIds(source).has(messageId);
}
-245
View File
@@ -1,245 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession, ChatMessage } from '../infrastructure/ai/types.ts';
import {
buildUndoLastTurnBranch,
hasUnresolvedToolCalls,
resolveUndoLastTurnBoundary,
} from './aiUndoLastTurn.ts';
const user = (content: string): ChatMessage => ({
id: `u_${content}`,
role: 'user',
content,
timestamp: 1,
});
const assistant = (content: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
id: `a_${content}`,
role: 'assistant',
content,
timestamp: 2,
...extra,
});
const session = (messages: ChatMessage[], extra: Partial<AISession> = {}): AISession => ({
id: 'chat-1',
title: 'Chat',
agentId: 'catty',
scope: { type: 'terminal', targetId: 't1' },
messages,
createdAt: 1,
updatedAt: 2,
...extra,
});
test('resolveUndoLastTurnBoundary finds the last user message index', () => {
const messages = [user('one'), assistant('reply'), user('two'), assistant('reply2')];
assert.equal(resolveUndoLastTurnBoundary(messages), 2);
});
test('resolveUndoLastTurnBoundary returns null without a user message', () => {
assert.equal(resolveUndoLastTurnBoundary([assistant('hello')]), null);
assert.equal(resolveUndoLastTurnBoundary([]), null);
});
test('resolveUndoLastTurnBoundary refuses boundaries inside the compacted prefix', () => {
const messages = [user('compacted'), assistant('x'), user('last'), assistant('y')];
assert.equal(resolveUndoLastTurnBoundary(messages, 3), null);
assert.equal(resolveUndoLastTurnBoundary(messages, 2), 2);
});
test('resolveUndoLastTurnBoundary refuses cuts between a tool call and its result', () => {
// The call sits in the kept prefix while its result would be cut away.
const split = [
assistant('', {
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
}),
user('again?'),
assistant('', {
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
}),
];
assert.equal(resolveUndoLastTurnBoundary(split), null);
assert.ok(hasUnresolvedToolCalls(split.slice(0, 2)));
// Result already inside the kept prefix — the boundary is safe.
const resolved = [
assistant('', {
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
}),
user('again?'),
assistant('done'),
];
assert.equal(hasUnresolvedToolCalls(resolved.slice(0, 1)), false);
assert.equal(resolveUndoLastTurnBoundary(resolved), 1);
});
test('buildUndoLastTurnBranch branches before the last user turn and restores it', () => {
const source = session([
user('first'),
assistant('answer'),
user('bad prompt'),
assistant('bad answer'),
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.equal(result.session.id, 'chat-2');
assert.deepEqual(result.session.messages.map((m) => m.content), ['first', 'answer']);
assert.equal(result.restored.text, 'bad prompt');
// Original session untouched (non-destructive).
assert.equal(source.messages.length, 4);
});
test('buildUndoLastTurnBranch clears the external agent session id and keeps compaction', () => {
const source = session(
[user('compacted'), assistant('x'), user('last'), assistant('y')],
{
externalSessionId: 'prov-1',
contextCompaction: { summary: 'summary', compactedMessageCount: 2 },
},
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 7 });
assert.ok(result);
assert.equal(result.session.externalSessionId, undefined);
assert.deepEqual(result.session.contextCompaction, { summary: 'summary', compactedMessageCount: 2 });
});
test('buildUndoLastTurnBranch restores message attachments into composer uploads', () => {
const source = session([
user('with file'),
assistant('ok'),
{
id: 'u_last',
role: 'user',
content: 'look at this',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'notes.txt', lineCount: 3 },
],
},
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 99 });
assert.ok(result);
assert.deepEqual(result.restored.attachments, [
{
id: 'undo_99_0',
filename: 'notes.txt',
mediaType: 'text/plain',
base64Data: 'QUJD',
dataUrl: 'data:text/plain;base64,QUJD',
lineCount: 3,
},
]);
});
test('buildUndoLastTurnBranch restores the undone turn selected user skills', () => {
const source = session([
user('first'),
assistant('answer'),
{ ...user('undo me'), selectedUserSkillSlugs: ['web-search', 'notes-writer'] },
assistant('bad answer'),
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.deepEqual(result.restored.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
// Original session untouched (non-destructive).
assert.deepEqual(source.messages[2]?.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
});
test('buildUndoLastTurnBranch restores no skill slugs for a plain turn', () => {
const source = session([user('first'), assistant('answer'), user('undo me'), assistant('ok')]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.deepEqual(result.restored.selectedUserSkillSlugs, []);
});
test('buildUndoLastTurnBranch returns null when no undo boundary exists', () => {
assert.equal(
buildUndoLastTurnBranch(session([assistant('only answer')]), { newId: 'x', now: 1 }),
null,
);
});
test('buildUndoLastTurnBranch resets the title when undo removes the first turn', () => {
// Undoing the first turn leaves the branch with no user message at all, so
// the inherited title was derived from the removed prompt and would stay
// stale forever (auto-titling only runs for untitled/New Chat sessions).
const source = session([user('first prompt'), assistant('reply')], { title: 'first prompt' });
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.messages.length, 0);
assert.equal(result?.restored.text, 'first prompt');
assert.equal(result?.session.title, 'New Chat');
});
test('buildUndoLastTurnBranch keeps the title when the retained user turn is note-only', () => {
// A note-only send persists empty user text while the mentioned note rides
// on the attachment (its title seeded the conversation title), so a
// blank-content retained user turn must still count as "a user message is
// kept" and must not reset the title to the untitled placeholder.
const noteOnlyUser: ChatMessage = {
id: 'u_note-only',
role: 'user',
content: '',
timestamp: 3,
attachments: [{
base64Data: '',
mediaType: 'text/markdown',
filename: 'plan.md',
vaultNoteId: 'note-1',
vaultNoteTitle: 'Deploy plan',
previewText: '# Deploy plan',
}],
};
const source = session(
[noteOnlyUser, assistant('reply'), user('second prompt'), assistant('reply2')],
{ title: 'Deploy plan' },
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.title, 'Deploy plan');
});
test('buildUndoLastTurnBranch keeps the title when a user message is retained', () => {
const source = session(
[user('first prompt'), assistant('reply'), user('second prompt'), assistant('reply2')],
{ title: 'renamed by user' },
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.messages.length, 2);
assert.equal(result?.restored.text, 'second prompt');
// The retained prefix still contains a user message, so the source title
// keeps describing the branch's conversation.
assert.equal(result?.session.title, 'renamed by user');
});
test('undo allows a failed last reply while retaining portable replay metadata', () => {
const continuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
const source = session([
user('first'),
assistant('first answer', { providerContinuation: continuation }),
user('retry this request'),
assistant('request failed', { executionStatus: 'failed' }),
], { externalSessionId: 'original-provider-thread' });
const result = buildUndoLastTurnBranch(source, { newId: 'branch', now: 99 });
assert.ok(result);
assert.deepEqual(result.session.messages[1].providerContinuation, continuation);
assert.equal(result.session.externalSessionId, undefined);
assert.equal(result.restored.text, 'retry this request');
assert.equal(source.messages.length, 4);
});
test('safe boundary does not pair a reused call ID with an earlier result', () => {
const prefix = [
assistant('first call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
assistant('first result', { toolResults: [{ toolCallId: 'reused', content: 'done' }] }),
assistant('second call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
];
assert.equal(resolveUndoLastTurnBoundary([...prefix, user('latest')]), null);
assert.equal(resolveUndoLastTurnBoundary([
...prefix,
assistant('second result', { toolResults: [{ toolCallId: 'reused', content: 'done again' }] }),
user('latest'),
]), 4);
});
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more