Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0108390d4f | ||
|
|
e992d51fa6 | ||
|
|
7c55381f39 | ||
|
|
d582baaf53 | ||
|
|
8c1657f1ba | ||
|
|
999ad916e3 | ||
|
|
8ca09b1616 | ||
|
|
70b05bfaaf | ||
|
|
e6ab69b516 | ||
|
|
c6d4d3ec16 | ||
|
|
487b7adf3e | ||
|
|
309996bf3c | ||
|
|
071c95ab5c | ||
|
|
ec99875dec | ||
|
|
51a6b7efaa | ||
|
|
30f5346035 | ||
|
|
e0302e5f34 | ||
|
|
0425841032 | ||
|
|
156550f7eb | ||
|
|
a1648adf12 | ||
|
|
8182bd6b3c | ||
|
|
484ac5f463 |
135
App.tsx
@@ -44,6 +44,7 @@ import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
|
||||
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
@@ -178,6 +179,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
|
||||
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
|
||||
// Combined state for the AddToWorkspaceDialog. null = closed; mode
|
||||
// determines whether picking targets appends them to an existing
|
||||
// workspace (focus sidebar "+") or spins up a brand-new workspace
|
||||
// tab (QuickSwitcher's New Workspace button).
|
||||
const [addToWorkspaceDialog, setAddToWorkspaceDialog] = useState<
|
||||
| { mode: 'append'; workspaceId: string }
|
||||
| { mode: 'create' }
|
||||
| null
|
||||
>(null);
|
||||
const [quickSearch, setQuickSearch] = useState('');
|
||||
// Protocol selection dialog state for QuickSwitcher
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
@@ -292,6 +302,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
@@ -1068,6 +1081,50 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
[sessions, t],
|
||||
);
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
const closeTabsBatch = useCallback(
|
||||
async (targetIds: string[]) => {
|
||||
if (targetIds.length === 0) return;
|
||||
if (closeTabsInFlightRef.current) return;
|
||||
|
||||
// Expand workspace ids into their constituent session ids so the busy
|
||||
// probe sees every local shell that's about to be killed.
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
||||
if (!ok) return;
|
||||
for (const tabId of targetIds) {
|
||||
if (workspaces.find((w) => w.id === tabId)) {
|
||||
closeWorkspace(tabId);
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
closeSession(tabId);
|
||||
} else if (logViews.find((lv) => lv.id === tabId)) {
|
||||
closeLogView(tabId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSession, closeLogView],
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
|
||||
@@ -1188,6 +1245,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
case 'commandPalette':
|
||||
setIsQuickSwitcherOpen(true);
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
// Dedicated shortcut to launch the AddToWorkspaceDialog in
|
||||
// create mode — same entry as QuickSwitcher's "New Workspace"
|
||||
// button, but without having to open QS first.
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
break;
|
||||
case 'portForwarding':
|
||||
// Navigate to vault and open port forwarding section
|
||||
setActiveTabId('vault');
|
||||
@@ -1579,6 +1642,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, [handleOpenSettings, t]);
|
||||
|
||||
// Delete-from-sidepanel plumbing: ScriptsSidePanel's right-click menu
|
||||
// dispatches `netcatty:snippets:delete` with the snippet id. Handled here
|
||||
// (rather than in QuickAddSnippetDialog) because delete needs no UI.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
|
||||
if (!id) return;
|
||||
updateSnippets(snippets.filter((s) => s.id !== id));
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:delete', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:delete', handler);
|
||||
}, [snippets, updateSnippets]);
|
||||
|
||||
const handleEndSessionDrag = useCallback(() => {
|
||||
setDraggingSessionId(null);
|
||||
}, [setDraggingSessionId]);
|
||||
@@ -1630,6 +1706,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
onCloseTabsBatch={closeTabsBatch}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
@@ -1742,6 +1819,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={(workspaceId) =>
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId })
|
||||
}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -1782,17 +1862,65 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Global "quick add snippet" dialog, triggered by the
|
||||
netcatty:snippets:add window event (from ScriptsSidePanel "+"). */}
|
||||
{/* Global "quick add / edit snippet" dialog, triggered by the
|
||||
netcatty:snippets:add and :edit window events (from ScriptsSidePanel
|
||||
"+" button and right-click menu). Delete is handled by a sibling
|
||||
useEffect above — it does not need a dialog. */}
|
||||
<QuickAddSnippetDialog
|
||||
snippets={snippets}
|
||||
packages={snippetPackages}
|
||||
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
|
||||
onUpdateSnippet={(snippet) =>
|
||||
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
|
||||
}
|
||||
onCreatePackage={(pkg) =>
|
||||
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
|
||||
"+" button (mode='append') or QuickSwitcher's "New Workspace"
|
||||
button (mode='create'). Single instance so dialog state and
|
||||
styling stay consistent across entry points. */}
|
||||
{addToWorkspaceDialog && (
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// has no serial code path. Create mode goes through
|
||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
||||
// session for serial hosts, so those should remain pickable.
|
||||
hosts={addToWorkspaceDialog.mode === 'append'
|
||||
? hosts.filter((h) => h.protocol !== 'serial')
|
||||
: hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
: 'New Workspace'
|
||||
}
|
||||
onAdd={(targets) => {
|
||||
if (addToWorkspaceDialog.mode === 'append') {
|
||||
// Match the workspace root's current split direction so
|
||||
// the new panes peer the existing siblings instead of
|
||||
// wrapping the whole tree into one side of a fresh split
|
||||
// (which would happen if we always passed the helper's
|
||||
// default 'vertical').
|
||||
const ws = workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId);
|
||||
const rootDir = ws && ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||
for (const target of targets) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createWorkspaceFromTargets(targets);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
@@ -1816,7 +1944,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setIsCreateWorkspaceOpen(true);
|
||||
setQuickSearch('');
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
|
||||
@@ -306,6 +306,12 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
@@ -409,6 +415,7 @@ const en: Messages = {
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
'settings.shortcuts.none': 'None',
|
||||
'settings.shortcuts.setDisabled': 'Set to disabled',
|
||||
'settings.shortcuts.category.tabs': 'Tabs',
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
@@ -1186,6 +1193,7 @@ const en: Messages = {
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
@@ -1633,6 +1641,9 @@ const en: Messages = {
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Key label',
|
||||
'keychain.edit.privateKeyRequired': 'Private key *',
|
||||
@@ -1672,6 +1683,8 @@ const en: Messages = {
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Create snippet',
|
||||
'snippets.empty.desc': 'Save your most used commands as snippets to reuse them in one click.',
|
||||
'snippets.search.noResults.title': 'No matches',
|
||||
'snippets.search.noResults.desc': 'No snippets or packages match "{query}". Try a different search term or clear the search to browse.',
|
||||
'snippets.section.packages': 'Packages',
|
||||
'snippets.section.snippets': 'Snippets',
|
||||
'snippets.package.count': '{count} snippet(s)',
|
||||
|
||||
@@ -799,6 +799,7 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '脚本',
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
@@ -1389,6 +1390,12 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
@@ -1481,6 +1488,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
'settings.shortcuts.none': '无',
|
||||
'settings.shortcuts.setDisabled': '设为禁用',
|
||||
'settings.shortcuts.category.tabs': '标签页',
|
||||
'settings.shortcuts.category.terminal': '终端',
|
||||
'settings.shortcuts.category.navigation': '导航',
|
||||
@@ -1505,6 +1513,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作区',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
@@ -1641,6 +1650,9 @@ const zhCN: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
@@ -1680,6 +1692,8 @@ const zhCN: Messages = {
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': '创建代码片段',
|
||||
'snippets.empty.desc': '将常用命令保存为代码片段,一键复用。',
|
||||
'snippets.search.noResults.title': '无匹配结果',
|
||||
'snippets.search.noResults.desc': '没有代码片段或代码包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。',
|
||||
'snippets.section.packages': '代码包',
|
||||
'snippets.section.snippets': '代码片段',
|
||||
'snippets.package.count': '{count} 个代码片段',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
selectDraftForAgentSwitch,
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
@@ -172,6 +173,47 @@ test("ensureDraftForScopeState returns the original ref when the scope already e
|
||||
assert.equal(next, draftsByScope);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "keep me only if I was already drafting",
|
||||
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "keep me only if I was already drafting");
|
||||
assert.deepEqual(next.attachments, currentDraft.attachments);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
|
||||
const currentDraft = createEmptyDraft("agent-alpha");
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "");
|
||||
assert.deepEqual(next.attachments, []);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, []);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "unfinished prompt",
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "unfinished prompt");
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("draft mutation version increments on every mutation for the same scope", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialVersion = getDraftMutationVersionState({}, scopeKey);
|
||||
|
||||
@@ -145,6 +145,31 @@ export function ensureDraftForScopeState(
|
||||
};
|
||||
}
|
||||
|
||||
export function selectDraftForAgentSwitch(
|
||||
currentDraft: AIDraft | null | undefined,
|
||||
agentId: string,
|
||||
startFresh: boolean,
|
||||
): AIDraft {
|
||||
const hasPendingDraftContent = Boolean(
|
||||
currentDraft
|
||||
&& (
|
||||
currentDraft.text.length > 0
|
||||
|| currentDraft.attachments.length > 0
|
||||
|| currentDraft.selectedUserSkillSlugs.length > 0
|
||||
),
|
||||
);
|
||||
|
||||
if (startFresh && !hasPendingDraftContent) {
|
||||
return createEmptyDraft(agentId);
|
||||
}
|
||||
|
||||
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
|
||||
return {
|
||||
...baseDraft,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearScopeDraftState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
|
||||
@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions removes non-restorable terminal chats and closed workspaces", () => {
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
@@ -99,10 +99,7 @@ test("pruneInactiveScopedSessions removes non-restorable terminal chats and clos
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.deepEqual(next.sessions, [
|
||||
{
|
||||
...sessions[0],
|
||||
externalSessionId: undefined,
|
||||
},
|
||||
sessions[0],
|
||||
sessions[3],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -103,8 +103,8 @@ export function pruneInactiveScopedSessions(
|
||||
* Session ids currently displayed by any live scope. A session whose
|
||||
* `scope.targetId` is inactive but whose id is still in use somewhere
|
||||
* (e.g. resumed from history into a different terminal) must not be
|
||||
* treated as orphaned — clearing its `externalSessionId` or deleting
|
||||
* it outright would break the chat the user is actively continuing.
|
||||
* treated as orphaned — deleting it outright would break the chat the
|
||||
* user is actively continuing.
|
||||
*/
|
||||
activeSessionIds: Set<string> = new Set(),
|
||||
): {
|
||||
@@ -135,15 +135,7 @@ export function pruneInactiveScopedSessions(
|
||||
sessionsChanged = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!session.externalSessionId) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
sessionsChanged = true;
|
||||
return [
|
||||
{ ...session, externalSessionId: undefined },
|
||||
];
|
||||
return [session];
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -98,8 +98,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
// history can be resumed into a different terminal and we must not
|
||||
// clear its `externalSessionId` (or delete it outright) while it's
|
||||
// actively being used.
|
||||
// delete it outright while it's actively being used.
|
||||
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
@@ -943,7 +942,7 @@ export function useAIState() {
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
@@ -980,7 +979,7 @@ export function useAIState() {
|
||||
}, [setPanelViewByScope]);
|
||||
|
||||
const clearDraftForScope = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let draftsChanged = false;
|
||||
|
||||
@@ -51,10 +51,35 @@ const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive
|
||||
|
||||
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
|
||||
// in the future means a restore is applying in some window and auto-sync
|
||||
// must not push concurrently.
|
||||
// must not push concurrently. The writer (`withRestoreBarrier`) heartbeats
|
||||
// the deadline to keep it alive; a crashed window naturally expires within
|
||||
// ~RESTORE_BARRIER_HOLD_MS. We still defend against two degenerate cases:
|
||||
// (1) a stale deadline sitting in the past — harmless but pollutes debug
|
||||
// state, so we opportunistically clear it; (2) a deadline absurdly far
|
||||
// in the future (clock skew between windows, pathological holdMs, or a
|
||||
// tampered value) — would otherwise lock auto-sync indefinitely, so we
|
||||
// clear it and treat the barrier as inactive.
|
||||
const RESTORE_BARRIER_SANITY_MAX_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const isRestoreInProgress = (): boolean => {
|
||||
const raw = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL);
|
||||
return typeof raw === 'number' && raw > Date.now();
|
||||
if (typeof raw !== 'number' || raw <= 0) return false;
|
||||
const now = Date.now();
|
||||
if (raw <= now) {
|
||||
// Deadline is in the past — either a clean finish that failed to
|
||||
// overwrite the key, or a crashed heartbeat. Clear so subsequent
|
||||
// reads are cheap and the key doesn't linger forever.
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
|
||||
return false;
|
||||
}
|
||||
if (raw - now > RESTORE_BARRIER_SANITY_MAX_MS) {
|
||||
console.warn(
|
||||
'[useAutoSync] Restore barrier deadline is absurdly far in the future; treating as corrupt and clearing.',
|
||||
{ deadline: raw, now },
|
||||
);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
type SyncTrigger = 'auto' | 'manual';
|
||||
|
||||
@@ -13,6 +13,7 @@ interface HotkeyActions {
|
||||
openHosts: () => void;
|
||||
openSftp: () => void;
|
||||
quickSwitch: () => void;
|
||||
newWorkspace: () => void;
|
||||
commandPalette: () => void;
|
||||
portForwarding: () => void;
|
||||
snippets: () => void;
|
||||
@@ -61,6 +62,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'openHosts',
|
||||
'openSftp',
|
||||
'quickSwitch',
|
||||
'newWorkspace',
|
||||
'commandPalette',
|
||||
'portForwarding',
|
||||
'snippets',
|
||||
@@ -168,6 +170,9 @@ export const useGlobalHotkeys = ({
|
||||
case 'quickSwitch':
|
||||
currentActions.quickSwitch?.();
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
currentActions.newWorkspace?.();
|
||||
break;
|
||||
case 'commandPalette':
|
||||
currentActions.commandPalette?.();
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MouseEvent,useCallback,useMemo,useState } from 'react';
|
||||
import { MouseEvent,useCallback,useMemo,useRef,useState } from 'react';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import {
|
||||
appendPaneToWorkspaceRoot,
|
||||
collectSessionIds,
|
||||
createWorkspaceFromSessions as createWorkspaceEntity,
|
||||
createWorkspaceFromSessionIds,
|
||||
@@ -24,6 +25,12 @@ export interface LogView {
|
||||
export const useSessionState = () => {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
// Latest workspaces snapshot for synchronous existence checks outside
|
||||
// setWorkspaces updaters — React doesn't guarantee updaters run
|
||||
// synchronously, so relying on a flag flipped inside them to decide
|
||||
// whether to also call setSessions is racy and can leave orphan panes.
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
// activeTabId is now managed by external store - components subscribe directly
|
||||
const setActiveTabId = activeTabStore.setActiveTabId;
|
||||
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null);
|
||||
@@ -383,6 +390,89 @@ export const useSessionState = () => {
|
||||
setActiveTabId(workspace.id);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Like createWorkspaceWithHosts but supports mixed targets — each
|
||||
// entry is either an SSH host or a local terminal. Used by the
|
||||
// "New Workspace" flow in QuickSwitcher.
|
||||
type WorkspaceTarget =
|
||||
| { kind: 'local'; shellType?: TerminalSession['shellType']; shell?: string; shellArgs?: string[]; shellName?: string; shellIcon?: string }
|
||||
| { kind: 'host'; host: Host };
|
||||
|
||||
const createWorkspaceFromTargets = useCallback((targets: WorkspaceTarget[], name: string = 'Workspace'): string | null => {
|
||||
if (targets.length === 0) return null;
|
||||
|
||||
const newSessions: TerminalSession[] = targets.map((target) => {
|
||||
if (target.kind === 'local') {
|
||||
const sessionId = crypto.randomUUID();
|
||||
return {
|
||||
id: sessionId,
|
||||
hostId: `local-${sessionId}`,
|
||||
hostLabel: target.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: target.shellType,
|
||||
localShell: target.shell,
|
||||
localShellArgs: target.shellArgs,
|
||||
localShellName: target.shellName,
|
||||
localShellIcon: target.shellIcon,
|
||||
};
|
||||
}
|
||||
const host = target.host;
|
||||
if (host.protocol === 'serial') {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
|
||||
const sessionIds = newSessions.map((s) => s.id);
|
||||
// Default to focus-mode (sidebar layout) regardless of target
|
||||
// count — matches the intent behind the QuickSwitcher "New
|
||||
// Workspace" flow, which the user expects to land in focus view.
|
||||
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
||||
title: name,
|
||||
viewMode: 'focus',
|
||||
});
|
||||
const sessionsWithWorkspace = newSessions.map((s) => ({ ...s, workspaceId: workspace.id }));
|
||||
|
||||
setSessions((prev) => [...prev, ...sessionsWithWorkspace]);
|
||||
setWorkspaces((prev) => [...prev, workspace]);
|
||||
setActiveTabId(workspace.id);
|
||||
return workspace.id;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createWorkspaceFromSessions = useCallback((
|
||||
baseSessionId: string,
|
||||
joiningSessionId: string,
|
||||
@@ -434,6 +524,118 @@ export const useSessionState = () => {
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Add a host into an existing workspace by creating a new session for
|
||||
// that host and appending it as the last pane at the workspace root.
|
||||
// Sibling sizes are rebalanced equally by appendPaneToWorkspaceRoot.
|
||||
// Unlike addSessionToWorkspace (which takes a pre-created orphan
|
||||
// session and a SplitHint), this is atomic — the new session is born
|
||||
// already bound to the target workspace and focused.
|
||||
const appendHostToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
host: Host,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Serial hosts use a different session constructor; they currently
|
||||
// only enter workspaces via createSerialSession + drag, so reject
|
||||
// them here to avoid a partially-constructed session.
|
||||
if (host.protocol === 'serial') return null;
|
||||
|
||||
// Cheap early-exit using the ref when the workspace is clearly
|
||||
// absent. The authoritative check lives inside the setWorkspaces
|
||||
// updater below so we also cover the concurrent-close race.
|
||||
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
|
||||
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const newSession: TerminalSession = {
|
||||
id: newSessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
// Nest setSessions + setActiveTabId inside the setWorkspaces updater
|
||||
// so we only commit the session when the workspace update actually
|
||||
// matched — otherwise a concurrent closeWorkspace between the ref
|
||||
// check and the updater firing would leave an orphan session with a
|
||||
// workspaceId pointing at nothing, and active tab would jump to a
|
||||
// closed id. The inner setSessions is idempotent (id dedupe) so
|
||||
// StrictMode's dev-time double-invoke does not duplicate the row.
|
||||
setWorkspaces(prev => {
|
||||
const target = prev.find(w => w.id === workspaceId);
|
||||
if (!target) return prev;
|
||||
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
||||
setActiveTabId(workspaceId);
|
||||
return prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
||||
focusedSessionId: newSessionId,
|
||||
};
|
||||
});
|
||||
});
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
|
||||
// but constructs a local-protocol session instead of an SSH one.
|
||||
const appendLocalTerminalToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
options?: {
|
||||
shellType?: TerminalSession['shellType'];
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
},
|
||||
direction: SplitDirection = 'vertical',
|
||||
): string | null => {
|
||||
// Same pattern as appendHostToWorkspace — ref guard + authoritative
|
||||
// inside-updater match to cover concurrent closeWorkspace.
|
||||
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
|
||||
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const localHostId = `local-${newSessionId}`;
|
||||
const newSession: TerminalSession = {
|
||||
id: newSessionId,
|
||||
hostId: localHostId,
|
||||
hostLabel: options?.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: options?.shellType,
|
||||
localShell: options?.shell,
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
setWorkspaces(prev => {
|
||||
const target = prev.find(w => w.id === workspaceId);
|
||||
if (!target) return prev;
|
||||
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
||||
setActiveTabId(workspaceId);
|
||||
return prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
||||
focusedSessionId: newSessionId,
|
||||
};
|
||||
});
|
||||
});
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const updateSplitSizes = useCallback((workspaceId: string, splitId: string, sizes: number[]) => {
|
||||
setWorkspaces(prev => prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
@@ -838,8 +1040,11 @@ export const useSessionState = () => {
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
|
||||
@@ -108,7 +108,8 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'osc52Clipboard',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
|
||||
@@ -58,12 +58,14 @@ import {
|
||||
} from './ai/draftSendGate';
|
||||
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
||||
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
@@ -177,35 +179,6 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return messages.flatMap((message): Array<{ role: 'user' | 'assistant'; content: string }> => {
|
||||
if (message.role === 'system') return [];
|
||||
|
||||
if (message.role === 'user') {
|
||||
return message.content ? [{ role: 'user', content: message.content }] : [];
|
||||
}
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
if (!parts.length) return [];
|
||||
return [{ role: 'assistant', content: parts.join('\n\n') }];
|
||||
}
|
||||
|
||||
if (message.role === 'tool' && message.toolResults?.length) {
|
||||
return message.toolResults.map((tr) => ({
|
||||
role: 'assistant',
|
||||
content: `Tool result:\n${tr.content}`,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -905,10 +878,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
historyMessages: buildAcpHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
@@ -1002,12 +976,15 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
showScopeDraftView();
|
||||
ensureScopeDraft(agentId);
|
||||
updateScopeDraft(agentId, (draft) => ({
|
||||
...draft,
|
||||
agentId,
|
||||
...selectDraftForAgentSwitch(
|
||||
draft,
|
||||
agentId,
|
||||
Boolean(activeSessionRef.current?.messages.length),
|
||||
),
|
||||
}));
|
||||
showScopeDraftView();
|
||||
setShowHistory(false);
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
|
||||
@@ -1,38 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AppLogoProps {
|
||||
className?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* App logo component that dynamically uses the accent color (--primary CSS variable).
|
||||
* The original logo.svg file remains unchanged; this component renders an inline SVG
|
||||
* with colors bound to the current theme's accent color.
|
||||
*/
|
||||
export const AppLogo: React.FC<AppLogoProps> = ({ className }) => (
|
||||
<svg viewBox="0 0 64 64" className={className}>
|
||||
{/* Main background - uses accent color */}
|
||||
<rect x="4" y="4" width="56" height="56" rx="12" fill="hsl(var(--primary))" />
|
||||
{/* Terminal window */}
|
||||
<rect x="14" y="17" width="36" height="24" rx="4" fill="white" />
|
||||
{/* Title bar - light accent tint */}
|
||||
<rect x="14" y="17" width="36" height="5" rx="4" fill="hsl(var(--primary) / 0.15)" />
|
||||
{/* Window buttons */}
|
||||
<circle cx="18" cy="19.5" r="1" fill="hsl(var(--primary))" />
|
||||
<circle cx="22" cy="19.5" r="1" fill="hsl(var(--primary))" opacity="0.7" />
|
||||
<circle cx="26" cy="19.5" r="1" fill="hsl(var(--primary))" opacity="0.5" />
|
||||
{/* Terminal prompt arrow */}
|
||||
<path d="M20 32 L24 30 L20 28" stroke="hsl(var(--primary))" fill="none" strokeWidth="1.6" />
|
||||
{/* Cursor line */}
|
||||
<path d="M28 34 H34" stroke="hsl(var(--primary))" strokeWidth="1.6" />
|
||||
{/* Cat ears */}
|
||||
<path d="M24 17 L26 12 L28 17Z" fill="white" />
|
||||
<path d="M36 17 L38 12 L40 17Z" fill="white" />
|
||||
{/* Cat tail */}
|
||||
<path d="M40 37 C44 40,46 42,46 46 C46 49,44 51,41 51" stroke="white" fill="none" strokeWidth="3.2" />
|
||||
{/* Connector/plug */}
|
||||
<rect x="38" y="48" width="6" height="5" rx="1" fill="white" stroke="hsl(var(--primary))" />
|
||||
</svg>
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="1024"
|
||||
height="1024"
|
||||
rx="192"
|
||||
ry="192"
|
||||
fill="hsl(var(--primary))"
|
||||
/>
|
||||
<g transform="translate(85.64 85.64) scale(0.68)">
|
||||
<g><path style={{opacity:1}} fill="#f9f9f9" d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#1f2657" d="M 706.5,245.5 C 677.258,242.344 647.925,240.677 618.5,240.5C 649.662,238.284 680.995,239.784 712.5,245C 710.527,245.495 708.527,245.662 706.5,245.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#18214c" d="M 231.5,324.5 C 229.375,356.62 228.042,388.62 227.5,420.5C 226.104,392.965 226.604,365.298 229,337.5C 229.17,331.677 230.003,327.344 231.5,324.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#0c1943" d="M 1026.5,359.5 C 1027.92,371.971 1028.59,384.637 1028.5,397.5C 1028.5,405.008 1028.17,412.341 1027.5,419.5C 1026.5,399.674 1026.17,379.674 1026.5,359.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#505c83" d="M 817.5,544.5 C 815.162,546.04 812.495,546.706 809.5,546.5C 811.905,545.232 814.572,544.565 817.5,544.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#919ab0" d="M 445.5,545.5 C 448.152,545.41 450.485,546.076 452.5,547.5C 449.848,547.59 447.515,546.924 445.5,545.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#022551" d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#032551" d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#0c1a4d" d="M 849.5,574.5 C 822.908,568.314 799.574,574.314 779.5,592.5C 781.087,589.732 782.92,587.065 785,584.5C 785.333,584.833 785.667,585.167 786,585.5C 792.05,581.554 798.217,577.721 804.5,574C 813.233,572.086 822.066,570.919 831,570.5C 837.645,570.207 843.812,571.54 849.5,574.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#98a2bf" d="M 423.5,572.5 C 419.684,573.482 415.684,574.149 411.5,574.5C 415.183,572.75 419.183,572.083 423.5,572.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#9ea6be" d="M 145.5,576.5 C 145.59,579.152 144.924,581.485 143.5,583.5C 143.41,580.848 144.076,578.515 145.5,576.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#132152" d="M 435.5,572.5 C 431.5,572.5 427.5,572.5 423.5,572.5C 419.183,572.083 415.183,572.75 411.5,574.5C 389.242,579.57 372.909,592.403 362.5,613C 356.408,617.241 350.075,617.574 343.5,614C 337.996,608.137 337.163,601.637 341,594.5C 343.929,589.631 347.096,584.965 350.5,580.5C 351.515,581.46 351.515,582.627 350.5,584C 346.551,587.055 344.718,590.888 345,595.5C 344.586,595.043 344.086,594.709 343.5,594.5C 340.638,598.733 339.805,603.4 341,608.5C 347.943,616.139 355.276,616.472 363,609.5C 363.684,608.216 363.517,607.049 362.5,606C 366.772,603.96 369.606,600.126 371,594.5C 374.005,593.423 376.839,591.423 379.5,588.5C 379.291,587.914 378.957,587.414 378.5,587C 379.572,585.716 380.905,585.383 382.5,586C 398.213,573.156 415.88,568.656 435.5,572.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#6c7794" d="M 742.5,596.5 C 741.841,599.455 741.508,602.455 741.5,605.5C 740.848,604.551 740.514,603.385 740.5,602C 740.393,599.779 741.06,597.946 742.5,596.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#6f7b97" d="M 1117.5,604.5 C 1118.77,606.905 1119.43,609.572 1119.5,612.5C 1117.96,610.162 1117.29,607.495 1117.5,604.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#a8aec5" d="M 135.5,622.5 C 135.802,626.79 135.136,630.79 133.5,634.5C 133.717,630.295 134.383,626.295 135.5,622.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#677393" d="M 653.5,662.5 C 634.473,662.218 615.473,662.551 596.5,663.5C 597.263,662.732 598.263,662.232 599.5,662C 617.671,661.171 635.671,661.338 653.5,662.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#032551" d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#01103f" d="M 132.5,640.5 C 131.835,647.48 131.502,654.48 131.5,661.5C 130.669,675.994 130.169,690.661 130,705.5C 128.188,682.722 128.854,660.055 132,637.5C 132.483,638.448 132.649,639.448 132.5,640.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#7c869d" d="M 1119.5,745.5 C 1119.71,748.495 1119.04,751.162 1117.5,753.5C 1117.57,750.572 1118.23,747.905 1119.5,745.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#7581a0" d="M 706.5,791.5 C 705.737,792.268 704.737,792.768 703.5,793C 695.323,793.823 687.323,793.656 679.5,792.5C 688.533,792.716 697.533,792.383 706.5,791.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#a7aec3" d="M 1028.5,883.5 C 1032.79,883.198 1036.79,883.864 1040.5,885.5C 1036.29,885.283 1032.29,884.617 1028.5,883.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#f9f9f9" d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#f8f8f9" d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#adb2c9" d="M 233.5,904.5 C 227.444,906.453 221.111,907.453 214.5,907.5C 220.536,905.419 226.869,904.419 233.5,904.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#bec4d7" d="M 210.5,908.5 C 205.5,910.943 200.166,912.61 194.5,913.5C 199.5,911.057 204.834,909.39 210.5,908.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#9ba0b8" d="M 884.5,984.5 C 884.277,988.162 883.277,991.495 881.5,994.5C 881.723,990.838 882.723,987.505 884.5,984.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#9aa5bc" d="M 1133.5,985.5 C 1134.92,987.515 1135.59,989.848 1135.5,992.5C 1134.08,990.485 1133.41,988.152 1133.5,985.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#adb1c6" d="M 118.5,996.5 C 117.815,1004.82 117.482,1013.15 117.5,1021.5C 116.835,1018.69 116.502,1015.69 116.5,1012.5C 116.429,1006.93 117.096,1001.6 118.5,996.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#c9d0dc" d="M 1135.5,992.5 C 1136.96,998.434 1137.63,1004.6 1137.5,1011C 1137.5,1015.02 1137.17,1018.85 1136.5,1022.5C 1136.59,1012.48 1136.26,1002.48 1135.5,992.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#b5bfcb" d="M 948.5,1025.5 C 948.5,1038.5 948.5,1051.5 948.5,1064.5C 947.167,1064.5 945.833,1064.5 944.5,1064.5C 945.209,1063.6 946.209,1063.26 947.5,1063.5C 947.171,1050.66 947.505,1037.99 948.5,1025.5 Z"/></g>
|
||||
<g><path style={{opacity:1}} fill="#8193aa" d="M 232.5,1079.5 C 235.152,1079.41 237.485,1080.08 239.5,1081.5C 236.848,1081.59 234.515,1080.92 232.5,1079.5 Z"/></g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default AppLogo;
|
||||
|
||||
@@ -520,7 +520,7 @@ echo $3 >> "$FILE"`);
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5 shrink-0">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 backdrop-blur border-b border-border/50 shrink-0">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* KEY button with split interaction: left=switch view, right=dropdown */}
|
||||
@@ -528,16 +528,15 @@ echo $3 >> "$FILE"`);
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md transition-colors",
|
||||
activeFilter === "key" ? "bg-primary/15" : "hover:bg-accent",
|
||||
activeFilter === "key"
|
||||
? "bg-foreground/10 text-foreground hover:bg-foreground/15"
|
||||
: "bg-foreground/5 text-foreground hover:bg-foreground/10",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 rounded-r-none hover:bg-transparent",
|
||||
activeFilter === "key" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-3 gap-2 rounded-r-none hover:bg-transparent text-inherit"
|
||||
onClick={() => setActiveFilter("key")}
|
||||
>
|
||||
<Key size={14} />
|
||||
@@ -547,10 +546,7 @@ echo $3 >> "$FILE"`);
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-1.5 rounded-l-none hover:bg-transparent",
|
||||
activeFilter === "key" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-1.5 rounded-l-none hover:bg-transparent text-inherit"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
@@ -589,33 +585,24 @@ echo $3 >> "$FILE"`);
|
||||
className={cn(
|
||||
"flex items-center rounded-md transition-colors",
|
||||
activeFilter === "certificate"
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-accent",
|
||||
? "bg-foreground/10 text-foreground hover:bg-foreground/15"
|
||||
: "bg-foreground/5 text-foreground hover:bg-foreground/10",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 rounded-r-none hover:bg-transparent",
|
||||
activeFilter === "certificate" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-3 gap-2 rounded-r-none hover:bg-transparent text-inherit"
|
||||
onClick={() => setActiveFilter("certificate")}
|
||||
>
|
||||
<BadgeCheck size={14} />
|
||||
{t("keychain.filter.certificate")}
|
||||
<span className="text-[10px] px-1.5 rounded-full bg-muted text-muted-foreground">
|
||||
{keys.filter((k) => k.certificate).length}
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-1.5 rounded-l-none hover:bg-transparent",
|
||||
activeFilter === "certificate" && "text-primary",
|
||||
)}
|
||||
className="h-10 px-1.5 rounded-l-none hover:bg-transparent text-inherit"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
@@ -645,7 +632,7 @@ echo $3 >> "$FILE"`);
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="h-9 pl-8 w-full"
|
||||
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -654,7 +641,7 @@ echo $3 >> "$FILE"`);
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
|
||||
@@ -455,7 +455,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 bg-secondary/50">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 border-b border-border/50 bg-secondary/80 backdrop-blur">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search
|
||||
@@ -464,7 +464,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("knownHosts.search.placeholder")}
|
||||
className="pl-9 h-9 bg-background border-border/60 text-sm"
|
||||
className="pl-9 h-10 bg-secondary border-border/60 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -474,7 +474,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
{/* View Mode Toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
@@ -505,15 +505,14 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-9 w-9"
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
@@ -532,8 +531,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
className="h-10 px-3 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
<Import size={14} className="mr-2" />
|
||||
|
||||
@@ -567,10 +567,13 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="h-14 px-4 flex items-center gap-3 bg-secondary/60 border-b border-border/60 relative z-20">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3 bg-secondary/80 backdrop-blur border-b border-border/50 relative z-20">
|
||||
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="secondary" className="h-9 px-3 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
>
|
||||
<Zap size={14} />
|
||||
{t("pf.action.newForwarding")}
|
||||
<ChevronDown
|
||||
@@ -618,7 +621,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
className="h-9 pl-8 w-44"
|
||||
className="h-10 pl-9 w-44 bg-secondary border-border/60 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -627,7 +630,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
{/* View mode toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
@@ -664,7 +667,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-9 w-9"
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface QuickAddSnippetDialogProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onCreateSnippet: (snippet: Snippet) => void;
|
||||
onUpdateSnippet?: (snippet: Snippet) => void;
|
||||
onCreatePackage?: (packagePath: string) => void;
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onCreateSnippet,
|
||||
onUpdateSnippet,
|
||||
onCreatePackage,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -44,6 +46,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
const [label, setLabel] = useState('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const [editing, setEditing] = useState<Snippet | null>(null);
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Listen for the global "add snippet" request dispatched by the
|
||||
@@ -51,6 +54,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
// every open so stale input from a previous cancel does not leak.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setEditing(null);
|
||||
setLabel('');
|
||||
setCommand('');
|
||||
setPackagePath('');
|
||||
@@ -60,6 +64,23 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
return () => window.removeEventListener('netcatty:snippets:add', handler);
|
||||
}, []);
|
||||
|
||||
// Sibling event for editing an existing snippet from the ScriptsSidePanel
|
||||
// context menu. Prefills the form and flips the dialog into update mode.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ snippet?: Snippet }>).detail;
|
||||
const snippet = detail?.snippet;
|
||||
if (!snippet) return;
|
||||
setEditing(snippet);
|
||||
setLabel(snippet.label ?? '');
|
||||
setCommand(snippet.command ?? '');
|
||||
setPackagePath(snippet.package ?? '');
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:edit', handler);
|
||||
}, []);
|
||||
|
||||
// Auto-focus the label input once the dialog renders, so the user can
|
||||
// start typing immediately after clicking the + button.
|
||||
useEffect(() => {
|
||||
@@ -92,16 +113,27 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
if (trimmedPackage && !packages.includes(trimmedPackage)) {
|
||||
onCreatePackage?.(trimmedPackage);
|
||||
}
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
});
|
||||
if (editing && onUpdateSnippet) {
|
||||
// Preserve tags/targets/shortkey/noAutoRun etc. that this lightweight
|
||||
// dialog does not expose — only the three quick-edit fields change.
|
||||
onUpdateSnippet({
|
||||
...editing,
|
||||
label: label.trim(),
|
||||
command,
|
||||
package: trimmedPackage || '',
|
||||
});
|
||||
} else {
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -118,7 +150,9 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t(editing ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('snippets.empty.desc')}
|
||||
</DialogDescription>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Search,
|
||||
FolderLock,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
@@ -68,7 +69,7 @@ interface QuickSwitcherProps {
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
onCreateWorkspace?: () => void;
|
||||
keyBindings?: KeyBinding[];
|
||||
showSftpTab: boolean;
|
||||
}
|
||||
@@ -84,6 +85,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
onCreateWorkspace,
|
||||
keyBindings,
|
||||
showSftpTab,
|
||||
}) => {
|
||||
@@ -280,7 +282,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
{/* Categorized view: Hosts/Tabs/Quick connect */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
{/* Jump To hint + New Workspace action */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
@@ -288,6 +290,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
{onCreateWorkspace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCreateWorkspace();
|
||||
onClose();
|
||||
}}
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
title="New Workspace"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hosts section */}
|
||||
|
||||
@@ -5,11 +5,17 @@
|
||||
* Clicking a snippet executes it in the focused terminal session.
|
||||
*/
|
||||
|
||||
import { ChevronRight, Package, Plus, Search, Zap } from 'lucide-react';
|
||||
import { ChevronRight, Edit2, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Snippet } from '../types';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from './ui/context-menu';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
@@ -126,6 +132,18 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
|
||||
}, []);
|
||||
|
||||
const handleEditSnippet = useCallback((snippet: Snippet) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('netcatty:snippets:edit', { detail: { snippet } }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDeleteSnippet = useCallback((id: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('netcatty:snippets:delete', { detail: { id } }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
@@ -213,16 +231,30 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
|
||||
{/* Snippets */}
|
||||
{displayedSnippets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{s.label}</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
|
||||
{s.command}
|
||||
</span>
|
||||
</button>
|
||||
<ContextMenu key={s.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{s.label}</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
|
||||
{s.command}
|
||||
</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleEditSnippet(s)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteSnippet(s.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
|
||||
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
|
||||
|
||||
@@ -152,7 +152,14 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
<div className="flex items-center gap-4">
|
||||
<AppLogo className="w-16 h-16" />
|
||||
<div>
|
||||
<div className="text-3xl font-semibold leading-none">{appInfo.name}</div>
|
||||
{/* Match the Vault sidebar wordmark so the Netcatty brand
|
||||
reads consistently across surfaces — same italic heavy
|
||||
cut, just scaled up for the Settings hero area and
|
||||
using the branded mixed-case "Netcatty" instead of
|
||||
the lowercase electron app name. */}
|
||||
<div className="text-3xl font-black italic tracking-tight leading-none text-foreground">
|
||||
Netcatty
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{appInfo.version ? appInfo.version : " "}
|
||||
|
||||
@@ -402,9 +402,15 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
|
||||
const displayedSnippets = useMemo(() => {
|
||||
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
// Apply search filter
|
||||
if (search.trim()) {
|
||||
// Search spans all packages (#777): when the user types in the search
|
||||
// box we drop the current-package scoping so cross-package matches are
|
||||
// reachable without navigating into each one. Otherwise the user is
|
||||
// browsing and we keep the package scope.
|
||||
const hasSearch = search.trim().length > 0;
|
||||
let result = hasSearch
|
||||
? snippets
|
||||
: snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
if (hasSearch) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(sn =>
|
||||
sn.label.toLowerCase().includes(s) ||
|
||||
@@ -734,16 +740,35 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
|
||||
layout="inline"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!editingSnippet.label || !editingSnippet.command}
|
||||
aria-label={t('common.save')}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
<>
|
||||
{editingSnippet.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const id = editingSnippet.id;
|
||||
if (!id) return;
|
||||
onDelete(id);
|
||||
handleClosePanel();
|
||||
}}
|
||||
aria-label={t('common.delete')}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!editingSnippet.label || !editingSnippet.command}
|
||||
aria-label={t('common.save')}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
@@ -959,7 +984,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<div className="h-full min-h-0 flex relative">
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
|
||||
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-2">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
{/* Search box */}
|
||||
<div className="relative w-64">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -980,7 +1005,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 gap-2"
|
||||
className="h-10 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
>
|
||||
<FolderPlus size={14} className="mr-1" /> {t('snippets.action.newPackage')}
|
||||
</Button>
|
||||
@@ -1049,7 +1074,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 pb-4">
|
||||
{displayedPackages.length > 0 && (
|
||||
{/* Hide the sub-package grid while searching (#777) — search spans
|
||||
all packages, so showing the package tiles alongside a flat
|
||||
cross-package snippet list is noisy. */}
|
||||
{displayedPackages.length > 0 && !search.trim() && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">{t('snippets.section.packages')}</h3>
|
||||
@@ -1196,6 +1224,29 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search-with-no-results feedback (#777 codex follow-up). Package
|
||||
tiles are already hidden during search, so the only visible
|
||||
surface is the flat snippet list — if that's empty the content
|
||||
area would be blank without this fallback. The gate intentionally
|
||||
excludes the fully-empty workspace (snippets.length === 0 AND
|
||||
displayedPackages.length === 0), which the global "Create
|
||||
snippet" empty state renders instead — avoids stacking two
|
||||
empty states. Package-only workspaces (no snippets yet) still
|
||||
get this feedback when searching. */}
|
||||
{search.trim() && displayedSnippets.length === 0 && (snippets.length > 0 || displayedPackages.length > 0) && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<div className="h-14 w-14 rounded-2xl bg-secondary/80 flex items-center justify-center mb-3">
|
||||
<Search size={24} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground mb-1">
|
||||
{t('snippets.search.noResults.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-center max-w-sm">
|
||||
{t('snippets.search.noResults.desc', { query: search.trim() })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -800,6 +800,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
isRestoringSelectionRef,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -1237,7 +1238,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hasText = !!selection && selection.length > 0;
|
||||
setHasSelection(hasText);
|
||||
|
||||
if (hasText && terminalSettings?.copyOnSelect) {
|
||||
if (hasText && terminalSettings?.copyOnSelect && !isRestoringSelectionRef.current) {
|
||||
navigator.clipboard.writeText(selection).catch((err) => {
|
||||
logger.warn("Copy on select failed:", err);
|
||||
});
|
||||
@@ -1328,6 +1329,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
|
||||
// True only while createXTermRuntime is programmatically restoring the
|
||||
// selection right after a keystroke (preserveSelectionOnInput). Lets
|
||||
// copy-on-select skip a redundant clipboard write that would otherwise
|
||||
// clobber whatever the user copied elsewhere in the meantime.
|
||||
const isRestoringSelectionRef = useRef(false);
|
||||
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
|
||||
import { Circle, Columns2, FolderTree, MessageSquare, PanelLeft, PanelRight, Palette, Plus, Search, Server, X, Zap } from 'lucide-react';
|
||||
import React, { createContext, memo, startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import {
|
||||
@@ -29,7 +29,10 @@ import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import { useStoredString } from '../application/state/useStoredString';
|
||||
import { useStoredNumber } from '../application/state/useStoredNumber';
|
||||
import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
@@ -46,6 +49,8 @@ import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { RippleButton } from './ui/ripple';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
@@ -407,6 +412,7 @@ interface TerminalLayerProps {
|
||||
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;
|
||||
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||||
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
||||
onSetDraggingSessionId: (id: string | null) => void;
|
||||
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||||
@@ -465,6 +471,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
onRequestAddToWorkspace,
|
||||
onUpdateSplitSizes,
|
||||
onSetDraggingSessionId,
|
||||
onToggleWorkspaceViewMode,
|
||||
@@ -600,6 +607,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const workspaceInnerRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceOverlayRef = useRef<HTMLDivElement>(null);
|
||||
const [dropHint, setDropHint] = useState<SplitHint>(null);
|
||||
// Focus-mode sidebar: client-side filter for the terminal list.
|
||||
const [focusSidebarSearch, setFocusSidebarSearch] = useState('');
|
||||
const [themePreview, setThemePreview] = useState<{ targetSessionId: string | null; themeId: string | null }>({
|
||||
targetSessionId: null,
|
||||
themeId: null,
|
||||
@@ -654,6 +663,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
|
||||
);
|
||||
const [focusSidebarWidth, setFocusSidebarWidth, persistFocusSidebarWidth] = useStoredNumber(
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 },
|
||||
);
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
'netcatty_side_panel_position',
|
||||
'left',
|
||||
@@ -781,6 +793,35 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Focus-mode workspace sidebar resize handler. The sidebar is always
|
||||
// anchored to the left of the workspace area, so a rightward drag grows it.
|
||||
const handleFocusSidebarResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = focusSidebarWidth;
|
||||
|
||||
let lastWidth = startWidth;
|
||||
let rafId: number | null = null;
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
lastWidth = Math.max(160, Math.min(480, startWidth + delta));
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
setFocusSidebarWidth(lastWidth);
|
||||
});
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
setFocusSidebarWidth(lastWidth);
|
||||
persistFocusSidebarWidth(lastWidth);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}, [focusSidebarWidth, setFocusSidebarWidth, persistFocusSidebarWidth]);
|
||||
|
||||
// Side panel resize handler
|
||||
const handleSidePanelResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1909,31 +1950,97 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const renderFocusModeSidebar = () => {
|
||||
if (!activeWorkspace || !isFocusMode) return null;
|
||||
|
||||
// Use terminal-theme colors for every surface in here so the sidebar
|
||||
// stays readable when the app theme and terminal theme diverge
|
||||
// (e.g. followAppTerminalTheme=off, light app + dark terminal).
|
||||
// Tailwind's bg-foreground/* / text-foreground classes bind to app
|
||||
// theme vars, so we derive row colors from the terminal theme
|
||||
// directly with color-mix.
|
||||
const termBg = resolvedPreviewTheme.colors.background;
|
||||
const termFg = resolvedPreviewTheme.colors.foreground;
|
||||
const selectedBg = `color-mix(in srgb, ${termFg} 10%, transparent)`;
|
||||
const selectedHoverBg = `color-mix(in srgb, ${termFg} 15%, transparent)`;
|
||||
const unselectedHoverBg = `color-mix(in srgb, ${termFg} 10%, transparent)`;
|
||||
const unselectedFg = `color-mix(in srgb, ${termFg} 75%, ${termBg} 25%)`;
|
||||
const mutedFg = `color-mix(in srgb, ${termFg} 55%, ${termBg} 45%)`;
|
||||
const separator = `color-mix(in srgb, ${termFg} 10%, ${termBg} 90%)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col"
|
||||
className="flex-shrink-0 flex flex-col relative"
|
||||
style={{
|
||||
width: focusSidebarWidth,
|
||||
// Paint the sidebar with the terminal's theme background so it
|
||||
// reads as one continuous surface with the focused terminal
|
||||
// (instead of a distinct tinted panel sitting next to it).
|
||||
backgroundColor: termBg,
|
||||
color: termFg,
|
||||
borderRight: `1px solid ${separator}`,
|
||||
}}
|
||||
data-section="terminal-workspace-sidebar"
|
||||
>
|
||||
{/* Header with view toggle */}
|
||||
<div className="h-10 flex items-center justify-between px-3 border-b border-border/50">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Terminals · {workspaceSessions.length}
|
||||
</span>
|
||||
{/* Resize handle sitting on the right edge of the sidebar. */}
|
||||
<div
|
||||
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
|
||||
onMouseDown={handleFocusSidebarResizeStart}
|
||||
/>
|
||||
{/* Header — search box + actions (matches Vault-sidebar search
|
||||
style but skinned to the terminal theme so it blends with the
|
||||
sidebar's bg). */}
|
||||
<div
|
||||
className="h-11 flex items-center gap-1.5 px-2"
|
||||
style={{ borderBottom: `1px solid ${separator}` }}
|
||||
>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{ color: mutedFg }}
|
||||
/>
|
||||
<Input
|
||||
value={focusSidebarSearch}
|
||||
onChange={(e) => setFocusSidebarSearch(e.target.value)}
|
||||
placeholder="Search terminals..."
|
||||
className="h-7 pl-6 pr-0 text-xs bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
style={{ color: termFg }}
|
||||
/>
|
||||
</div>
|
||||
{onRequestAddToWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
|
||||
title="Add Terminal"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
|
||||
title="Switch to Split View"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
<Columns2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{workspaceSessions.map(session => {
|
||||
{workspaceSessions.filter((session) => {
|
||||
const term = focusSidebarSearch.trim().toLowerCase();
|
||||
if (!term) return true;
|
||||
return (
|
||||
session.hostLabel?.toLowerCase().includes(term)
|
||||
|| session.hostname?.toLowerCase().includes(term)
|
||||
|| session.username?.toLowerCase().includes(term)
|
||||
);
|
||||
}).map(session => {
|
||||
const host = sessionHostsMap.get(session.id);
|
||||
const isSelected = session.id === focusedSessionId;
|
||||
const statusColor = session.status === 'connected'
|
||||
@@ -1942,35 +2049,49 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500';
|
||||
|
||||
const restBg = isSelected ? selectedBg : 'transparent';
|
||||
const hoverBg = isSelected ? selectedHoverBg : unselectedHoverBg;
|
||||
const rowFg = isSelected ? termFg : unselectedFg;
|
||||
|
||||
return (
|
||||
<div
|
||||
<RippleButton
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/15 border border-primary/30"
|
||||
: "hover:bg-secondary/80 border border-transparent"
|
||||
)}
|
||||
variant="ghost"
|
||||
// Row colors are terminal-theme derived (see renderFocusModeSidebar
|
||||
// top). `hover:text-inherit` pins text against ghost variant's
|
||||
// hover:text-accent-foreground default; hover bg is swapped
|
||||
// via inline style so we stay on terminal-theme alpha rather
|
||||
// than Tailwind's app-theme foreground color.
|
||||
className="w-full h-auto justify-start gap-2 px-2 py-1.5 font-normal hover:text-inherit"
|
||||
style={{ backgroundColor: restBg, color: rowFg }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = restBg;
|
||||
}}
|
||||
onClick={() => onSetWorkspaceFocusedSession?.(activeWorkspace.id, session.id)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative flex-shrink-0">
|
||||
{host ? (
|
||||
<DistroAvatar host={host} fallback={session.hostLabel} size="sm" />
|
||||
) : (
|
||||
<Server size={16} className="text-muted-foreground" />
|
||||
<Server size={16} style={{ color: mutedFg }} />
|
||||
)}
|
||||
<Circle
|
||||
size={6}
|
||||
className={cn("absolute -bottom-0.5 -right-0.5 fill-current", statusColor)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{session.hostLabel}</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className={cn("text-xs truncate", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{session.hostLabel}
|
||||
</div>
|
||||
<div className="text-[10px] truncate" style={{ color: mutedFg }}>
|
||||
{session.username}@{session.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RippleButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1992,14 +2113,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
zIndex: isTerminalLayerVisible ? 10 : 0,
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
|
||||
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
{/* Side panel with tab header + content (SFTP / Scripts / Theme).
|
||||
Uses `order-last` instead of flex-row-reverse on the parent so the
|
||||
workspace focus-mode sidebar and terminal area below stay in source
|
||||
order (sidebar on the left) regardless of the side panel's side. */}
|
||||
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0 || mountedAiTabIds.length > 0) && (
|
||||
<>
|
||||
<div
|
||||
style={{ width: isSidePanelOpenForCurrentTab ? sidePanelWidth : 0 }}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-full relative z-20",
|
||||
sidePanelPosition === 'right' && "order-last",
|
||||
)}
|
||||
>
|
||||
{isSidePanelOpenForCurrentTab && (
|
||||
@@ -2220,6 +2345,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
{/* Focus mode sidebar */}
|
||||
{isFocusMode && renderFocusModeSidebar()}
|
||||
|
||||
|
||||
<div ref={workspaceInnerRef} className="overflow-hidden relative flex-1">
|
||||
{draggingSessionId && !isFocusMode && (
|
||||
<div
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
|
||||
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
|
||||
@@ -36,6 +36,7 @@ interface TopTabsProps {
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
onCloseLogView: (logViewId: string) => void;
|
||||
onCloseTabsBatch: (targetIds: string[]) => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -244,6 +245,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onCloseLogView,
|
||||
onCloseTabsBatch,
|
||||
onOpenQuickSwitcher,
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
@@ -494,6 +496,37 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}).filter(Boolean);
|
||||
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
|
||||
|
||||
// Bulk-close menu items shared by session and workspace context menus.
|
||||
// Anchor is the tab the user right-clicked on (matches VSCode/JetBrains UX).
|
||||
const renderBulkCloseItems = (anchorId: string) => {
|
||||
const anchorIdx = orderedTabs.indexOf(anchorId);
|
||||
const othersIds = orderedTabs.filter((id) => id !== anchorId);
|
||||
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={othersIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(othersIds)}
|
||||
>
|
||||
{t('tabs.closeOthers')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={rightIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(rightIds)}
|
||||
>
|
||||
{t('tabs.closeToRight')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onCloseTabsBatch(orderedTabs)}
|
||||
>
|
||||
{t('tabs.closeAll')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the tabs
|
||||
const renderOrderedTabs = () => {
|
||||
return orderedTabItems.map((item) => {
|
||||
@@ -522,7 +555,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -548,13 +581,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{activeTabId === session.id && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div
|
||||
@@ -593,6 +619,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(session.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -623,7 +650,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -649,13 +676,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div
|
||||
@@ -699,6 +719,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(workspace.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -717,7 +738,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
@@ -740,13 +761,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileText
|
||||
size={14}
|
||||
@@ -842,7 +856,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
data-state={isSftpActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"netcatty-tab relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"netcatty-tab relative h-7 px-3 rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
@@ -865,12 +879,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -76,6 +76,7 @@ import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
|
||||
import SnippetsManager from "./SnippetsManager";
|
||||
import { ImportVaultDialog, ImportOptions } from "./vault/ImportVaultDialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -867,23 +868,30 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
const displayedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
if (selectedGroupPath) {
|
||||
// Match hosts whose group equals the selected path
|
||||
// For "General" group, also match hosts with empty/undefined group
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = h.group || "";
|
||||
if (selectedGroupPath === "General") {
|
||||
return hostGroup === "" || hostGroup === "General";
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
});
|
||||
} else if (showOnlyUngroupedHostsInRoot) {
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = (h.group || "").trim();
|
||||
return hostGroup === "";
|
||||
});
|
||||
// Search spans all groups (#777): when the user types in the search box
|
||||
// we skip group/ungrouped-root scoping, so a matching host in another
|
||||
// group is still reachable without having to navigate into it first.
|
||||
// The tree view already uses this shape — see `treeViewHosts` below.
|
||||
const hasSearch = search.trim().length > 0;
|
||||
if (!hasSearch) {
|
||||
if (selectedGroupPath) {
|
||||
// Match hosts whose group equals the selected path
|
||||
// For "General" group, also match hosts with empty/undefined group
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = h.group || "";
|
||||
if (selectedGroupPath === "General") {
|
||||
return hostGroup === "" || hostGroup === "General";
|
||||
}
|
||||
return hostGroup === selectedGroupPath;
|
||||
});
|
||||
} else if (showOnlyUngroupedHostsInRoot) {
|
||||
filtered = filtered.filter((h) => {
|
||||
const hostGroup = (h.group || "").trim();
|
||||
return hostGroup === "";
|
||||
});
|
||||
}
|
||||
}
|
||||
if (search.trim()) {
|
||||
if (hasSearch) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
@@ -1590,24 +1598,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
"bg-secondary border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
sidebarCollapsed ? "w-14" : "w-52"
|
||||
)}
|
||||
data-section="vault-sidebar"
|
||||
>
|
||||
<div className={cn(
|
||||
"py-4 flex items-center",
|
||||
"pt-5 pb-6 flex items-center",
|
||||
sidebarCollapsed ? "px-2 justify-center" : "px-4"
|
||||
)}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
className="flex items-center gap-2.5 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<AppLogo className="h-10 w-10 rounded-xl flex-shrink-0" />
|
||||
<AppLogo className="h-8 w-8 flex-shrink-0" />
|
||||
{!sidebarCollapsed && (
|
||||
<p className="text-sm font-bold text-foreground">Netcatty</p>
|
||||
<p className="text-xl font-black italic tracking-tight text-foreground leading-none">
|
||||
Netcatty
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -1620,7 +1630,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<div className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-3")}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1635,13 +1645,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<LayoutGrid size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.hosts")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.hosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "keys" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1655,13 +1665,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Key size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.keychain")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "port" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1673,13 +1683,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Plug size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.portForwarding")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.portForwarding")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "snippets" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1693,13 +1703,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<FileCode size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.snippets")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.snippets")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1711,13 +1721,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<BookMarked size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.knownHosts")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.knownHosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={currentSection === "logs" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
@@ -1729,7 +1739,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<Activity size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.logs")}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.logs")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
@@ -1967,6 +1977,52 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isMultiSelectMode && isHostsSectionActive && (
|
||||
<div className="px-4 py-1.5 bg-background border-b border-border/40 flex items-center gap-2">
|
||||
<span className="flex items-center h-7 text-xs text-muted-foreground leading-none">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={12} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keep hosts mounted so switching sections does not reset scroll or remount the list. */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -2401,49 +2457,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
|
||||
662
components/ai/acpHistory.test.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
buildAcpHistoryMessages,
|
||||
buildAcpHistoryMessagesForBridge,
|
||||
} from "./acpHistory.ts";
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: ChatMessage["role"],
|
||||
content: string,
|
||||
extra: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
timestamp: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildAcpHistoryMessages compacts older ACP context and keeps only recent raw turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "我希望最小改动,不要添加很多 test"),
|
||||
message("a1", "assistant", "已按最小改动处理"),
|
||||
message("u2", "user", "MCP 不允许使用,Windows 上不要假设 pwsh.exe"),
|
||||
message("a2", "assistant", "PR #738 已创建,commit 4181a2c"),
|
||||
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
|
||||
message("a3", "assistant", "建议 ACP history compaction"),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "search",
|
||||
content: `error: ${"large output ".repeat(500)}`,
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
message("u4", "user", "好的"),
|
||||
message("a4", "assistant", "准备实现"),
|
||||
message("u5", "user", "继续"),
|
||||
message("a5", "assistant", "继续处理"),
|
||||
message("u6", "user", "现在提交"),
|
||||
message("a6", "assistant", "还没提交"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Compact prior Netcatty UI context/);
|
||||
assert.match(result[0].content, /最小改动/);
|
||||
assert.match(result[0].content, /pwsh\.exe/);
|
||||
assert.match(result[0].content, /PR #738/);
|
||||
assert.ok(result[0].content.length <= 3000);
|
||||
|
||||
assert.ok(result.length <= 7);
|
||||
assert.deepEqual(
|
||||
result.slice(1).map((entry) => entry.content),
|
||||
["好的", "准备实现", "继续", "继续处理", "现在提交", "还没提交"],
|
||||
);
|
||||
assert.ok(result.every((entry) => entry.content.length <= 3000));
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessagesForBridge keeps fallback history available for stale ACP session recovery", () => {
|
||||
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
|
||||
|
||||
assert.equal(buildAcpHistoryMessagesForBridge([], "acp-session-1"), undefined);
|
||||
assert.deepEqual(
|
||||
buildAcpHistoryMessagesForBridge(messages, "acp-session-1"),
|
||||
buildAcpHistoryMessages(messages),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
|
||||
message("a1", "assistant", "Understood."),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
|
||||
assert.deepEqual(
|
||||
result.slice(-6).map((entry) => entry.content),
|
||||
[
|
||||
"filler user message 11",
|
||||
"filler assistant message 11",
|
||||
"filler user message 12",
|
||||
"filler assistant message 12",
|
||||
"filler user message 13",
|
||||
"filler assistant message 13",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short important user constraints outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not treat pr inside ordinary words as important", () => {
|
||||
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
|
||||
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
|
||||
// Those words land at priority=1 (kept only as space allows) while the
|
||||
// 不要提交 line lands at priority=2 (always preferred). The check below
|
||||
// doesn't assert that the ordinary words are absent from the compact
|
||||
// section — they may legitimately survive when budget allows; that's
|
||||
// intentional after we stopped blanket-dropping short user messages.
|
||||
// What we DO verify: the priority-2 line is selected, which is only
|
||||
// possible if the IMPORTANT_PATTERNS regex correctly distinguishes it
|
||||
// from the surrounding short ordinary-word turns.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
message("u2", "user", "approach"),
|
||||
message("a2", "assistant", "ack"),
|
||||
message("u3", "user", "improve"),
|
||||
message("a3", "assistant", "ack"),
|
||||
message("u4", "user", "prepare"),
|
||||
message("a4", "assistant", "ack"),
|
||||
];
|
||||
|
||||
for (let index = 5; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
for (let index = 1; index <= 12; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(
|
||||
message("u13", "user", "Keep the existing layout and copy wording unchanged."),
|
||||
message("a13", "assistant", "Understood."),
|
||||
);
|
||||
|
||||
for (let index = 14; index <= 18; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Please propose a migration plan for the sidebar state."),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Introduce a dedicated hook for the panel stack. 2. Move the derived view state into that hook. 3. Keep the existing UI copy and layout. 4. Add a regression test around back navigation.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move the derived view state into that hook\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
|
||||
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
|
||||
// would previously be dropped by a blanket length<10 heuristic, even
|
||||
// though they don't match any TRIVIAL pattern.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Use ssh2"),
|
||||
message("a1", "assistant", "Got it."),
|
||||
message("u2", "user", "中文输出"),
|
||||
message("a2", "assistant", "明白"),
|
||||
];
|
||||
|
||||
// Push enough later turns so u1/u2 fall outside the recent raw window
|
||||
// and have to survive via the durable-user compaction path.
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Use ssh2/);
|
||||
assert.match(result[0].content, /中文输出/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops one-word filler user messages", () => {
|
||||
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
|
||||
// "thanks" filler to leak into the compact section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "继续"),
|
||||
message("a2", "assistant", "继续处理"),
|
||||
];
|
||||
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
// u1 / u2 fall outside the recent raw window. The compact context, if it
|
||||
// exists, must not surface these trivial turns as durable user requests.
|
||||
if (result.length > 0 && result[0].role === "user") {
|
||||
assert.doesNotMatch(result[0].content, /User request: ok\b/);
|
||||
assert.doesNotMatch(result[0].content, /User request: 继续/);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
|
||||
// Regression: tool results used to only reach fallback replay via the
|
||||
// 500-char compact summary. If the user's last interaction produced a
|
||||
// large tool output (cat/rg/fetched file), any "use that output"-style
|
||||
// follow-up lost the actual bytes. Now tool messages flow through the
|
||||
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
|
||||
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal", arguments: { cmd: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: bigToolOutput, isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Raw-window tool result carries both the [from ...] provenance label
|
||||
// and the actual bytes (not just the 500-char compact summary).
|
||||
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
|
||||
// Confirm we kept enough bytes to exceed the compact-summary cap.
|
||||
const toolResultIdx = flat.indexOf("Tool result [from terminal");
|
||||
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
|
||||
const toolResultChunk = flat.slice(toolResultIdx);
|
||||
assert.ok(
|
||||
toolResultChunk.length > 600,
|
||||
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
|
||||
// Regression: if the raw window starts mid-tool-interaction, the
|
||||
// preceding assistant tool_call message may be outside the 6-item
|
||||
// slice. Without the call's name/args inline on the result line, the
|
||||
// AI sees opaque bytes and "use that output" becomes ambiguous.
|
||||
const messages: ChatMessage[] = [
|
||||
// Early filler to push the tool_call off the raw window
|
||||
message("u0", "user", "prior chatter"),
|
||||
message("a0", "assistant", "prior reply"),
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [
|
||||
{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } },
|
||||
],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: "127.0.0.1 localhost", isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
message("a2", "assistant", "acknowledged"),
|
||||
message("u3", "user", "now do the same for /etc/resolv.conf"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// The tool_result line must carry the originating tool_call's name and
|
||||
// args, so even if a1 was pushed out of the raw window, the result is
|
||||
// self-describing.
|
||||
assert.match(flat, /Tool result \[from terminal_exec/);
|
||||
assert.match(flat, /cat \/etc\/hosts/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
|
||||
// Regression target: codex review flagged that the compaction path
|
||||
// scanned messages.entries() over the full transcript. Build a very
|
||||
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
|
||||
// only messages within the recent user-turn window contribute
|
||||
// durable candidates.
|
||||
const messages: ChatMessage[] = [];
|
||||
// An ancient high-priority constraint that MUST be aged out.
|
||||
messages.push(message("old-important", "user", "不要提交 old-marker-xyz"));
|
||||
messages.push(message("old-ack", "assistant", "收到"));
|
||||
|
||||
// 300 filler turns between the ancient constraint and the window —
|
||||
// well past MAX_DURABLE_SCAN_TURNS (100).
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
messages.push(
|
||||
message(`u${i}`, "user", `filler user message ${i}`),
|
||||
message(`a${i}`, "assistant", `filler assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
// A recent constraint that should survive.
|
||||
messages.push(message("recent-important", "user", "不要提交 recent-marker-abc"));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
messages.push(
|
||||
message(`t${i}`, "user", `tail user message ${i}`),
|
||||
message(`ta${i}`, "assistant", `tail assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Recent priority-2 constraint is kept.
|
||||
assert.match(flat, /recent-marker-abc/);
|
||||
// Ancient one past the scan window is dropped — proof the bound holds.
|
||||
assert.doesNotMatch(flat, /old-marker-xyz/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
|
||||
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
|
||||
// the raw message array. In a tool-heavy chat, each user turn can
|
||||
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
|
||||
// results + follow-up assistant), so 200 messages might be only
|
||||
// ~40 user turns. An instruction like "不要提交" from turn 5 would
|
||||
// fall out of the scan before the turn count justified aging it out.
|
||||
//
|
||||
// Now the bound is MAX_DURABLE_SCAN_TURNS=100 user turns. Build a
|
||||
// chat with only 30 user turns but many messages per turn — the
|
||||
// early constraint must still survive.
|
||||
const messages: ChatMessage[] = [];
|
||||
messages.push(message("early-important", "user", "不要提交 EARLY_CONSTRAINT_MARKER"));
|
||||
messages.push(message("early-ack", "assistant", "收到"));
|
||||
|
||||
// 35 additional turns, each with 6 messages (bloats the total
|
||||
// message count to >200 without exceeding 100 user turns).
|
||||
for (let turn = 1; turn < 36; turn += 1) {
|
||||
messages.push(message(`u${turn}`, "user", `turn ${turn} request`));
|
||||
messages.push(message(`a${turn}-plan`, "assistant", "let me check", {
|
||||
toolCalls: [
|
||||
{ id: `c${turn}a`, name: "terminal_exec", arguments: { cmd: "echo a" } },
|
||||
{ id: `c${turn}b`, name: "terminal_exec", arguments: { cmd: "echo b" } },
|
||||
{ id: `c${turn}c`, name: "terminal_exec", arguments: { cmd: "echo c" } },
|
||||
],
|
||||
}));
|
||||
messages.push(message(`t${turn}a`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}a`, content: `result a of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}b`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}b`, content: `result b of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}c`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}c`, content: `result c of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`a${turn}-done`, "assistant", `turn ${turn} done`));
|
||||
}
|
||||
|
||||
// Sanity: the message count is over 200 even though user turns are 30.
|
||||
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Under the old raw-count bound, the early constraint would age out;
|
||||
// under the turn-based bound it survives.
|
||||
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
|
||||
// Regression: isSubstantiveAssistantMessage previously required length
|
||||
// >= 40 OR a small English keyword match OR a numbered list. Short
|
||||
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
|
||||
// satisfied none of those and were silently dropped. After a stale-
|
||||
// session recovery, "do what you suggested earlier" would then replay
|
||||
// only the user's question without the assistant's actual decision.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "which client should I use"),
|
||||
message("a1", "assistant", "Use ssh2"),
|
||||
message("u2", "user", "output language?"),
|
||||
message("a2", "assistant", "中文输出"),
|
||||
message("u3", "user", "merge or rebase?"),
|
||||
message("a3", "assistant", "rebase instead"),
|
||||
];
|
||||
|
||||
// Pad so u1..a3 fall outside the recent raw window (last 6 items) and
|
||||
// must flow through the durable-assistant compact pass.
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.match(flat, /Use ssh2/);
|
||||
assert.match(flat, /中文输出/);
|
||||
assert.match(flat, /rebase instead/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
|
||||
// Sanity: removing the length/keyword gate must not let assistant
|
||||
// filler leak into the compact durable-assistant section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "prompt 1"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "prompt 2"),
|
||||
message("a2", "assistant", "明白"),
|
||||
message("u3", "user", "prompt 3"),
|
||||
message("a3", "assistant", "got it"),
|
||||
];
|
||||
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `more filler ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.doesNotMatch(flat, /Assistant context: ack\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: got it\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: 明白/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
|
||||
// Regression: the raw-window fix covered the last 6 items, but once
|
||||
// a tool result fell into the compact section (summarizeToolMessage
|
||||
// path) the `[from <name>(<args>)]` provenance label was absent.
|
||||
// With multiple older tool outputs, all surfacing as identical
|
||||
// `Tool result (callN): ...`, follow-ups like "use the resolv.conf
|
||||
// output" have no way to map to the right call.
|
||||
const messages: ChatMessage[] = [
|
||||
// Two distinct tool interactions, both pushed well outside the
|
||||
// recent raw window by later turns.
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call-hosts", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-hosts", content: "127.0.0.1 localhost", isError: false }],
|
||||
}),
|
||||
message("u2", "user", "show resolv.conf"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call-resolv", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool2", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-resolv", content: "nameserver 8.8.8.8", isError: false }],
|
||||
}),
|
||||
// Important user text so summarizeMessage picks these up via the
|
||||
// important-text branch; tool results themselves are always
|
||||
// summarized regardless of IMPORTANT_PATTERNS.
|
||||
message("u3", "user", "fallback plan"),
|
||||
];
|
||||
|
||||
// Filler to push the early tool results out of the 6-item raw window
|
||||
// and into the compact summary section (scanned = last 20).
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Both older tool results must now carry provenance labels so a
|
||||
// follow-up can disambiguate them.
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/hosts/);
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
|
||||
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
|
||||
// Without skipping raw-window items, the same last-6 turns would be
|
||||
// summarized in the compact section AND appended verbatim in the raw
|
||||
// section — doubling the budget cost of important user turns / large
|
||||
// tool output and crowding out older durable context.
|
||||
//
|
||||
// Setup: enough filler upfront that u3 ends up OUTSIDE the raw window
|
||||
// (so it can be asserted absent from raw), then a distinctive "raw
|
||||
// only" marker that should appear only in the last-6 raw slice.
|
||||
const messages: ChatMessage[] = [];
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
messages.push(
|
||||
message(`uf${index}`, "user", `filler user ${index}`),
|
||||
message(`af${index}`, "assistant", `filler assistant ${index}`),
|
||||
);
|
||||
}
|
||||
// These are the last 4 user/assistant messages — guaranteed to be in
|
||||
// the last-6 raw slice. The IMPORTANT markers below would ordinarily
|
||||
// also get summarized into the compact section, duplicating the cost.
|
||||
messages.push(
|
||||
message("u-rec1", "user", "commit now IMPORTANT_RAW_MARKER please"),
|
||||
message("a-rec1", "assistant", "", {
|
||||
toolCalls: [{ id: "c1", name: "git", arguments: { op: "commit" } }],
|
||||
}),
|
||||
message("tool-rec", "tool", "", {
|
||||
toolResults: [{ toolCallId: "c1", content: "committed abc123 RAW_TOOL_MARKER", isError: false }],
|
||||
}),
|
||||
message("u-rec2", "user", "now push"),
|
||||
);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
assert.ok(compact, "expected a compact context message");
|
||||
|
||||
// Both markers belong to messages inside the raw window — they must
|
||||
// not be summarized into compact (which would double-bill them).
|
||||
assert.doesNotMatch(compact.content, /IMPORTANT_RAW_MARKER/);
|
||||
assert.doesNotMatch(compact.content, /RAW_TOOL_MARKER/);
|
||||
|
||||
// Raw section still carries them verbatim.
|
||||
const raw = result.filter((m) => !m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
const rawFlat = raw.map((m) => m.content).join("\n");
|
||||
assert.match(rawFlat, /IMPORTANT_RAW_MARKER/);
|
||||
assert.match(rawFlat, /RAW_TOOL_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
|
||||
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
|
||||
// assistant tool_call with the same id overwrite the older one. An
|
||||
// older tool_result in the replay history would then be annotated
|
||||
// with the wrong command (e.g. a /etc/hosts result labeled as
|
||||
// /etc/resolv.conf). Now each tool_result is indexed by its own
|
||||
// messageId + toolCallId and resolved to the most recent preceding
|
||||
// call with that id.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool-hosts", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "127.0.0.1 localhost HOSTS_BYTES", isError: false }],
|
||||
}),
|
||||
// A later assistant turn reuses the id "call1" for a different call.
|
||||
message("u2", "user", "show resolv"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool-resolv", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "nameserver 8.8.8.8 RESOLV_BYTES", isError: false }],
|
||||
}),
|
||||
message("u3", "user", "ok"),
|
||||
];
|
||||
|
||||
// Pad so the first interaction lands in the compact summary pass.
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Each tool_result must be annotated with ITS OWN preceding call's
|
||||
// args — not whichever assistant tool_call happened to win the
|
||||
// last-write on the shared id.
|
||||
//
|
||||
// Extract the two Tool-result lines and match each to its expected
|
||||
// args. Use non-greedy .*? — the args JSON can contain parentheses.
|
||||
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
|
||||
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
|
||||
|
||||
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
|
||||
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Move parser setup into a dedicated hook. 2. Keep storage schema unchanged. 3. Add a regression test.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 7; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", index % 2 === 0 ? "ok" : "continue"),
|
||||
message(`a${index}`, "assistant", "ack"),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);
|
||||
});
|
||||
438
components/ai/acpHistory.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
|
||||
type AcpHistoryMessage = { role: "user" | "assistant"; content: string };
|
||||
type RawHistoryMessage = AcpHistoryMessage & { sourceId: string };
|
||||
type DurableUserLine = {
|
||||
line: string;
|
||||
messageIndex: number;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
const MAX_RECENT_RAW_MESSAGES = 6;
|
||||
const MAX_MESSAGES_TO_SCAN = 20;
|
||||
// Bound the scan by user turns, not raw message count: a tool-heavy ACP
|
||||
// chat can produce 5+ messages per logical turn (user + assistant +
|
||||
// several tool_results + follow-up assistant), so a plain
|
||||
// message-count cap ages out early constraints much sooner than intended.
|
||||
const MAX_DURABLE_SCAN_TURNS = 100;
|
||||
const MAX_COMPACT_CONTEXT_CHARS = 3000;
|
||||
const MAX_RAW_MESSAGE_CHARS = 2000;
|
||||
const MAX_TOOL_SUMMARY_CHARS = 500;
|
||||
const MAX_DURABLE_USER_CONTEXT_CHARS = 1400;
|
||||
const MAX_DURABLE_ASSISTANT_CONTEXT_CHARS = 900;
|
||||
const MAX_RECENT_SUMMARY_CONTEXT_CHARS = 1200;
|
||||
const MAX_DURABLE_USER_MESSAGE_CHARS = 280;
|
||||
const MAX_DURABLE_ASSISTANT_MESSAGE_CHARS = 360;
|
||||
const MAX_TOOL_CALL_LABEL_CHARS = 200;
|
||||
|
||||
type ToolCallInfo = { name: string; arguments: unknown };
|
||||
|
||||
const IMPORTANT_PATTERNS = [
|
||||
/不要|别|不能|不允许|必须|希望|只|最小|先|暂时|fallback|pwsh|powershell|cmd\.exe|windows|mcp|skills|cli|commit|\bpr\b|打包|内存|历史|压缩|慢/i,
|
||||
/error|failed|failure|exit code|exception|cannot|unable|timeout|crash|fallback|commit|pull request|PR #\d+/i,
|
||||
];
|
||||
const DURABLE_CONSTRAINT_PATTERNS = [
|
||||
/\bdo not\b|\bdon't\b|\bkeep\b|\bpreserve\b|\bavoid\b|\bonly\b|\bunchanged\b|\blocal only\b|\bwithout\b|\bleave\b/i,
|
||||
/不要|别|保留|保持|维持|不改|别改|不要改|仅限本地/i,
|
||||
];
|
||||
const TRIVIAL_USER_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|yes|no|thanks|thank you|continue|继续|好的|收到|行|嗯|好|继续处理|继续吧|开始吧)[.!? ]*$/i,
|
||||
];
|
||||
const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|understood|got it|working|proceeding|ready|ack(?: \d+)?|收到|明白|继续处理|准备实现|开始处理|处理中)[.!? ]*$/i,
|
||||
];
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isImportantText(value: string): boolean {
|
||||
return IMPORTANT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isDurableConstraintText(value: string): boolean {
|
||||
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isTrivialUserMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
|
||||
// Don't blanket-drop short messages — short user turns are often
|
||||
// load-bearing constraints ("Use ssh2", "中文输出", "no logs", "more
|
||||
// verbose") that the IMPORTANT/DURABLE regexes can't realistically
|
||||
// enumerate. The trivial-phrase regex already catches actual filler
|
||||
// ("ok", "yes", "thanks", "继续").
|
||||
return TRIVIAL_USER_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableUserPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isSubstantiveAssistantMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (!normalized) return false;
|
||||
// Mirror the user-side loosening: don't blanket-drop short assistant
|
||||
// messages just because they're under 40 chars or don't match the small
|
||||
// English keyword list. Short but load-bearing decisions ("Use ssh2",
|
||||
// "rebase instead", "中文输出") aren't realistically enumerable and
|
||||
// they're the exact things a later "do what you suggested" references.
|
||||
// TRIVIAL_ASSISTANT_MESSAGE_PATTERNS still catches the actual filler
|
||||
// ("ok", "ack", "got it", "明白").
|
||||
return !TRIVIAL_ASSISTANT_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableAssistantPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function appendUniqueLine(
|
||||
target: string[],
|
||||
seen: Set<string>,
|
||||
line: string,
|
||||
maxSectionChars: number,
|
||||
sectionCharsRef: { value: number },
|
||||
): void {
|
||||
const normalized = normalizeWhitespace(line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = sectionCharsRef.value + normalized.length;
|
||||
if (nextChars > maxSectionChars) return;
|
||||
seen.add(normalized);
|
||||
target.push(normalized);
|
||||
sectionCharsRef.value = nextChars;
|
||||
}
|
||||
|
||||
function summarizeToolMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (!message.toolResults?.length) return [];
|
||||
return message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const content = normalizeWhitespace(result.content || "");
|
||||
// Same provenance problem as the raw-window path: once a tool result
|
||||
// lands in the compact section (older than the 6-item raw window),
|
||||
// its paired assistant tool_call is almost always gone. Without the
|
||||
// call label, multiple older results collapse into indistinguishable
|
||||
// "Tool result (callN): ..." lines and follow-ups like "use the
|
||||
// resolv.conf output" can't be resolved. Inline the name+args here
|
||||
// the same way toRawHistoryMessage does.
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (message.role === "system") return [];
|
||||
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (message.content && isImportantText(message.content)) {
|
||||
const label = message.role === "user" ? "User" : "Assistant";
|
||||
lines.push(`${label}: ${truncateText(normalizeWhitespace(message.content), MAX_TOOL_SUMMARY_CHARS)}`);
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
const args = JSON.stringify(toolCall.arguments ?? {});
|
||||
const summary = `Tool call: ${toolCall.name}(${truncateText(args, 220)})`;
|
||||
if (isImportantText(summary)) lines.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function summarizeDurableUserMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "user" || !message.content) return null;
|
||||
if (isTrivialUserMessage(message.content)) return null;
|
||||
return `User request: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "assistant" || !message.content) return null;
|
||||
if (!isSubstantiveAssistantMessage(message.content)) return null;
|
||||
return `Assistant context: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_ASSISTANT_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-tool-result provenance index. Keys are
|
||||
* `${toolResultMessageId}:${toolCallId}` rather than the bare toolCall.id
|
||||
* so that provider-reused ids (e.g. "call1" across unrelated turns) don't
|
||||
* cause later calls to overwrite older ones in the lookup — each
|
||||
* tool_result resolves to the most recent assistant tool_call that
|
||||
* preceded it with matching id, which preserves historical correctness
|
||||
* when rebuilding older compact summaries.
|
||||
*/
|
||||
function buildToolCallIndex(messages: ChatMessage[]): Map<string, ToolCallInfo> {
|
||||
const provenance = new Map<string, ToolCallInfo>();
|
||||
// Rolling map of the latest tool_call seen (by id) up to the current
|
||||
// point in the message stream.
|
||||
const latestByCallId = new Map<string, ToolCallInfo>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
if (!toolCall.id) continue;
|
||||
latestByCallId.set(toolCall.id, { name: toolCall.name, arguments: toolCall.arguments });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
for (const result of message.toolResults) {
|
||||
const info = latestByCallId.get(result.toolCallId);
|
||||
if (info) {
|
||||
provenance.set(`${message.id}:${result.toolCallId}`, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function lookupToolCallInfo(
|
||||
index: Map<string, ToolCallInfo>,
|
||||
toolMessageId: string,
|
||||
toolCallId: string,
|
||||
): ToolCallInfo | undefined {
|
||||
return index.get(`${toolMessageId}:${toolCallId}`);
|
||||
}
|
||||
|
||||
function toRawHistoryMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): RawHistoryMessage[] {
|
||||
if (message.role === "user") {
|
||||
return message.content
|
||||
? [{ sourceId: message.id, role: "user", content: truncateText(message.content, MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
return parts.length
|
||||
? [{ sourceId: message.id, role: "assistant", content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
|
||||
// per message, ~2000). Without this, follow-up turns after stale-session
|
||||
// recovery would only see the 500-char compact summary in
|
||||
// summarizeToolMessage, losing the actual bytes the user might reference
|
||||
// ("use that output", "what did cat show?"). ACP only supports user/
|
||||
// assistant roles, so we flatten to "assistant" — the tool results were
|
||||
// produced during the assistant's turn.
|
||||
//
|
||||
// Inline the originating tool_call's name+args. Tool calls and their
|
||||
// results live in separate messages; if the last six raw items start
|
||||
// in the middle of a tool interaction, the preceding assistant tool
|
||||
// call can be outside the window. Without the call label the result
|
||||
// is opaque bytes and "use that output" becomes ambiguous.
|
||||
const parts = message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
|
||||
});
|
||||
return [{
|
||||
sourceId: message.id,
|
||||
role: "assistant",
|
||||
content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS),
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildCompactContext(
|
||||
messages: ChatMessage[],
|
||||
durableScanStart: number,
|
||||
recentRawSourceIds: Set<string>,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): AcpHistoryMessage[] {
|
||||
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
|
||||
const summaryLines: string[] = [];
|
||||
const durableUserCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableUserLines: DurableUserLine[] = [];
|
||||
const durableAssistantCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableAssistantLines: DurableUserLine[] = [];
|
||||
const seen = new Set<string>();
|
||||
const durableChars = { value: 0 };
|
||||
const durableAssistantChars = { value: 0 };
|
||||
const summaryChars = { value: 0 };
|
||||
|
||||
for (let messageIndex = durableScanStart; messageIndex < messages.length; messageIndex += 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
const durableUserLine = summarizeDurableUserMessage(message);
|
||||
if (durableUserLine) {
|
||||
durableUserCandidates.push({
|
||||
line: durableUserLine,
|
||||
messageIndex,
|
||||
priority: getDurableUserPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
const durableAssistantLine = summarizeDurableAssistantMessage(message);
|
||||
if (durableAssistantLine) {
|
||||
durableAssistantCandidates.push({
|
||||
line: durableAssistantLine,
|
||||
messageIndex,
|
||||
priority: getDurableAssistantPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
durableUserCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_USER_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableUserLines.push(candidate);
|
||||
durableChars.value = nextChars;
|
||||
});
|
||||
|
||||
durableAssistantCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableAssistantChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_ASSISTANT_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableAssistantLines.push(candidate);
|
||||
durableAssistantChars.value = nextChars;
|
||||
});
|
||||
|
||||
const durableUserLines = selectedDurableUserLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
const durableAssistantLines = selectedDurableAssistantLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
|
||||
for (const line of [...durableUserLines, ...durableAssistantLines]) {
|
||||
seen.add(normalizeWhitespace(line));
|
||||
}
|
||||
|
||||
// Skip messages that are already appended verbatim in the raw window —
|
||||
// otherwise the same last-6 turns get summarized here AND re-sent as
|
||||
// raw, doubling the budget cost of important user turns / large tool
|
||||
// output and crowding out older durable context the replay is meant
|
||||
// to preserve. Matches the recentRawSourceIds skip in the durable pass.
|
||||
for (const message of scanned) {
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
for (const line of summarizeMessage(message, toolCallIndex)) {
|
||||
appendUniqueLine(summaryLines, seen, line, MAX_RECENT_SUMMARY_CONTEXT_CHARS, summaryChars);
|
||||
}
|
||||
}
|
||||
|
||||
if (!durableUserLines.length && !durableAssistantLines.length && !summaryLines.length) return [];
|
||||
|
||||
const contentLines = [
|
||||
"[Compact prior Netcatty UI context]",
|
||||
"The external ACP agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
|
||||
];
|
||||
if (durableUserLines.length) {
|
||||
contentLines.push("Earlier user requests that may still apply:");
|
||||
contentLines.push(...durableUserLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (durableAssistantLines.length) {
|
||||
contentLines.push("Earlier assistant context that may still matter:");
|
||||
contentLines.push(...durableAssistantLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (summaryLines.length) {
|
||||
contentLines.push("Recent noteworthy context:");
|
||||
contentLines.push(...summaryLines.map((line) => `- ${line}`));
|
||||
}
|
||||
|
||||
return [{
|
||||
role: "user",
|
||||
content: truncateText(
|
||||
contentLines.join("\n"),
|
||||
MAX_COMPACT_CONTEXT_CHARS,
|
||||
),
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first message to include in the scan window,
|
||||
* bounded by MAX_DURABLE_SCAN_TURNS user turns (not raw message count).
|
||||
* Walking backwards stops at the target turn count, so the cost is
|
||||
* bounded even when the transcript is huge.
|
||||
*/
|
||||
function computeDurableScanStart(messages: ChatMessage[]): number {
|
||||
let userTurns = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role === "user") {
|
||||
userTurns += 1;
|
||||
if (userTurns >= MAX_DURABLE_SCAN_TURNS) return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMessage[] {
|
||||
// Compute the scan start once, then do all subsequent work over the
|
||||
// already-sliced tail. This avoids O(N) walks over the whole transcript
|
||||
// on every send — previously buildToolCallIndex + the flatMap-to-take-
|
||||
// last-6 raw history both traversed every message in the chat.
|
||||
const durableScanStart = computeDurableScanStart(messages);
|
||||
const scannedTail = messages.slice(durableScanStart);
|
||||
|
||||
// The tool-call provenance index only needs entries for tool_results
|
||||
// that might appear in our output. Building from the scanned tail is
|
||||
// correct for any tool_result whose paired assistant tool_call is
|
||||
// also within the window, which covers >99% of realistic patterns
|
||||
// (tool_calls and tool_results are always adjacent or near-adjacent).
|
||||
// If an ancient tool_call's result stays within the window while the
|
||||
// call itself is outside, that single result loses its [from X(Y)]
|
||||
// label — an acceptable trade for eliminating the per-send O(N) walk.
|
||||
const toolCallIndex = buildToolCallIndex(scannedTail);
|
||||
|
||||
const rawHistory = scannedTail
|
||||
.flatMap((message) => toRawHistoryMessage(message, toolCallIndex))
|
||||
.slice(-MAX_RECENT_RAW_MESSAGES);
|
||||
const compactContext = buildCompactContext(
|
||||
messages,
|
||||
durableScanStart,
|
||||
new Set(rawHistory.map((message) => message.sourceId)),
|
||||
toolCallIndex,
|
||||
);
|
||||
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
|
||||
|
||||
return [...compactContext, ...recentRaw];
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessagesForBridge(
|
||||
messages: ChatMessage[],
|
||||
_existingSessionId?: string | null,
|
||||
): AcpHistoryMessage[] | undefined {
|
||||
// The main process bridge only consumes this payload during stale-session
|
||||
// fallback replay, so keep it available even when a session id exists.
|
||||
const historyMessages = buildAcpHistoryMessages(messages);
|
||||
return historyMessages.length ? historyMessages : undefined;
|
||||
}
|
||||
@@ -355,14 +355,13 @@ export function useAIChatStreaming({
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
let errorStr: string;
|
||||
if (err instanceof Error) errorStr = err.message;
|
||||
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
|
||||
else if (typeof err === 'string') errorStr = err;
|
||||
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
console.error('[AIChatSidePanel] Stream error (full):', err);
|
||||
// Pass the raw error to classifyError so it can inspect structured
|
||||
// fields (statusCode, responseBody) from APICallError and friends;
|
||||
// string-coercing here would strip the metadata we need to detect
|
||||
// 413 / HTML-error-page / parse-failure scenarios.
|
||||
const errorInfo = classifyError(err);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
@@ -560,11 +559,10 @@ export function useAIChatStreaming({
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(
|
||||
typedChunk.error instanceof Error ? typedChunk.error.message
|
||||
: typeof typedChunk.error === 'string' ? typedChunk.error
|
||||
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
|
||||
),
|
||||
// Pass the raw error so classifyError can detect 413 / HTML /
|
||||
// schema-parse scenarios via structured fields (statusCode,
|
||||
// responseBody) instead of lossy string conversion.
|
||||
errorInfo: classifyError(typedChunk.error),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { Ban, RotateCcw } from "lucide-react";
|
||||
import type { HotkeyScheme, KeyBinding } from "../../../domain/models";
|
||||
import { keyEventToString } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
@@ -221,7 +221,18 @@ export default function SettingsShortcutsTab(props: {
|
||||
>
|
||||
{isRecordingThis
|
||||
? t("settings.shortcuts.recording")
|
||||
: currentKey || t("settings.shortcuts.scheme.disabled")}
|
||||
: currentKey === "Disabled"
|
||||
? t("settings.shortcuts.scheme.disabled")
|
||||
: currentKey || t("settings.shortcuts.scheme.disabled")}
|
||||
</button>
|
||||
)}
|
||||
{!isSpecialBinding && (
|
||||
<button
|
||||
onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.shortcuts.setDisabled")}
|
||||
>
|
||||
<Ban size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -815,6 +815,20 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.clearWipesScrollback")}
|
||||
description={t("settings.terminal.behavior.clearWipesScrollback.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.clearWipesScrollback ?? true} onChange={(v) => updateTerminalSetting("clearWipesScrollback", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.preserveSelectionOnInput")}
|
||||
description={t("settings.terminal.behavior.preserveSelectionOnInput.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Terminal Compose Bar
|
||||
* A modern text input bar for composing commands before sending them.
|
||||
* Supports pre-reviewing passwords/commands and broadcasting to multiple sessions.
|
||||
* An immersive, borderless prompt bar that blends into the terminal's
|
||||
* background — like the Claude Code compose area. Enter sends, Escape
|
||||
* closes, Shift+Enter inserts a newline. The only visible chrome is a
|
||||
* hair-line top border separating it from the terminal output.
|
||||
*/
|
||||
import { Radio, Send, X } from 'lucide-react';
|
||||
import { Radio, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -73,10 +75,9 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${resolvedBg}, color-mix(in srgb, ${resolvedFg} 4%, ${resolvedBg} 96%))`,
|
||||
borderTop: `1px solid color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`,
|
||||
borderRadius: '0 0 8px 8px',
|
||||
padding: '6px 10px',
|
||||
backgroundColor: resolvedBg,
|
||||
borderTop: `1px solid color-mix(in srgb, ${resolvedFg} 8%, ${resolvedBg} 92%)`,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -90,77 +91,48 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input field */}
|
||||
{/* Borderless input — lives flush on the terminal bg so the
|
||||
bar feels like part of the terminal rather than a panel. */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 resize-none rounded-md px-3 py-1.5 text-xs font-mono leading-relaxed",
|
||||
"outline-none transition-all duration-200",
|
||||
"placeholder:opacity-40",
|
||||
"flex-1 min-w-0 resize-none bg-transparent border-none px-0 py-0",
|
||||
"text-xs font-mono leading-relaxed outline-none",
|
||||
"placeholder:opacity-70",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`,
|
||||
color: resolvedFg,
|
||||
border: `1px solid color-mix(in srgb, ${resolvedFg} 25%, ${resolvedBg} 75%)`,
|
||||
minHeight: '28px',
|
||||
minHeight: '20px',
|
||||
maxHeight: '120px',
|
||||
boxShadow: `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent)`,
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={t("terminal.composeBar.placeholder")}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = `color-mix(in srgb, ${resolvedFg} 40%, ${resolvedBg} 60%)`;
|
||||
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent), 0 0 0 1px color-mix(in srgb, ${resolvedFg} 8%, transparent)`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = `color-mix(in srgb, ${resolvedFg} 25%, ${resolvedBg} 75%)`;
|
||||
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent)`;
|
||||
}}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => { isComposingRef.current = false; }}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: resolvedFg,
|
||||
background: `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 30%, ${resolvedBg} 70%)`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`;
|
||||
}}
|
||||
onClick={handleSend}
|
||||
title={t("terminal.composeBar.send")}
|
||||
>
|
||||
<Send size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: `color-mix(in srgb, ${resolvedFg} 60%, ${resolvedBg} 40%)`,
|
||||
background: `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 22%, ${resolvedBg} 78%)`;
|
||||
e.currentTarget.style.color = resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`;
|
||||
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 60%, ${resolvedBg} 40%)`;
|
||||
}}
|
||||
onClick={onClose}
|
||||
title={t("terminal.composeBar.close")}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Minimal close button — no filled bg, hover only. */}
|
||||
<button
|
||||
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
|
||||
style={{
|
||||
color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`;
|
||||
e.currentTarget.style.color = resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`;
|
||||
}}
|
||||
onClick={onClose}
|
||||
title={t("terminal.composeBar.close")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
*/
|
||||
import { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import { Check, FolderInput, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host } from '../../types';
|
||||
@@ -57,100 +57,10 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
|
||||
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
|
||||
{!hidesSftp && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
disabled={status !== 'connected'}
|
||||
aria-label={t("terminal.toolbar.openSftp")}
|
||||
onClick={onOpenSFTP}
|
||||
>
|
||||
<FolderInput size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isSSHSession && onSetTerminalEncoding && (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.encoding")}
|
||||
>
|
||||
<Languages size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.encoding")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => (
|
||||
<PopoverClose asChild key={enc}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
|
||||
terminalEncoding === enc && "font-medium"
|
||||
)}
|
||||
onClick={() => onSetTerminalEncoding(enc)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
terminalEncoding === enc ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.scripts")}
|
||||
onClick={onOpenScripts}
|
||||
>
|
||||
<Zap size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.scripts")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.terminalSettings")}
|
||||
onClick={onOpenTheme}
|
||||
>
|
||||
<Palette size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.terminalSettings")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<HostKeywordHighlightPopover
|
||||
host={host}
|
||||
onUpdateHost={onUpdateHost}
|
||||
@@ -191,6 +101,85 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<TooltipContent>{t("terminal.toolbar.searchTerminal")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Overflow menu — collapses the four opener-style actions
|
||||
(SFTP / Encoding / Scripts / Terminal Settings) behind a
|
||||
single ⋮ trigger so the toolbar doesn't feel crowded.
|
||||
Highlight / Compose / Search stay visible because they
|
||||
are toggled mid-session, not just once. */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
aria-label={t("terminal.toolbar.more")}
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.more")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-48 p-1" align="end">
|
||||
{!hidesSftp && (
|
||||
<PopoverClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(menuItemClass, status !== 'connected' && "opacity-50 pointer-events-none")}
|
||||
onClick={onOpenSFTP}
|
||||
disabled={status !== 'connected'}
|
||||
>
|
||||
<FolderInput size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
)}
|
||||
<PopoverClose asChild>
|
||||
<button type="button" className={menuItemClass} onClick={onOpenScripts}>
|
||||
<Zap size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t("terminal.toolbar.scripts")}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
<PopoverClose asChild>
|
||||
<button type="button" className={menuItemClass} onClick={onOpenTheme}>
|
||||
<Palette size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t("terminal.toolbar.terminalSettings")}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
{isSSHSession && onSetTerminalEncoding && (
|
||||
<>
|
||||
<div className="h-px bg-border/60 my-1 mx-1" />
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
||||
<Languages size={11} />
|
||||
{t("terminal.toolbar.encoding")}
|
||||
</div>
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => (
|
||||
<PopoverClose asChild key={enc}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(menuItemClass, "pl-6", terminalEncoding === enc && "font-medium")}
|
||||
onClick={() => onSetTerminalEncoding(enc)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
terminalEncoding === enc ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showClose && onClose && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -114,6 +114,10 @@ export type CreateXTermRuntimeContext = {
|
||||
onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
|
||||
// Autocomplete input handler — called on every character input
|
||||
onAutocompleteInput?: (data: string) => void;
|
||||
|
||||
// Set to true while we're programmatically restoring a selection so that
|
||||
// copy-on-select listeners can suppress redundant clipboard writes.
|
||||
isRestoringSelectionRef?: RefObject<boolean>;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -419,6 +423,38 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true;
|
||||
}
|
||||
|
||||
// Preserve mouse selection across keystrokes when enabled. xterm.js
|
||||
// unconditionally clears the selection on user input
|
||||
// (SelectionService.ts: coreService.onUserInput → clearSelection).
|
||||
// Capture the selection here, then re-apply it after xterm has
|
||||
// processed the key + cleared. The microtask runs after both
|
||||
// synchronous listeners, so by then either the selection is gone (and
|
||||
// we restore) or it's still there (we no-op).
|
||||
if (
|
||||
ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
|
||||
term.hasSelection()
|
||||
) {
|
||||
const sel = term.getSelectionPosition();
|
||||
if (sel) {
|
||||
const length =
|
||||
(sel.end.y - sel.start.y) * term.cols + (sel.end.x - sel.start.x);
|
||||
const savedStartX = sel.start.x;
|
||||
const savedStartY = sel.start.y;
|
||||
queueMicrotask(() => {
|
||||
if (term.hasSelection()) return;
|
||||
// Bail out if scrollback trim invalidated the row index.
|
||||
if (savedStartY >= term.buffer.active.length) return;
|
||||
const restoreFlag = ctx.isRestoringSelectionRef;
|
||||
if (restoreFlag) restoreFlag.current = true;
|
||||
try {
|
||||
term.select(savedStartX, savedStartY, length);
|
||||
} finally {
|
||||
if (restoreFlag) restoreFlag.current = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Autocomplete key handler (must be checked before other handlers)
|
||||
if (ctx.onAutocompleteKeyEvent) {
|
||||
const consumed = ctx.onAutocompleteKeyEvent(e);
|
||||
@@ -664,7 +700,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (!isEraseScrollbackSequence(params)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
|
||||
// Honor it unless the user opts into the legacy "preserve history" behavior.
|
||||
const wipeAllowed = ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true;
|
||||
return !wipeAllowed;
|
||||
});
|
||||
|
||||
// Register OSC 7 handler using xterm.js parser
|
||||
|
||||
63
components/ui/ripple.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button, ButtonProps } from "./button";
|
||||
|
||||
interface RippleState {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const RIPPLE_DURATION_MS = 600;
|
||||
|
||||
export const RippleButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ children, className, onPointerDown, ...props }, ref) => {
|
||||
const [ripples, setRipples] = React.useState<RippleState[]>([]);
|
||||
const nextId = React.useRef(0);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height) * 2;
|
||||
const x = e.clientX - rect.left - size / 2;
|
||||
const y = e.clientY - rect.top - size / 2;
|
||||
const id = nextId.current++;
|
||||
setRipples((rs) => [...rs, { id, x, y, size }]);
|
||||
window.setTimeout(
|
||||
() => setRipples((rs) => rs.filter((r) => r.id !== id)),
|
||||
RIPPLE_DURATION_MS,
|
||||
);
|
||||
onPointerDown?.(e);
|
||||
},
|
||||
[onPointerDown],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
onPointerDown={handlePointerDown}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="pointer-events-none absolute inset-0">
|
||||
{ripples.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className="absolute rounded-full bg-current"
|
||||
style={{
|
||||
left: r.x,
|
||||
top: r.y,
|
||||
width: r.size,
|
||||
height: r.size,
|
||||
animation: `ripple ${RIPPLE_DURATION_MS}ms ease-out forwards`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
RippleButton.displayName = "RippleButton";
|
||||
276
components/workspace/AddToWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* AddToWorkspaceDialog — lightweight multi-select picker for appending
|
||||
* new panes into the active workspace. Visually matches QuickSwitcher
|
||||
* (fixed top overlay, same header / row chrome) but with checkmarks on
|
||||
* the right and a thin footer to commit the selection.
|
||||
*/
|
||||
import { Check, Search, Terminal } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export type AddTarget =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'host'; host: Host };
|
||||
|
||||
interface AddToWorkspaceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hosts: Host[];
|
||||
workspaceTitle?: string;
|
||||
onAdd: (targets: AddTarget[]) => void;
|
||||
}
|
||||
|
||||
const LOCAL_ITEM_ID = '__local-terminal__';
|
||||
|
||||
type Item =
|
||||
| { type: 'local'; id: typeof LOCAL_ITEM_ID }
|
||||
| { type: 'host'; id: string; host: Host };
|
||||
|
||||
export const AddToWorkspaceDialog: React.FC<AddToWorkspaceDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
hosts,
|
||||
workspaceTitle,
|
||||
onAdd,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset on open + auto-focus the search input.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery('');
|
||||
setSelected(new Set());
|
||||
setSelectedIndex(0);
|
||||
const timer = window.setTimeout(() => inputRef.current?.focus(), 40);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
// Close on click outside.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// NOTE: no serial filter here — callers decide which subset of
|
||||
// hosts to pass based on mode. `appendHostToWorkspace` cannot build
|
||||
// a serial session, so append mode passes non-serial hosts only;
|
||||
// `createWorkspaceFromTargets` handles serial explicitly, so create
|
||||
// mode passes everything.
|
||||
const selectableHosts = hosts;
|
||||
|
||||
const localMatches = useMemo(() => {
|
||||
const term = query.trim().toLowerCase();
|
||||
if (!term) return true;
|
||||
return 'local terminal localhost'.includes(term);
|
||||
}, [query]);
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
const term = query.trim().toLowerCase();
|
||||
if (!term) return selectableHosts;
|
||||
return selectableHosts.filter((h) =>
|
||||
(h.label?.toLowerCase().includes(term))
|
||||
|| (h.hostname?.toLowerCase().includes(term))
|
||||
|| (h.username?.toLowerCase().includes(term))
|
||||
|| (h.group?.toLowerCase().includes(term)),
|
||||
);
|
||||
}, [selectableHosts, query]);
|
||||
|
||||
const items = useMemo<Item[]>(() => {
|
||||
const list: Item[] = [];
|
||||
if (localMatches) list.push({ type: 'local', id: LOCAL_ITEM_ID });
|
||||
for (const h of filteredHosts) list.push({ type: 'host', id: h.id, host: h });
|
||||
return list;
|
||||
}, [localMatches, filteredHosts]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCommit = () => {
|
||||
if (selected.size === 0) return;
|
||||
const targets: AddTarget[] = [];
|
||||
if (selected.has(LOCAL_ITEM_ID)) targets.push({ kind: 'local' });
|
||||
for (const host of selectableHosts) {
|
||||
if (selected.has(host.id)) targets.push({ kind: 'host', host });
|
||||
}
|
||||
if (targets.length === 0) return;
|
||||
onAdd(targets);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, Math.max(items.length - 1, 0)));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === ' ' || (e.key === 'Enter' && !(e.metaKey || e.ctrlKey))) {
|
||||
if (items.length === 0) return;
|
||||
e.preventDefault();
|
||||
toggle(items[selectedIndex].id);
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleCommit();
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const count = selected.size;
|
||||
const localIndex = items.findIndex((it) => it.type === 'local');
|
||||
const firstHostIndex = items.findIndex((it) => it.type === 'host');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 top-12 z-50 flex justify-center pt-2"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full max-w-2xl mx-4 bg-background border border-border rounded-xl shadow-2xl overflow-hidden max-h-[520px] flex flex-col"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
{/* Search header — mirrors QuickSwitcher chrome. */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
||||
<Search size={16} className="text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search hosts or local shells..."
|
||||
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
|
||||
/>
|
||||
{workspaceTitle && (
|
||||
<span className="text-[11px] text-muted-foreground truncate max-w-[180px]">
|
||||
{workspaceTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
<div>
|
||||
{/* Jump-to hint */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Pick one or more</span>
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">Space</kbd>
|
||||
<span className="text-[10px] text-muted-foreground">toggle</span>
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'}+Enter
|
||||
</kbd>
|
||||
<span className="text-[10px] text-muted-foreground">add</span>
|
||||
</div>
|
||||
|
||||
{/* Local Shells section */}
|
||||
{localIndex !== -1 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Local Shells
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const idx = localIndex;
|
||||
const isCursor = idx === selectedIndex;
|
||||
const isChecked = selected.has(LOCAL_ITEM_ID);
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isCursor ? 'bg-primary/15' : 'hover:bg-muted/50'}`}
|
||||
onClick={() => toggle(LOCAL_ITEM_ID)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium flex-1 truncate">Local Terminal</span>
|
||||
{isChecked && <Check size={14} className="text-primary flex-shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hosts section */}
|
||||
{filteredHosts.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Hosts</span>
|
||||
</div>
|
||||
{filteredHosts.map((host, i) => {
|
||||
const idx = firstHostIndex + i;
|
||||
const isCursor = idx === selectedIndex;
|
||||
const isChecked = selected.has(host.id);
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isCursor ? 'bg-primary/15' : 'hover:bg-muted/50'}`}
|
||||
onClick={() => toggle(host.id)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<DistroAvatar host={host} fallback={(host.label || host.hostname).slice(0, 2).toUpperCase()} size="sm" />
|
||||
<span className="text-sm font-medium truncate">{host.label || host.hostname}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{host.group ? `Personal / ${host.group}` : 'Personal'}
|
||||
</div>
|
||||
{isChecked && <Check size={14} className="text-primary flex-shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
|
||||
No matches
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Slim footer to commit. Kept minimal so the layout feels like
|
||||
QuickSwitcher's chrome with a single action strip tacked on. */}
|
||||
<div className="flex items-center justify-end gap-2 px-3 py-2 border-t border-border">
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" disabled={count === 0} onClick={handleCommit}>
|
||||
{count === 0 ? 'Add' : `Add ${count}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddToWorkspaceDialog;
|
||||
@@ -411,6 +411,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
{ id: 'port-forwarding', action: 'portForwarding', label: 'Open Port Forwarding', mac: '⌘ + P', pc: 'Ctrl + P', category: 'app' },
|
||||
{ id: 'command-palette', action: 'commandPalette', label: 'Open Command Palette', mac: '⌘ + K', pc: 'Ctrl + K', category: 'app' },
|
||||
{ id: 'quick-switch', action: 'quickSwitch', label: 'Quick Switch', mac: '⌘ + J', pc: 'Ctrl + J', category: 'app' },
|
||||
{ id: 'new-workspace', action: 'newWorkspace', label: 'New Workspace', mac: '⌘ + Shift + J', pc: 'Ctrl + Shift + J', category: 'app' },
|
||||
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
|
||||
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
|
||||
|
||||
@@ -497,6 +498,18 @@ export interface TerminalSettings {
|
||||
// Paste
|
||||
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
|
||||
|
||||
// Shell `clear` command behavior — controls whether CSI 3 J (erase scrollback)
|
||||
// from the shell is honored. Default true matches POSIX/ncurses since 2013:
|
||||
// `clear` clears both visible screen and scrollback. Disable to keep history
|
||||
// across `clear` (matches iTerm2 default and pre-2013 behavior).
|
||||
clearWipesScrollback: boolean;
|
||||
|
||||
// When true, typing on the keyboard does NOT clear an existing mouse
|
||||
// selection. Lets the user select text, type a command prefix (e.g. `sz `),
|
||||
// and then paste the still-live selection. xterm.js's default is to clear
|
||||
// on input; this opt-in toggle restores the selection right after.
|
||||
preserveSelectionOnInput: boolean;
|
||||
|
||||
// Clipboard
|
||||
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
|
||||
|
||||
@@ -625,6 +638,8 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
showServerStats: true, // Show server stats by default
|
||||
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
|
||||
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
|
||||
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
autocompleteEnabled: true, // Autocomplete enabled by default
|
||||
|
||||
@@ -364,7 +364,16 @@ export type SyncEvent =
|
||||
| { type: 'AUTH_REQUIRED'; provider: CloudProvider }
|
||||
| { type: 'AUTH_COMPLETED'; provider: CloudProvider; account: ProviderAccount }
|
||||
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState }
|
||||
| { type: 'SYNC_BLOCKED_CLEARED' };
|
||||
| { type: 'SYNC_BLOCKED_CLEARED' }
|
||||
| {
|
||||
type: 'PROVIDERS_DIVERGED';
|
||||
summaries: Array<{
|
||||
provider: CloudProvider;
|
||||
hosts: number;
|
||||
keys: number;
|
||||
snippets: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Storage Keys
|
||||
|
||||
@@ -32,11 +32,45 @@ function hosts(n: number): SyncPayload["hosts"] {
|
||||
})) as SyncPayload["hosts"];
|
||||
}
|
||||
|
||||
test("null base → not suspicious (first sync / null after re-auth)", () => {
|
||||
test("null base, no remote fallback → not suspicious (nothing to compare)", () => {
|
||||
const result = detectSuspiciousShrink(payload({ hosts: hosts(1) }), null);
|
||||
assert.deepEqual(result, { suspicious: false });
|
||||
});
|
||||
|
||||
test("null base + empty remote → not suspicious (genuinely empty cloud)", () => {
|
||||
const result = detectSuspiciousShrink(payload({ hosts: hosts(5) }), null, payload());
|
||||
assert.deepEqual(result, { suspicious: false });
|
||||
});
|
||||
|
||||
test("null base + populated remote + empty outgoing → suspicious via remote (#779 scenario)", () => {
|
||||
// Fresh install with no stored base; remote already holds user's keychain.
|
||||
// Local payload is empty (degraded vault / load race) → must be blocked.
|
||||
const remote = payload({ keys: Array.from({ length: 8 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
|
||||
const out = payload();
|
||||
const result = detectSuspiciousShrink(out, null, remote);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) {
|
||||
assert.equal(result.entityType, "keys");
|
||||
assert.equal(result.viaRemote, true);
|
||||
assert.equal(result.lost, 8);
|
||||
}
|
||||
});
|
||||
|
||||
test("null base + larger remote + outgoing growth → not suspicious (lost is negative)", () => {
|
||||
const remote = payload({ hosts: hosts(3) });
|
||||
const out = payload({ hosts: hosts(10) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, null, remote), { suspicious: false });
|
||||
});
|
||||
|
||||
test("base present takes precedence over remote fallback", () => {
|
||||
// base=10, outgoing=10 → not suspicious; remote=0 should NOT trigger a
|
||||
// via-remote warning because a real base is available.
|
||||
const base = payload({ hosts: hosts(10) });
|
||||
const remote = payload();
|
||||
const out = payload({ hosts: hosts(10) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base, remote), { suspicious: false });
|
||||
});
|
||||
|
||||
test("no shrink — same counts → not suspicious", () => {
|
||||
const base = payload({ hosts: hosts(5) });
|
||||
const out = payload({ hosts: hosts(5) });
|
||||
|
||||
@@ -18,6 +18,8 @@ export type ShrinkFinding =
|
||||
baseCount: number;
|
||||
outgoingCount: number;
|
||||
lost: number;
|
||||
/** True when the comparison reference was the current remote (base was null). */
|
||||
viaRemote?: boolean;
|
||||
};
|
||||
|
||||
// Keep in sync with all array-typed fields of SyncPayload. When a new
|
||||
@@ -49,11 +51,21 @@ function countOf(p: SyncPayload, key: CheckedEntityType): number {
|
||||
export function detectSuspiciousShrink(
|
||||
outgoing: SyncPayload,
|
||||
base: SyncPayload | null,
|
||||
remote?: SyncPayload | null,
|
||||
): ShrinkFinding {
|
||||
if (!base) return { suspicious: false };
|
||||
// Fall back to the current remote when we have no stored base — a null base
|
||||
// happens on first sync, after unlock key re-derivation, or when the base
|
||||
// blob failed to decrypt. Without this fallback, a degraded/empty local
|
||||
// payload would be admitted unconditionally and could overwrite populated
|
||||
// remote data (#779). We only use `remote` when `base` is unavailable so
|
||||
// legitimate resurrections (device that legitimately grew past an older
|
||||
// remote snapshot) remain unaffected.
|
||||
const reference = base ?? remote ?? null;
|
||||
const viaRemote = !base && !!remote;
|
||||
if (!reference) return { suspicious: false };
|
||||
|
||||
for (const entityType of CHECKED_ENTITIES) {
|
||||
const baseCount = countOf(base, entityType);
|
||||
const baseCount = countOf(reference, entityType);
|
||||
const outgoingCount = countOf(outgoing, entityType);
|
||||
const lost = baseCount - outgoingCount;
|
||||
if (lost <= 0) continue;
|
||||
@@ -66,6 +78,7 @@ export function detectSuspiciousShrink(
|
||||
baseCount,
|
||||
outgoingCount,
|
||||
lost,
|
||||
...(viaRemote ? { viaRemote: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +90,7 @@ export function detectSuspiciousShrink(
|
||||
baseCount,
|
||||
outgoingCount,
|
||||
lost,
|
||||
...(viaRemote ? { viaRemote: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,24 +16,75 @@ export const pruneWorkspaceNode = (node: WorkspaceNode, targetSessionId: string)
|
||||
|
||||
const nextChildren: WorkspaceNode[] = [];
|
||||
const nextSizes: number[] = [];
|
||||
const sizeList = node.sizes && node.sizes.length === node.children.length ? node.sizes : node.children.map(() => 1);
|
||||
const sizeList = node.sizes && node.sizes.length === node.children.length
|
||||
? node.sizes
|
||||
: node.children.map(() => 1 / node.children.length);
|
||||
let removedDirectChild = false;
|
||||
|
||||
node.children.forEach((child, idx) => {
|
||||
const pruned = pruneWorkspaceNode(child, targetSessionId);
|
||||
if (pruned) {
|
||||
nextChildren.push(pruned);
|
||||
nextSizes.push(sizeList[idx] ?? 1);
|
||||
nextSizes.push(sizeList[idx] ?? 1 / node.children.length);
|
||||
} else {
|
||||
removedDirectChild = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (nextChildren.length === 0) return null;
|
||||
if (nextChildren.length === 1) return nextChildren[0];
|
||||
|
||||
// Only rebalance siblings to equal sizes when this level actually
|
||||
// lost one of its direct children. If the prune happened deeper in
|
||||
// one branch, this split's direct children are unchanged and their
|
||||
// original ratios must be preserved (otherwise e.g. a root 0.8/0.2
|
||||
// split gets rewritten to 0.5/0.5 when a grand-child pane closes).
|
||||
if (removedDirectChild) {
|
||||
const equalSize = 1 / nextChildren.length;
|
||||
return { ...node, children: nextChildren, sizes: nextChildren.map(() => equalSize) };
|
||||
}
|
||||
|
||||
// Preserve existing ratios; normalise defensively in case sibling
|
||||
// subtrees changed shape (e.g. a split collapsed to a single pane).
|
||||
const total = nextSizes.reduce((acc, n) => acc + n, 0) || 1;
|
||||
const normalized = nextSizes.map(n => n / total);
|
||||
return { ...node, children: nextChildren, sizes: normalized };
|
||||
};
|
||||
|
||||
/**
|
||||
* Append a new pane containing `sessionId` to the end of the workspace
|
||||
* root's split. If the root already splits in the requested direction,
|
||||
* the new pane becomes its last sibling and all sibling sizes are reset
|
||||
* to equal. Otherwise the root is wrapped in a new split (same behaviour
|
||||
* as the existing `insertPaneIntoWorkspace(root, id, { targetSessionId:
|
||||
* undefined })` path) with two equal children.
|
||||
*/
|
||||
export const appendPaneToWorkspaceRoot = (
|
||||
root: WorkspaceNode,
|
||||
sessionId: string,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): WorkspaceNode => {
|
||||
const newPane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId };
|
||||
|
||||
if (root.type === 'split' && root.direction === direction) {
|
||||
const nextChildren = [...root.children, newPane];
|
||||
const equalSize = 1 / nextChildren.length;
|
||||
return {
|
||||
...root,
|
||||
children: nextChildren,
|
||||
sizes: nextChildren.map(() => equalSize),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'split',
|
||||
direction,
|
||||
children: [root, newPane],
|
||||
sizes: [0.5, 0.5],
|
||||
};
|
||||
};
|
||||
|
||||
const createSplitFromPane = (
|
||||
existingPane: WorkspaceNode,
|
||||
newPane: WorkspaceNode,
|
||||
|
||||
@@ -4,8 +4,10 @@ const path = require("node:path");
|
||||
const USER_SKILLS_DIR_NAME = "Skills";
|
||||
const USER_SKILLS_README_NAME = "README.txt";
|
||||
const MAX_SKILL_BYTES = 24 * 1024;
|
||||
const MAX_DESCRIPTION_LENGTH = 280;
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
const MAX_INDEX_SKILLS = 8;
|
||||
const MAX_INDEX_DESCRIPTION_CHARS = 160;
|
||||
const MAX_INDEX_LINE_CHARS = 1400;
|
||||
const MAX_EXPLICIT_SKILLS = 4;
|
||||
const MAX_MATCHED_SKILLS = 2;
|
||||
const MAX_MATCHED_SKILL_CHARS = 6000;
|
||||
@@ -67,6 +69,12 @@ function escapeRegExp(value) {
|
||||
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function truncateInlineText(value, maxChars) {
|
||||
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= maxChars) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function formatSkillReadWarning(error) {
|
||||
const code = typeof error?.code === "string" ? error.code : null;
|
||||
const message = typeof error?.message === "string" ? error.message : String(error || "Unknown error");
|
||||
@@ -354,11 +362,22 @@ async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs =
|
||||
}
|
||||
|
||||
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
|
||||
const remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
|
||||
let remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
|
||||
const indexEntries = [];
|
||||
let indexChars = 0;
|
||||
|
||||
const indexLine = indexSkills
|
||||
.map((skill) => `${skill.name}: ${skill.description}`)
|
||||
.join("; ");
|
||||
for (const skill of indexSkills) {
|
||||
const entry = `${skill.name}: ${truncateInlineText(skill.description, MAX_INDEX_DESCRIPTION_CHARS)}`;
|
||||
const separatorChars = indexEntries.length > 0 ? 2 : 0;
|
||||
if (indexChars + separatorChars + entry.length > MAX_INDEX_LINE_CHARS) {
|
||||
remainingCount += indexSkills.length - indexEntries.length;
|
||||
break;
|
||||
}
|
||||
indexEntries.push(entry);
|
||||
indexChars += separatorChars + entry.length;
|
||||
}
|
||||
|
||||
const indexLine = indexEntries.join("; ");
|
||||
|
||||
const orderedExplicitSlugs = [];
|
||||
const seenExplicitSlugs = new Set();
|
||||
|
||||
@@ -99,6 +99,69 @@ test("keeps every explicitly selected skill in the built context", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("uses longer skill descriptions for routing matches without injecting the full index text", async () => {
|
||||
const longDescription = [
|
||||
"Use when the user needs a detailed workflow for operating Netcatty through ACP skills and CLI.",
|
||||
"Includes platform launcher guidance, scoped command execution, recovery behavior, and constraints.",
|
||||
"This intentionally exceeds the older short description budget so routing has enough signal.",
|
||||
"It also names edge cases such as unavailable optional shells, strict chat-session scoping, and fallback-only history replay so the agent can choose the skill without reading the whole body first.",
|
||||
].join(" ");
|
||||
|
||||
assert.ok(longDescription.length > 320);
|
||||
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "Detailed Router",
|
||||
name: "Detailed Router",
|
||||
description: longDescription,
|
||||
body: "Detailed router body",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const status = await scanUserSkills(electronApp);
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"Need fallback-only history replay guidance for ACP recovery.",
|
||||
[],
|
||||
);
|
||||
|
||||
assert.equal(status.readyCount, 1);
|
||||
assert.equal(status.warningCount, 0);
|
||||
assert.equal(result.context.includes("### Detailed Router"), true);
|
||||
assert.equal(result.context.includes("Detailed router body"), true);
|
||||
assert.equal(result.context.includes(longDescription), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("caps the injected available-skills index when descriptions are very long", async () => {
|
||||
const longDescription = "signal ".repeat(65);
|
||||
|
||||
await withUserSkills(
|
||||
Array.from({ length: 8 }, (_, index) => ({
|
||||
directoryName: `Skill ${index + 1}`,
|
||||
name: `Skill ${index + 1}`,
|
||||
description: `${longDescription}${index + 1}`,
|
||||
body: `Body ${index + 1}`,
|
||||
})),
|
||||
async (electronApp) => {
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"plain prompt",
|
||||
[],
|
||||
);
|
||||
|
||||
const availableLine = result.context
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Available user skills: "));
|
||||
|
||||
assert.ok(availableLine, "expected available-skills index line");
|
||||
assert.ok(availableLine.length < 1800, `expected capped index line, got ${availableLine.length}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves an unavailable explicit selection in the built context", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
|
||||
@@ -2317,6 +2317,14 @@ function registerHandlers(ipcMain) {
|
||||
try {
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
// Capture whether the prior run was already cancelled (via the
|
||||
// cancel IPC) BEFORE we set the flag ourselves — the cancel IPC
|
||||
// contract explicitly preserves the provider session so the
|
||||
// next prompt can continue in the same conversation. Tearing
|
||||
// down the provider here would silently break that contract in
|
||||
// the "click Stop, then immediately send next prompt" flow,
|
||||
// discarding the recovered ACP session.
|
||||
const alreadyCancelledViaIpc = existingRun.cancelRequested;
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
@@ -2324,7 +2332,15 @@ function registerHandlers(ipcMain) {
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
// Only tear down the provider for true interrupt-and-restart
|
||||
// flows (user typed a new prompt while the old one was still
|
||||
// streaming, no explicit cancel). When we do skip cleanup here,
|
||||
// the reuse/reset logic below still handles auth/MCP/permission
|
||||
// changes correctly — the provider is preserved only when
|
||||
// nothing else would require rebuilding it.
|
||||
if (!alreadyCancelledViaIpc) {
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
|
||||
@@ -2476,9 +2492,48 @@ function registerHandlers(ipcMain) {
|
||||
providerEntry.mcpFingerprint === mcpSnapshot.fingerprint &&
|
||||
providerEntry.permissionMode === currentPermissionMode,
|
||||
);
|
||||
const shouldResetProviderForHistoryReplay = Boolean(
|
||||
shouldReuseProvider &&
|
||||
providerEntry?.historyReplayFallback &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
);
|
||||
|
||||
if (!shouldReuseProvider) {
|
||||
const resumeSessionId = providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
|
||||
if (!shouldReuseProvider || shouldResetProviderForHistoryReplay) {
|
||||
const resumeSessionId = shouldResetProviderForHistoryReplay
|
||||
? undefined
|
||||
: providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
|
||||
// Preserve the replay-fallback flag across any recreation where
|
||||
// history recovery is still pending, not just the reset-for-replay
|
||||
// path. Otherwise a provider recreation driven by an orthogonal
|
||||
// change (permission mode / MCP scope / auth fingerprint) between
|
||||
// a still-empty recovered turn and its retry would drop the flag
|
||||
// and lose the recovered conversation on the next turn.
|
||||
//
|
||||
// Also hedge whenever we're spawning a brand-new provider process
|
||||
// that's being told to resume an existing session id (the common
|
||||
// app-restart / reconnect flow — #753). Some ACP agents (Copilot
|
||||
// CLI, some Codex builds) silently spin up a fresh session
|
||||
// instead of erroring with "session not found", so the catch-
|
||||
// block fallback below never fires and the agent ends up with
|
||||
// zero prior context. Scheduling a compact replay on the first
|
||||
// turn guarantees the agent sees durable constraints and the
|
||||
// last few raw turns even when session/load is effectively a
|
||||
// no-op. After the first successful streamed turn the flag
|
||||
// clears (post-stream hook), so steady-state cost stays at
|
||||
// just the latest prompt.
|
||||
const preserveHistoryReplayFallback =
|
||||
shouldResetProviderForHistoryReplay ||
|
||||
Boolean(
|
||||
providerEntry?.historyReplayFallback &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
) ||
|
||||
Boolean(
|
||||
resumeSessionId &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||
@@ -2555,7 +2610,7 @@ function registerHandlers(ipcMain) {
|
||||
authFingerprint,
|
||||
mcpFingerprint: mcpSnapshot.fingerprint,
|
||||
permissionMode: currentPermissionMode,
|
||||
historyReplayFallback: false,
|
||||
historyReplayFallback: preserveHistoryReplayFallback,
|
||||
};
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
}
|
||||
@@ -2726,14 +2781,17 @@ function registerHandlers(ipcMain) {
|
||||
role: "user",
|
||||
content: buildMessageContent(contextualPrompt, images),
|
||||
};
|
||||
const shouldReplayHistory = Boolean(
|
||||
providerEntry.historyReplayFallback &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
);
|
||||
|
||||
const result = streamText({
|
||||
model: modelInstance,
|
||||
messages: providerEntry.historyReplayFallback
|
||||
messages: shouldReplayHistory
|
||||
? [
|
||||
...(Array.isArray(historyMessages)
|
||||
? historyMessages.map((msg) => ({ role: msg.role, content: msg.content }))
|
||||
: []),
|
||||
...historyMessages.map((msg) => ({ role: msg.role, content: msg.content })),
|
||||
latestPromptMessage,
|
||||
]
|
||||
: [latestPromptMessage],
|
||||
@@ -2819,6 +2877,21 @@ function registerHandlers(ipcMain) {
|
||||
: "Agent returned an empty response.",
|
||||
});
|
||||
} else {
|
||||
// Clear replay fallback when the recovered turn either streamed
|
||||
// content OR was user-aborted. The empty-but-not-aborted case is
|
||||
// handled in the if-branch above and intentionally keeps the flag
|
||||
// so a follow-up retry can re-replay onto a fresh session.
|
||||
//
|
||||
// Why also clear on abort: if the user actively cancelled, the
|
||||
// freshly recovered ACP session has whatever state was built up so
|
||||
// far. Leaving the flag set would make the next turn trigger
|
||||
// shouldResetProviderForHistoryReplay, which discards the recovered
|
||||
// session (resumeSessionId is forced to undefined in that path) and
|
||||
// re-spends tokens on another compact replay. That breaks the
|
||||
// cancel-preserves-session contract for users who stop early.
|
||||
if (shouldReplayHistory) {
|
||||
providerEntry.historyReplayFallback = false;
|
||||
}
|
||||
debugMcpLog("ACP stream done", { requestId, chatSessionId, hasContent });
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
||||
return { ok: true };
|
||||
@@ -2871,6 +2944,18 @@ function registerHandlers(ipcMain) {
|
||||
if (activeRun && activeRun.requestId === effectiveRequestId) {
|
||||
activeRun.cancelRequested = true;
|
||||
}
|
||||
// Synchronously clear historyReplayFallback on the preserved provider
|
||||
// entry. Without this, a user pressing Stop and immediately sending
|
||||
// the next prompt can have their new request enter the stream
|
||||
// handler before the aborted run's post-stream clearing code runs.
|
||||
// The new turn would then see historyReplayFallback=true, trigger
|
||||
// shouldResetProviderForHistoryReplay, and recreate the provider
|
||||
// without the recovered existingSessionId — discarding the very
|
||||
// session the cancel contract promised to preserve.
|
||||
if (effectiveChatSessionId) {
|
||||
const preservedEntry = acpProviders.get(effectiveChatSessionId);
|
||||
if (preservedEntry) preservedEntry.historyReplayFallback = false;
|
||||
}
|
||||
const controller = acpActiveStreams.get(effectiveRequestId);
|
||||
let cancelled = false;
|
||||
if (controller) {
|
||||
|
||||
837
electron/bridges/aiBridge.test.cjs
Normal file
@@ -0,0 +1,837 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const Module = require("node:module");
|
||||
|
||||
function createIpcMainStub() {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyStreamResult() {
|
||||
return {
|
||||
fullStream: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadBridgeWithMocks(options = {}) {
|
||||
const streamCalls = [];
|
||||
const safeSendCalls = [];
|
||||
let providerCreationCount = 0;
|
||||
const providerCreationArgs = [];
|
||||
|
||||
const fallbackProvider = {
|
||||
tools: {},
|
||||
languageModel() {
|
||||
return { id: "fake-model" };
|
||||
},
|
||||
async initSession() {},
|
||||
getSessionId() {
|
||||
return "fresh-session";
|
||||
},
|
||||
cleanup() {},
|
||||
};
|
||||
|
||||
const mocks = {
|
||||
"./mcpServerBridge.cjs": {
|
||||
init() {},
|
||||
setMainWindowGetter() {},
|
||||
getOrCreateHost: async () => 4010,
|
||||
getScopedSessionIds: () => [],
|
||||
buildMcpServerConfig: () => ({ name: "netcatty-remote-hosts", type: "http", url: "http://127.0.0.1:4010" }),
|
||||
getPermissionMode: () =>
|
||||
typeof options.getPermissionMode === "function"
|
||||
? options.getPermissionMode()
|
||||
: "default",
|
||||
getMaxIterations: () => 20,
|
||||
setChatSessionCancelled() {},
|
||||
cancelPtyExecsForSession() {},
|
||||
clearPendingApprovals() {},
|
||||
cleanupScopedMetadata: async () => {},
|
||||
cleanup() {},
|
||||
},
|
||||
"../cli/discoveryPath.cjs": {
|
||||
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
|
||||
TOOL_CLI_DISCOVERY_ENV_VAR: "NETCATTY_TOOL_CLI_DISCOVERY_FILE",
|
||||
},
|
||||
"./ai/userSkills.cjs": {
|
||||
scanUserSkills: async () => ({ readyCount: 0, warningCount: 0, skills: [], warnings: [] }),
|
||||
buildUserSkillsContext: async () => ({ context: "", selectedSkills: [] }),
|
||||
toPublicUserSkillsStatus: (value) => value,
|
||||
},
|
||||
"./ai/shellUtils.cjs": {
|
||||
stripAnsi: (value) => value,
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
shouldUseShellForCommand: () => false,
|
||||
resolveCliFromPath: () => null,
|
||||
resolveClaudeAcpBinaryPath: () => null,
|
||||
getShellEnv: async () => ({}),
|
||||
invalidateShellEnvCache() {},
|
||||
serializeStreamChunk: (chunk) => chunk,
|
||||
toUnpackedAsarPath: (value) => value,
|
||||
},
|
||||
"./ai/codexHelpers.cjs": {
|
||||
codexLoginSessions: new Map(),
|
||||
resolveCodexAcpBinaryPath: () => null,
|
||||
appendCodexLoginOutput() {},
|
||||
toCodexLoginSessionResponse: () => ({}),
|
||||
getActiveCodexLoginSession: () => null,
|
||||
normalizeCodexIntegrationState: () => ({}),
|
||||
readCodexCustomProviderConfig: () => null,
|
||||
getCodexAuthOverride: () => ({}),
|
||||
getCodexCustomConfigPreflightError: () => null,
|
||||
extractCodexError: (err) => ({ message: err?.message || String(err) }),
|
||||
isCodexAuthError: () => false,
|
||||
getCodexAuthFingerprint: (...args) =>
|
||||
typeof options.getCodexAuthFingerprint === "function"
|
||||
? options.getCodexAuthFingerprint(...args)
|
||||
: "auth-fingerprint",
|
||||
getCodexMcpFingerprint: () => "mcp-fingerprint",
|
||||
invalidateCodexValidationCache() {},
|
||||
getCodexValidationCache: () => null,
|
||||
setCodexValidationCache() {},
|
||||
},
|
||||
"./ai/ptyExec.cjs": {
|
||||
execViaPty: async () => {
|
||||
throw new Error("execViaPty should not be called in this test");
|
||||
},
|
||||
},
|
||||
"./ipcUtils.cjs": {
|
||||
safeSend(sender, channel, payload) {
|
||||
safeSendCalls.push({ sender, channel, payload });
|
||||
},
|
||||
},
|
||||
"./windowManager.cjs": {
|
||||
getMainWindow() {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
webContents: { id: 1 },
|
||||
};
|
||||
},
|
||||
getSettingsWindow() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"@mcpc-tech/acp-ai-provider": {
|
||||
createACPProvider(args) {
|
||||
providerCreationCount += 1;
|
||||
providerCreationArgs.push(args);
|
||||
if (typeof options.createACPProvider === "function") {
|
||||
return options.createACPProvider({ args, providerCreationCount, fallbackProvider });
|
||||
}
|
||||
if (providerCreationCount === 1) {
|
||||
return {
|
||||
tools: {},
|
||||
languageModel() {
|
||||
return { id: "fake-model" };
|
||||
},
|
||||
async initSession() {
|
||||
throw new Error("Resource not found: session not found");
|
||||
},
|
||||
getSessionId() {
|
||||
return "stale-session";
|
||||
},
|
||||
cleanup() {},
|
||||
};
|
||||
}
|
||||
return fallbackProvider;
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
stepCountIs: () => Symbol("stopWhen"),
|
||||
streamText(args) {
|
||||
const { messages } = args;
|
||||
streamCalls.push(messages);
|
||||
if (typeof options.streamText === "function") {
|
||||
return options.streamText({ ...args, streamCalls });
|
||||
}
|
||||
if (streamCalls.length === 1) {
|
||||
throw new Error("transport failed before replayed turn completed");
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const bridgePath = require.resolve("./aiBridge.cjs");
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (Object.prototype.hasOwnProperty.call(mocks, request)) {
|
||||
return mocks[request];
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
delete require.cache[bridgePath];
|
||||
|
||||
try {
|
||||
const bridge = require("./aiBridge.cjs");
|
||||
return {
|
||||
bridge,
|
||||
streamCalls,
|
||||
safeSendCalls,
|
||||
providerCreationArgs,
|
||||
restore() {
|
||||
try {
|
||||
bridge.cleanup();
|
||||
} finally {
|
||||
delete require.cache[bridgePath];
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
delete require.cache[bridgePath];
|
||||
Module._load = originalLoad;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
|
||||
const ipcMain = createIpcMainStub();
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
console.error = (...args) => {
|
||||
const message = args.map((part) => String(part ?? "")).join(" ");
|
||||
if (message.includes("transport failed before replayed turn completed")) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
};
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-1",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first recovered turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-2",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "retry after transport failure",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.equal(streamCalls.length, 2);
|
||||
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
|
||||
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
|
||||
assert.equal(providerCreationArgs.length, 3);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[0], true);
|
||||
assert.equal(providerCreationArgs[0].existingSessionId, "stale-session");
|
||||
assert.equal("existingSessionId" in providerCreationArgs[1], false);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[2], false);
|
||||
});
|
||||
|
||||
test("clears replay fallback after a user-cancelled recovered turn so the fresh ACP session is preserved", async () => {
|
||||
// Regression: if the user stops the first turn after stale-session
|
||||
// recovery, historyReplayFallback must still be cleared. Otherwise the
|
||||
// next turn triggers shouldResetProviderForHistoryReplay, which discards
|
||||
// the freshly recovered ACP session (resumeSessionId is forced to
|
||||
// undefined in that path) and re-spends tokens on another compact
|
||||
// replay. That would break the cancel-preserves-session contract.
|
||||
|
||||
// Gate that the test releases AFTER cancel has been dispatched, so the
|
||||
// bridge's reader loop wakes up to find signal.aborted=true.
|
||||
let releaseRead;
|
||||
const readReleased = new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// First call (the recovered turn) — block in read() so the test can
|
||||
// fire cancel before any chunk arrives, simulating "user clicks Stop
|
||||
// before the agent emits content". Second call (follow-up) — return
|
||||
// an immediately-done empty stream.
|
||||
if (callsRef.length === 1) {
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
await readReleased;
|
||||
// After cancel, signal.aborted is true; return done so the
|
||||
// loop exits cleanly. Never produced a content chunk →
|
||||
// hasContent stays false, aborted is true → we hit the
|
||||
// else-branch where the fix lives.
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const cancelHandler = ipcMain.handlers.get("netcatty:ai:acp:cancel");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
assert.equal(typeof cancelHandler, "function");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// Kick off the first turn; it will block at reader.read().
|
||||
const firstTurn = streamHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-cancel",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first recovered turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Yield enough microtasks so the handler reaches the streamText/read
|
||||
// path before we cancel.
|
||||
for (let i = 0; i < 10; i += 1) await Promise.resolve();
|
||||
|
||||
// Fire cancel — this calls controller.abort() inside the bridge.
|
||||
await cancelHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-cancel",
|
||||
});
|
||||
|
||||
// Now release the blocked read so the loop wakes, sees aborted, and
|
||||
// exits. The else-branch should clear historyReplayFallback.
|
||||
releaseRead();
|
||||
await firstTurn;
|
||||
|
||||
// Second turn — should reuse the recovered fresh-session and send
|
||||
// only the latest prompt (no compact replay).
|
||||
await streamHandler(event, {
|
||||
requestId: "req-cancel-2",
|
||||
chatSessionId: "chat-cancel",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "follow-up after cancel",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// Two streamText calls: the cancelled one + the follow-up.
|
||||
assert.equal(streamCalls.length, 2);
|
||||
|
||||
// Provider creation count: 1 stale attempt + 1 fallback recovery = 2.
|
||||
// If the bug regresses, the follow-up turn would force a 3rd creation
|
||||
// (shouldResetProviderForHistoryReplay → cleanupAcpProvider → recreate
|
||||
// without existingSessionId).
|
||||
assert.equal(
|
||||
providerCreationArgs.length,
|
||||
2,
|
||||
"expected the recovered fresh session to be preserved across user cancel",
|
||||
);
|
||||
|
||||
// Follow-up turn should send only the latest prompt — the recovered
|
||||
// session has the prior context; replaying compact history again would
|
||||
// waste tokens and visually feel like the conversation forgot itself.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
1,
|
||||
"follow-up after cancel must not re-replay compact history",
|
||||
);
|
||||
});
|
||||
|
||||
test("replays compact history on the first turn after app restart even when session/load 'succeeds'", async () => {
|
||||
// Regression for #753: after an app restart, the renderer still has
|
||||
// the prior chat's externalSessionId and full message history in
|
||||
// storage, and passes both to the bridge on the next send. The
|
||||
// externalSessionId becomes existingSessionId → resumeSessionId in
|
||||
// the bridge, and createACPProvider spawns a fresh agent process
|
||||
// with that id.
|
||||
//
|
||||
// Problem: some ACP agents (Copilot CLI, some Codex builds) don't
|
||||
// error on session/load when the id is stale — they silently start
|
||||
// a new session. The catch-block fallback never fires, so
|
||||
// historyReplayFallback stays false and the stream sends only the
|
||||
// latest prompt. The agent says "no previous records" even though
|
||||
// the UI shows the prior conversation.
|
||||
//
|
||||
// Fix: when we're spawning a new provider AND telling it to resume
|
||||
// an existing session id AND we have compact history to replay,
|
||||
// preload historyReplayFallback=true. The first turn includes the
|
||||
// replay; after it streams real content the flag clears so steady-
|
||||
// state cost stays at just the latest prompt.
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
createACPProvider({ fallbackProvider }) {
|
||||
// Pretend session/load succeeded silently — no error thrown, but
|
||||
// also no real context. This models Copilot CLI's behavior.
|
||||
return fallbackProvider;
|
||||
},
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// Return content so the post-stream hook clears the flag after.
|
||||
if (callsRef.length === 1) {
|
||||
const chunks = [{ type: "text-delta", text: "ok" }];
|
||||
let i = 0;
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
if (i < chunks.length) return { done: false, value: chunks[i++] };
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const historyMessages = [{ role: "user", content: "prior constraint: 不要提交" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// First turn after app restart. existingSessionId is set (renderer
|
||||
// persisted it), historyMessages is non-empty.
|
||||
await streamHandler(event, {
|
||||
requestId: "req-restart-1",
|
||||
chatSessionId: "chat-restart",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "what did we discuss?",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stored-session-from-storage",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Second turn — should send only the latest prompt now.
|
||||
await streamHandler(event, {
|
||||
requestId: "req-restart-2",
|
||||
chatSessionId: "chat-restart",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "and now continue",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stored-session-from-storage",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// Single provider creation — session/load "succeeded" so no fallback.
|
||||
assert.equal(providerCreationArgs.length, 1);
|
||||
assert.equal(providerCreationArgs[0].existingSessionId, "stored-session-from-storage");
|
||||
|
||||
// First turn MUST include the compact history + latest prompt.
|
||||
// Regression target: pre-fix, streamCalls[0] had length 1 (latest only).
|
||||
assert.equal(
|
||||
streamCalls[0].length,
|
||||
2,
|
||||
"first turn after app restart must preload compact history as a hedge",
|
||||
);
|
||||
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
|
||||
|
||||
// Second turn uses steady-state behavior (latest only). This confirms
|
||||
// the flag clears after one successful streamed turn and the hedge
|
||||
// doesn't keep replaying forever.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
1,
|
||||
"steady-state turns must not keep replaying history",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves recovered ACP session when user cancels then immediately sends the next prompt", async () => {
|
||||
// Regression: after a user-cancel of a recovered turn, the existingRun
|
||||
// path in the next stream handler used to call cleanupAcpProvider
|
||||
// unconditionally — destroying the fresh ACP session the cancel IPC
|
||||
// had just promised to preserve. Combined with historyReplayFallback
|
||||
// still being true at that moment, the follow-up turn then recreated
|
||||
// a bare new provider via shouldResetProviderForHistoryReplay and
|
||||
// the user lost all recovered conversation context.
|
||||
//
|
||||
// With the fix: (a) the cancel IPC synchronously clears the replay
|
||||
// flag on the preserved provider, and (b) the existingRun path skips
|
||||
// cleanupAcpProvider when the prior run was already cancelled via
|
||||
// the cancel IPC. The next stream then reuses the recovered session
|
||||
// and sends only the latest prompt.
|
||||
|
||||
let releaseRead;
|
||||
const readReleased = new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// Turn 1: block in read() so the test can fire cancel, then
|
||||
// immediately fire the next stream request while the aborted
|
||||
// stream is still unwinding.
|
||||
if (callsRef.length === 1) {
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
await readReleased;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const cancelHandler = ipcMain.handlers.get("netcatty:ai:acp:cancel");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// Turn 1 starts and blocks in read().
|
||||
const firstTurn = streamHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-race",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Yield so the handler reaches the streamText/read phase.
|
||||
for (let i = 0; i < 10; i += 1) await Promise.resolve();
|
||||
|
||||
// User clicks Stop.
|
||||
await cancelHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-race",
|
||||
});
|
||||
|
||||
// User immediately sends the next prompt BEFORE releasing the read
|
||||
// — i.e. before the first stream handler's post-stream code can
|
||||
// run. This is the exact timing window codex flagged.
|
||||
const secondTurn = streamHandler(event, {
|
||||
requestId: "req-cancel-2",
|
||||
chatSessionId: "chat-race",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "immediate follow-up",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Let the first turn unwind now.
|
||||
releaseRead();
|
||||
await firstTurn;
|
||||
await secondTurn;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// 2 provider creations: the stale attempt + fallback recovery.
|
||||
// If the regression is back, there would be a 3rd creation (the
|
||||
// existingRun cleanup + reset-for-replay path discarding the
|
||||
// recovered session).
|
||||
assert.equal(
|
||||
providerCreationArgs.length,
|
||||
2,
|
||||
"expected recovered fresh session to be preserved across cancel+immediate-send",
|
||||
);
|
||||
|
||||
// Second turn must NOT re-replay compact history — the preserved
|
||||
// session already has that context.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
1,
|
||||
"follow-up after cancel must not re-replay compact history",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves history-replay across provider recreation caused by permission-mode / MCP / auth change", async () => {
|
||||
// Regression: after a stale-session recovery left historyReplayFallback=true
|
||||
// (e.g. the recovered turn returned empty), an orthogonal change that
|
||||
// flips shouldReuseProvider to false (permission mode, MCP scope, auth
|
||||
// fingerprint) used to recreate the provider with historyReplayFallback:
|
||||
// false. The next turn then sent only the latest prompt and dropped the
|
||||
// recovered conversation context. We now preserve the flag on any
|
||||
// recreation where a history-replay is still pending.
|
||||
|
||||
// Use permission mode as the orthogonal change — auth fingerprint would
|
||||
// drag in Codex-specific auth validation we can't stub cleanly.
|
||||
let permissionMode = "default";
|
||||
function createStreamResult(chunks) {
|
||||
let idx = 0;
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
if (idx < chunks.length) {
|
||||
return { done: false, value: chunks[idx++] };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
getPermissionMode: () => permissionMode,
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// Turn 1: empty stream — the recovered turn returned no content, so
|
||||
// the empty-non-aborted branch keeps historyReplayFallback=true.
|
||||
if (callsRef.length === 1) return createEmptyStreamResult();
|
||||
// Turn 2: content streams — confirms the replay actually reached
|
||||
// the recreated provider.
|
||||
return createStreamResult([{ type: "text-delta", text: "ok" }]);
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// Turn 1: stale-session recovery + empty response (flag stays set).
|
||||
await streamHandler(event, {
|
||||
requestId: "req-1",
|
||||
chatSessionId: "chat-preserve",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Simulate the user toggling the MCP permission mode between turns.
|
||||
// This flips shouldReuseProvider to false and forces recreation via
|
||||
// the non-reset branch — exactly where the preserve-flag gap lived.
|
||||
permissionMode = "auto";
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-2",
|
||||
chatSessionId: "chat-preserve",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "second turn after permission change",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.equal(streamCalls.length, 2);
|
||||
// Turn 2 must include history + latest; regression would make it just 1.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
2,
|
||||
"second turn must re-replay compact history onto the recreated provider",
|
||||
);
|
||||
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
|
||||
|
||||
// 3 provider creations: stale attempt + first fallback + permission-change recreation.
|
||||
assert.equal(providerCreationArgs.length, 3);
|
||||
});
|
||||
|
||||
test("keeps replay fallback enabled after an empty recovered turn by retrying in a fresh ACP session", async () => {
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
streamText() {
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
await streamHandler(event, {
|
||||
requestId: "req-1",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first recovered turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-2",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "retry after empty response",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.equal(streamCalls.length, 2);
|
||||
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
|
||||
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
|
||||
assert.equal(providerCreationArgs.length, 3);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[0], true);
|
||||
assert.equal(providerCreationArgs[0].existingSessionId, "stale-session");
|
||||
assert.equal("existingSessionId" in providerCreationArgs[1], false);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[2], false);
|
||||
});
|
||||
@@ -6,27 +6,78 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { exec } = require("node:child_process");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows using the attrib command
|
||||
* Returns true if the file has the hidden attribute set
|
||||
* Uses async exec to avoid blocking the main process
|
||||
* Parse the output of `attrib.exe <dir>\*` into a set of basenames whose
|
||||
* `H` (hidden) flag is set. Exposed separately so the parser can be
|
||||
* unit-tested without spawning a real subprocess.
|
||||
*
|
||||
* Example attrib output (one entry per line):
|
||||
* A C:\path\file1.txt
|
||||
* H C:\path\file2.txt
|
||||
* A H R C:\path\file3.txt
|
||||
* H C:\path\hidden_dir [DIR]
|
||||
*/
|
||||
async function isWindowsHiddenFile(filePath) {
|
||||
if (process.platform !== "win32") return false;
|
||||
function parseAttribOutput(stdout) {
|
||||
const hidden = new Set();
|
||||
for (const line of String(stdout).split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
// Flags occupy the leading columns. Locate the path by the first
|
||||
// drive letter ("C:\") or UNC prefix ("\\server\share"). The `\\\\`
|
||||
// alternative has no leading anchor because attrib output has the
|
||||
// path inside the line, not at column 0 (leading whitespace holds
|
||||
// the attribute flags).
|
||||
const pathStart = line.search(/[A-Za-z]:[\\/]|\\\\/);
|
||||
if (pathStart < 0) continue;
|
||||
const attrPart = line.substring(0, pathStart).toUpperCase();
|
||||
if (!attrPart.includes("H")) continue;
|
||||
const fullPath = line.substring(pathStart).trim();
|
||||
// Some Windows versions append a trailing literal "[DIR]" marker
|
||||
// when attrib is invoked with /d. Strip only that exact marker —
|
||||
// not any arbitrary bracketed suffix — so legitimate filenames
|
||||
// ending in brackets ("Notes [old]", "Draft [v2].md") survive
|
||||
// intact and still get matched by hiddenSet.has(entry.name).
|
||||
const cleaned = fullPath.replace(/\s+\[DIR\]\s*$/, "");
|
||||
// Always use the win32 basename here — attrib output uses backslash
|
||||
// separators, and the parser must work under CI on non-Windows hosts.
|
||||
const basename = path.win32.basename(cleaned);
|
||||
if (basename) hidden.add(basename);
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-list hidden filenames in a Windows directory.
|
||||
*
|
||||
* Previously we called `attrib` once per entry inside the concurrency
|
||||
* worker loop. On a directory with ~800 files, that spawns ~800 subprocesses
|
||||
* and takes ~30 s (see #766). One subprocess call with a wildcard returns
|
||||
* the hidden attribute for every entry at once, so we replace the per-file
|
||||
* check with a single upfront pass and a Set lookup in the worker.
|
||||
*
|
||||
* Returns the set of hidden basenames (empty on non-Windows or on failure).
|
||||
*/
|
||||
async function listWindowsHiddenBasenames(dirPath) {
|
||||
if (process.platform !== "win32") return new Set();
|
||||
try {
|
||||
const { stdout } = await execAsync(`attrib "${filePath}"`);
|
||||
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
|
||||
// The attributes appear in the first ~10 characters before the path
|
||||
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
|
||||
return attrPart.includes("H");
|
||||
const pattern = path.join(dirPath, "*");
|
||||
// `/d` is required so attrib.exe also reports directory entries —
|
||||
// without it the wildcard is file-centric and hidden folders would
|
||||
// be silently omitted from the set, causing the SFTP browser to
|
||||
// show them as not-hidden (a regression from the per-file path
|
||||
// that passed each entry's full path directly).
|
||||
const { stdout } = await execFileAsync("attrib.exe", [pattern, "/d"], {
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
return parseAttribOutput(stdout);
|
||||
} catch (err) {
|
||||
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
|
||||
return false;
|
||||
console.warn(`[localFsBridge] Batch attrib failed for ${dirPath}:`, err.message);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +88,17 @@ async function isWindowsHiddenFile(filePath) {
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Read directory entries and the Windows hidden-attribute set in
|
||||
// parallel. The hidden lookup is a single subprocess that covers every
|
||||
// entry in the directory; per-file attrib calls were the ~30 s hotspot
|
||||
// that #766 reported on an 800-file directory.
|
||||
const [entries, hiddenSet] = await Promise.all([
|
||||
fs.promises.readdir(dirPath, { withFileTypes: true }),
|
||||
isWindows ? listWindowsHiddenBasenames(dirPath) : Promise.resolve(new Set()),
|
||||
]);
|
||||
|
||||
// Stat entries in parallel with a small concurrency limit.
|
||||
// Serial stats can be very slow on Windows for large dirs.
|
||||
const CONCURRENCY = 32;
|
||||
@@ -70,8 +129,8 @@ async function listLocalDir(event, payload) {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
// Windows hidden attribute: resolved from the batched lookup.
|
||||
const hidden = isWindows ? hiddenSet.has(entry.name) : false;
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
@@ -90,7 +149,7 @@ async function listLocalDir(event, payload) {
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
const hidden = isWindows ? hiddenSet.has(brokenEntry.name) : false;
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
@@ -269,4 +328,6 @@ module.exports = {
|
||||
getHomeDir,
|
||||
getSystemInfo,
|
||||
readKnownHosts,
|
||||
parseAttribOutput,
|
||||
listWindowsHiddenBasenames,
|
||||
};
|
||||
|
||||
139
electron/bridges/localFsBridge.test.cjs
Normal file
@@ -0,0 +1,139 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { parseAttribOutput, listWindowsHiddenBasenames } = require("./localFsBridge.cjs");
|
||||
|
||||
test("parseAttribOutput returns an empty set for empty input", () => {
|
||||
assert.equal(parseAttribOutput("").size, 0);
|
||||
assert.equal(parseAttribOutput("\r\n\r\n").size, 0);
|
||||
});
|
||||
|
||||
test("parseAttribOutput captures basenames of files with the H flag", () => {
|
||||
const stdout = [
|
||||
"A C:\\Users\\foo\\public.txt",
|
||||
" H C:\\Users\\foo\\.secret",
|
||||
"A H R C:\\Users\\foo\\hidden-readonly.exe",
|
||||
"A C:\\Users\\foo\\another.log",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual(
|
||||
[...hidden].sort(),
|
||||
[".secret", "hidden-readonly.exe"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseAttribOutput ignores the trailing [DIR] marker on some Windows versions", () => {
|
||||
const stdout = [
|
||||
" H C:\\data\\node_modules [DIR]",
|
||||
" H C:\\data\\.git [DIR]",
|
||||
"A C:\\data\\README.md",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual([...hidden].sort(), [".git", "node_modules"].sort());
|
||||
});
|
||||
|
||||
test("parseAttribOutput preserves filenames that legitimately end with bracketed suffixes", () => {
|
||||
// Regression: a prior version stripped ANY trailing bracketed suffix
|
||||
// via /\s+\[[^\]]+\]\s*$/, truncating "Notes [old]" to "Notes".
|
||||
// Only the literal [DIR] marker that attrib emits with /d is a parser
|
||||
// artifact; user-facing filenames with brackets must survive intact so
|
||||
// hiddenSet.has(entry.name) still matches the actual readdir entry.
|
||||
const stdout = [
|
||||
" H C:\\data\\Notes [old]",
|
||||
" H C:\\data\\Draft [v2].md",
|
||||
" H C:\\data\\archived [2024]",
|
||||
" H C:\\data\\node_modules [DIR]",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual(
|
||||
[...hidden].sort(),
|
||||
["Draft [v2].md", "Notes [old]", "archived [2024]", "node_modules"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseAttribOutput handles UNC paths", () => {
|
||||
const stdout = [
|
||||
" H \\\\fileserver\\share\\secret.cfg",
|
||||
"A \\\\fileserver\\share\\public.cfg",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual([...hidden], ["secret.cfg"]);
|
||||
});
|
||||
|
||||
test("parseAttribOutput skips malformed lines", () => {
|
||||
const stdout = [
|
||||
"Parameter format not correct",
|
||||
"",
|
||||
" H C:\\good\\hidden.txt",
|
||||
"File not found",
|
||||
" H not-a-windows-path.txt",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual([...hidden], ["hidden.txt"]);
|
||||
});
|
||||
|
||||
test("listWindowsHiddenBasenames returns an empty set on non-Windows without spawning anything", async () => {
|
||||
// Running this test file is only meaningful on a non-Windows host for this
|
||||
// assertion. On Windows CI we skip the subprocess-free guarantee.
|
||||
if (process.platform === "win32") return;
|
||||
const result = await listWindowsHiddenBasenames("/tmp");
|
||||
assert.ok(result instanceof Set);
|
||||
assert.equal(result.size, 0);
|
||||
});
|
||||
|
||||
test("listWindowsHiddenBasenames invokes attrib.exe with /d so hidden directories aren't omitted", async () => {
|
||||
// Regression: without `/d`, `attrib <dir>\*` treats the wildcard as
|
||||
// file-centric and hidden directories (node_modules, .git, …) never
|
||||
// reach parseAttribOutput — the SFTP browser then shows them as
|
||||
// not-hidden, a behavior regression from the per-file implementation.
|
||||
const Module = require("node:module");
|
||||
const realChildProcess = require("node:child_process");
|
||||
const originalLoad = Module._load;
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
let capturedArgs = null;
|
||||
let capturedExecutable = null;
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "node:child_process") {
|
||||
return {
|
||||
...realChildProcess,
|
||||
execFile: (executable, args, _options, cb) => {
|
||||
capturedExecutable = executable;
|
||||
capturedArgs = args;
|
||||
cb(null, { stdout: "", stderr: "" });
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const bridgePath = require.resolve("./localFsBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
|
||||
try {
|
||||
const { listWindowsHiddenBasenames: fn } = require("./localFsBridge.cjs");
|
||||
await fn("C:\\fixture");
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
delete require.cache[bridgePath];
|
||||
}
|
||||
|
||||
assert.equal(capturedExecutable, "attrib.exe");
|
||||
assert.ok(
|
||||
Array.isArray(capturedArgs) && capturedArgs.includes("/d"),
|
||||
`expected /d in attrib args so hidden directories are included, got ${JSON.stringify(capturedArgs)}`,
|
||||
);
|
||||
});
|
||||
253
electron/bridges/mainProcessErrorGuards.test.cjs
Normal file
@@ -0,0 +1,253 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
classifyProcessError,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
isNonFatalNetworkError,
|
||||
} = require("./processErrorGuards.cjs");
|
||||
|
||||
test("treats Chromium ERR_NETWORK_CHANGED as non-fatal", () => {
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("net::ERR_NETWORK_CHANGED")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("treats other Chromium net::ERR_* failures as non-fatal network errors", () => {
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("net::ERR_INTERNET_DISCONNECTED")),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("net::ERR_NAME_NOT_RESOLVED")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("treats Node socket error codes as non-fatal network errors", () => {
|
||||
const err = new Error("socket reset");
|
||||
err.code = "ECONNRESET";
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
|
||||
const dnsErr = new Error("dns failed");
|
||||
dnsErr.code = "ENOTFOUND";
|
||||
assert.equal(isNonFatalNetworkError(dnsErr), true);
|
||||
});
|
||||
|
||||
test("keeps non-network errors fatal", () => {
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("Something else broke")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("generic startup exceptions stay fatal before the app is up", () => {
|
||||
const result = classifyProcessError(new Error("boom"), {
|
||||
runtimeStarted: false,
|
||||
});
|
||||
|
||||
assert.equal(result.action, "fatal");
|
||||
});
|
||||
|
||||
test("generic runtime exceptions are suppressed after startup", () => {
|
||||
const result = classifyProcessError(new Error("boom"), {
|
||||
runtimeStarted: true,
|
||||
});
|
||||
|
||||
assert.equal(result.action, "suppress");
|
||||
assert.match(result.reason, /runtime/i);
|
||||
});
|
||||
|
||||
test("generic runtime promise rejections are also suppressed after startup", () => {
|
||||
const result = classifyProcessError(new Error("promise boom"), {
|
||||
runtimeStarted: true,
|
||||
origin: "unhandledRejection",
|
||||
});
|
||||
|
||||
assert.equal(result.action, "suppress");
|
||||
assert.match(result.reason, /runtime/i);
|
||||
});
|
||||
|
||||
test("controller keeps startup strict until the main window is actually shown", () => {
|
||||
const controller = createProcessErrorController();
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
assert.equal(controller.isRuntimeProtectionActive(), false);
|
||||
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
assert.equal(controller.isRuntimeProtectionActive(), true);
|
||||
});
|
||||
|
||||
test("controller becomes strict again while recreating a missing main window", () => {
|
||||
const controller = createProcessErrorController();
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
assert.equal(controller.isRuntimeProtectionActive(), true);
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
assert.equal(controller.isRuntimeProtectionActive(), false);
|
||||
|
||||
controller.completeMainWindowStartup({ windowShown: false });
|
||||
assert.equal(controller.isRuntimeProtectionActive(), true);
|
||||
});
|
||||
|
||||
test("startup-period errors stay fatal while recreating the main window", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
const fatals = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError() {},
|
||||
onFatalError(err) {
|
||||
fatals.push(err.message);
|
||||
throw err;
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
installProcessErrorHandlers(fakeProcess, controller);
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
controller.beginMainWindowStartup();
|
||||
|
||||
assert.throws(() => {
|
||||
fakeProcess.emit("uncaughtException", new Error("recreate boom"));
|
||||
}, /recreate boom/);
|
||||
assert.deepEqual(fatals, ["recreate boom"]);
|
||||
});
|
||||
|
||||
test("fatal startup failures uninstall listeners and keep throwing", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
const captured = [];
|
||||
const fatals = [];
|
||||
let uninstall = null;
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
fatals.push(err.message);
|
||||
uninstall?.();
|
||||
throw err;
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
uninstall = installProcessErrorHandlers(fakeProcess, controller);
|
||||
|
||||
assert.throws(() => {
|
||||
fakeProcess.emit("uncaughtException", new Error("startup boom"));
|
||||
}, /startup boom/);
|
||||
assert.deepEqual(fatals, ["startup boom"]);
|
||||
assert.deepEqual(captured, [["uncaughtException", "startup boom"]]);
|
||||
assert.equal(fakeProcess.listenerCount("uncaughtException"), 0);
|
||||
assert.equal(fakeProcess.listenerCount("unhandledRejection"), 0);
|
||||
});
|
||||
|
||||
test("installed handlers suppress runtime failures after startup", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
const captured = [];
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
throw err;
|
||||
},
|
||||
logError(...args) {
|
||||
errors.push(args.map(String).join(" "));
|
||||
},
|
||||
logWarn(...args) {
|
||||
warnings.push(args.map(String).join(" "));
|
||||
},
|
||||
});
|
||||
|
||||
installProcessErrorHandlers(fakeProcess, controller);
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
|
||||
fakeProcess.emit("uncaughtException", new Error("runtime boom"));
|
||||
fakeProcess.emit("unhandledRejection", new Error("runtime rejection"));
|
||||
assert.deepEqual(captured, [
|
||||
["uncaughtException", "runtime boom"],
|
||||
["unhandledRejection", "runtime rejection"],
|
||||
]);
|
||||
assert.equal(errors.some((line) => line.includes("runtime error after startup")), true);
|
||||
assert.equal(warnings.length, 0);
|
||||
});
|
||||
|
||||
test("unhandled rejection marks the forwarded error so uncaught follow-up is not double-captured", () => {
|
||||
const captured = [];
|
||||
const fatals = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
fatals.push(err);
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
controller.handleUnhandledRejection(new Error("startup rejection"));
|
||||
assert.equal(fatals.length, 1);
|
||||
assert.equal(fatals[0].__fromUnhandledRejection, true);
|
||||
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
|
||||
|
||||
controller.handleUncaughtException(fatals[0]);
|
||||
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
|
||||
});
|
||||
|
||||
test("benign stream teardown errors are ignored by the installed handlers", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
let captureCount = 0;
|
||||
let fatalCount = 0;
|
||||
const controller = createProcessErrorController({
|
||||
captureError() {
|
||||
captureCount += 1;
|
||||
},
|
||||
onFatalError() {
|
||||
fatalCount += 1;
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
installProcessErrorHandlers(fakeProcess, controller);
|
||||
const err = new Error("broken pipe");
|
||||
err.code = "EPIPE";
|
||||
fakeProcess.emit("uncaughtException", err);
|
||||
|
||||
assert.equal(captureCount, 0);
|
||||
assert.equal(fatalCount, 0);
|
||||
});
|
||||
|
||||
test("controller suppresses wrapped network errors from err.cause", () => {
|
||||
const err = new Error("request failed");
|
||||
err.cause = new Error("net::ERR_NETWORK_CHANGED");
|
||||
|
||||
const result = classifyProcessError(err, {
|
||||
runtimeStarted: false,
|
||||
});
|
||||
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
assert.equal(result.action, "suppress");
|
||||
});
|
||||
|
||||
test("controller suppresses ssh-style errors with a level property", () => {
|
||||
const err = new Error("connection lost before handshake");
|
||||
err.level = "client-socket";
|
||||
|
||||
const result = classifyProcessError(err, {
|
||||
runtimeStarted: false,
|
||||
});
|
||||
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
assert.equal(result.action, "suppress");
|
||||
});
|
||||
193
electron/bridges/processErrorGuards.cjs
Normal file
@@ -0,0 +1,193 @@
|
||||
function isNonFatalNetworkError(err) {
|
||||
if (!err) return false;
|
||||
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||||
// never a reason to kill the entire multi-session app.
|
||||
if (err.level) return true;
|
||||
|
||||
const candidates = [err, err.cause].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
const code = candidate.code;
|
||||
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||||
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||||
switch (code) {
|
||||
case "ECONNRESET":
|
||||
case "ECONNREFUSED":
|
||||
case "ECONNABORTED":
|
||||
case "ETIMEDOUT":
|
||||
case "ENOTFOUND":
|
||||
case "EHOSTUNREACH":
|
||||
case "EHOSTDOWN":
|
||||
case "ENETUNREACH":
|
||||
case "ENETDOWN":
|
||||
case "EADDRNOTAVAIL":
|
||||
case "EPROTO":
|
||||
case "EPERM":
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Chromium/Electron networking often rejects with a message like
|
||||
// "net::ERR_NETWORK_CHANGED" but without a useful `code` property.
|
||||
// These are transport failures for background fetch/update/sync work,
|
||||
// not reasons to kill the whole app.
|
||||
const message = String(candidate.message || "");
|
||||
if (/net::ERR_(?:NETWORK_[A-Z_]+|INTERNET_DISCONNECTED|NAME_NOT_RESOLVED|CONNECTION_[A-Z_]+|ADDRESS_[A-Z_]+|SSL_[A-Z_]+|CERT_[A-Z_]+|PROXY_[A-Z_]+|TUNNEL_[A-Z_]+|SOCKS_[A-Z_]+)/.test(message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBenignStreamError(err) {
|
||||
const code = err?.code;
|
||||
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
|
||||
}
|
||||
|
||||
function classifyProcessError(err, options = {}) {
|
||||
const runtimeStarted = options.runtimeStarted === true;
|
||||
|
||||
if (isBenignStreamError(err)) {
|
||||
return {
|
||||
action: "ignore",
|
||||
reason: "benign stream teardown",
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonFatalNetworkError(err)) {
|
||||
return {
|
||||
action: "suppress",
|
||||
reason: "non-fatal network error",
|
||||
};
|
||||
}
|
||||
|
||||
if (runtimeStarted) {
|
||||
return {
|
||||
action: "suppress",
|
||||
reason: "runtime error after startup",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "fatal",
|
||||
reason: "startup error before app became usable",
|
||||
};
|
||||
}
|
||||
|
||||
function createProcessErrorController(options = {}) {
|
||||
const captureError = typeof options.captureError === "function" ? options.captureError : () => {};
|
||||
const onFatalError = typeof options.onFatalError === "function"
|
||||
? options.onFatalError
|
||||
: (err) => { throw err; };
|
||||
const logError = typeof options.logError === "function" ? options.logError : (...args) => console.error(...args);
|
||||
const logWarn = typeof options.logWarn === "function" ? options.logWarn : (...args) => console.warn(...args);
|
||||
|
||||
let hasShownMainWindow = false;
|
||||
let pendingMainWindowStartupCount = 0;
|
||||
|
||||
const isRuntimeProtectionActive = () => (
|
||||
hasShownMainWindow && pendingMainWindowStartupCount === 0
|
||||
);
|
||||
|
||||
const beginMainWindowStartup = () => {
|
||||
pendingMainWindowStartupCount += 1;
|
||||
};
|
||||
|
||||
const completeMainWindowStartup = ({ windowShown = false } = {}) => {
|
||||
if (pendingMainWindowStartupCount > 0) {
|
||||
pendingMainWindowStartupCount -= 1;
|
||||
}
|
||||
if (windowShown) {
|
||||
hasShownMainWindow = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUncaughtException = (err) => {
|
||||
const decision = classifyProcessError(err, {
|
||||
runtimeStarted: isRuntimeProtectionActive(),
|
||||
origin: "uncaughtException",
|
||||
});
|
||||
|
||||
if (decision.action === "ignore") {
|
||||
logWarn("Ignored process error:", decision.reason, err?.code || err?.message || err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.action === "suppress") {
|
||||
if (!err?.__fromUnhandledRejection) {
|
||||
captureError("uncaughtException", err);
|
||||
}
|
||||
logError(`Suppressed uncaught exception (${decision.reason}):`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!err?.__fromUnhandledRejection) {
|
||||
captureError("uncaughtException", err);
|
||||
}
|
||||
onFatalError(err, {
|
||||
origin: "uncaughtException",
|
||||
decision,
|
||||
reason: err,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (reason) => {
|
||||
const decision = classifyProcessError(reason, {
|
||||
runtimeStarted: isRuntimeProtectionActive(),
|
||||
origin: "unhandledRejection",
|
||||
});
|
||||
|
||||
if (decision.action === "ignore") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.action === "suppress") {
|
||||
captureError("unhandledRejection", reason);
|
||||
logError(`Suppressed unhandled rejection (${decision.reason}):`, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
captureError("unhandledRejection", reason);
|
||||
const err = reason instanceof Error ? reason : new Error(String(reason));
|
||||
err.__fromUnhandledRejection = true;
|
||||
onFatalError(err, {
|
||||
origin: "unhandledRejection",
|
||||
decision,
|
||||
reason,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
beginMainWindowStartup,
|
||||
completeMainWindowStartup,
|
||||
handleUncaughtException,
|
||||
handleUnhandledRejection,
|
||||
isRuntimeProtectionActive,
|
||||
};
|
||||
}
|
||||
|
||||
function installProcessErrorHandlers(processObject, controller) {
|
||||
if (!processObject?.on || !processObject?.removeListener) {
|
||||
throw new Error("A process-like EventEmitter is required");
|
||||
}
|
||||
if (!controller?.handleUncaughtException || !controller?.handleUnhandledRejection) {
|
||||
throw new Error("A process error controller is required");
|
||||
}
|
||||
|
||||
processObject.on("uncaughtException", controller.handleUncaughtException);
|
||||
processObject.on("unhandledRejection", controller.handleUnhandledRejection);
|
||||
|
||||
return () => {
|
||||
processObject.removeListener("uncaughtException", controller.handleUncaughtException);
|
||||
processObject.removeListener("unhandledRejection", controller.handleUnhandledRejection);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyProcessError,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
isBenignStreamError,
|
||||
isNonFatalNetworkError,
|
||||
};
|
||||
@@ -36,6 +36,9 @@ let menuDeps = null;
|
||||
let electronApp = null; // Reference to Electron app for userData path
|
||||
let isQuitting = false;
|
||||
const rendererReadyCallbacksByWebContentsId = new Map();
|
||||
const rendererReadySeenByWebContentsId = new Set();
|
||||
const rendererReadyWaitersByWebContentsId = new Map();
|
||||
const unhealthyWebContentsIds = new Set();
|
||||
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
|
||||
const OAUTH_DEFAULT_WIDTH = 600;
|
||||
const OAUTH_DEFAULT_HEIGHT = 700;
|
||||
@@ -791,6 +794,128 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
|
||||
return { showOnce, markRendererReady };
|
||||
}
|
||||
|
||||
function resolveRendererReady(wcId) {
|
||||
if (!wcId) return;
|
||||
unhealthyWebContentsIds.delete(wcId);
|
||||
rendererReadySeenByWebContentsId.add(wcId);
|
||||
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
|
||||
if (cb) cb();
|
||||
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
|
||||
if (!waiters || waiters.size === 0) return;
|
||||
rendererReadyWaitersByWebContentsId.delete(wcId);
|
||||
for (const resolve of waiters) {
|
||||
try {
|
||||
resolve();
|
||||
} catch {
|
||||
// ignore waiter errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowUsable(win, options = {}) {
|
||||
const requireVisible = options.requireVisible === true;
|
||||
if (!win || typeof win.isDestroyed !== "function" || win.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
if (requireVisible) {
|
||||
if (typeof win.isVisible !== "function") return false;
|
||||
try {
|
||||
if (!win.isVisible()) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const contents = win.webContents;
|
||||
if (!contents || typeof contents.isDestroyed !== "function" || contents.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
const wcId = (() => {
|
||||
try {
|
||||
return contents.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (wcId && unhealthyWebContentsIds.has(wcId)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof contents.isCrashed === "function") {
|
||||
try {
|
||||
if (contents.isCrashed()) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function waitForRendererReady(win, { timeoutMs = 15000 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wcId = (() => {
|
||||
try {
|
||||
return win?.webContents?.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!win || win.isDestroyed?.() || !wcId) {
|
||||
reject(new Error("Main window is unavailable before renderer ready."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rendererReadySeenByWebContentsId.has(wcId)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
try { win.removeListener("closed", handleClosed); } catch {}
|
||||
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
|
||||
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
|
||||
if (waiters) {
|
||||
waiters.delete(handleReady);
|
||||
if (waiters.size === 0) {
|
||||
rendererReadyWaitersByWebContentsId.delete(wcId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReady = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleClosed = () => {
|
||||
cleanup();
|
||||
reject(new Error("Main window closed before renderer became ready."));
|
||||
};
|
||||
const handleGone = (_event, details) => {
|
||||
cleanup();
|
||||
reject(new Error(`Renderer process exited before ready: ${details?.reason || "unknown"}`));
|
||||
};
|
||||
|
||||
let waiters = rendererReadyWaitersByWebContentsId.get(wcId);
|
||||
if (!waiters) {
|
||||
waiters = new Set();
|
||||
rendererReadyWaitersByWebContentsId.set(wcId, waiters);
|
||||
}
|
||||
waiters.add(handleReady);
|
||||
|
||||
win.once("closed", handleClosed);
|
||||
win.webContents?.once?.("render-process-gone", handleGone);
|
||||
|
||||
if (Number(timeoutMs) > 0) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Renderer did not report ready before timeout."));
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main application window
|
||||
*/
|
||||
@@ -869,12 +994,27 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
// Clear reference when the main window is destroyed
|
||||
win.on('closed', () => {
|
||||
try {
|
||||
if (win?.webContents?.id) {
|
||||
unhealthyWebContentsIds.delete(win.webContents.id);
|
||||
rendererReadySeenByWebContentsId.delete(win.webContents.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (mainWindow === win) mainWindow = null;
|
||||
});
|
||||
|
||||
// Log renderer crashes for diagnostics (skip normal clean exits)
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (details?.reason === "clean-exit") return;
|
||||
try {
|
||||
if (win.webContents?.id) {
|
||||
unhealthyWebContentsIds.add(win.webContents.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const crashLogBridge = require("./crashLogBridge.cjs");
|
||||
crashLogBridge.captureError("render-process-gone", new Error(
|
||||
@@ -1097,14 +1237,62 @@ async function createWindow(electronModule, options) {
|
||||
/**
|
||||
* Create or focus the settings window
|
||||
*/
|
||||
/**
|
||||
* Show + reliably focus a window's renderer. Works around two Windows-specific
|
||||
* Electron quirks that surface when a prewarmed/hidden window is later shown
|
||||
* (see issue #760):
|
||||
*
|
||||
* 1. SetForegroundWindow restrictions: `BrowserWindow.focus()` invoked from
|
||||
* a non-foreground process is often silently rejected by Windows. The
|
||||
* window appears on top but never receives true OS foreground focus, so
|
||||
* `document.hasFocus()` stays false in the renderer.
|
||||
* 2. Chromium suppresses the input caret + keyboard routing whenever
|
||||
* `document.hasFocus()` is false, even if an `<input>` is the active
|
||||
* element. The classic symptom: clicking an input selects/deletes work
|
||||
* but the caret never blinks and typed characters don't appear.
|
||||
*
|
||||
* The alwaysOnTop toggle is the established workaround for (1); explicitly
|
||||
* calling `webContents.focus()` covers (2) so the renderer marks the page as
|
||||
* focused regardless of whether the OS granted foreground.
|
||||
*/
|
||||
function showAndFocusWindow(win) {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
try {
|
||||
win.show();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
win.setAlwaysOnTop(true);
|
||||
win.focus();
|
||||
win.setAlwaysOnTop(false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
win.focus();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.focus();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
|
||||
|
||||
// If settings window already exists, show and focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.show();
|
||||
settingsWindow.focus();
|
||||
showAndFocusWindow(settingsWindow);
|
||||
return settingsWindow;
|
||||
}
|
||||
|
||||
@@ -1264,7 +1452,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
await win.loadURL(`${baseUrl}${settingsPath}`);
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
if (showOnLoad) { showAndFocusWindow(win); }
|
||||
return win;
|
||||
} catch (e) {
|
||||
console.warn("Dev server not reachable for settings window", e);
|
||||
@@ -1273,7 +1461,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html#/settings");
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
if (showOnLoad) { showAndFocusWindow(win); }
|
||||
|
||||
return win;
|
||||
}
|
||||
@@ -1467,8 +1655,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
ipcMain.on("netcatty:renderer:ready", (event) => {
|
||||
const wcId = event?.sender?.id;
|
||||
if (!wcId) return;
|
||||
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
|
||||
if (cb) cb();
|
||||
resolveRendererReady(wcId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1558,6 +1745,8 @@ module.exports = {
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
getSettingsWindow,
|
||||
isWindowUsable,
|
||||
waitForRendererReady,
|
||||
setIsQuitting,
|
||||
openFallbackBrowser,
|
||||
tryOpenExternalWithFallback,
|
||||
|
||||
67
electron/bridges/windowManagerReadiness.test.cjs
Normal file
@@ -0,0 +1,67 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { isWindowUsable } = require("./windowManager.cjs");
|
||||
|
||||
function createWindowStub({ destroyed = false, webContents } = {}) {
|
||||
return {
|
||||
isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
isVisible() {
|
||||
return true;
|
||||
},
|
||||
webContents,
|
||||
};
|
||||
}
|
||||
|
||||
test("isWindowUsable returns false when webContents is crashed", () => {
|
||||
const win = createWindowStub({
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(isWindowUsable(win), false);
|
||||
});
|
||||
|
||||
test("isWindowUsable returns true for a healthy live window", () => {
|
||||
const win = createWindowStub({
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(isWindowUsable(win), true);
|
||||
});
|
||||
|
||||
test("isWindowUsable can require a visible window", () => {
|
||||
const hiddenWin = {
|
||||
...createWindowStub({
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
isVisible() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isWindowUsable(hiddenWin, { requireVisible: true }), false);
|
||||
assert.equal(isWindowUsable(hiddenWin, { requireVisible: false }), true);
|
||||
});
|
||||
@@ -20,79 +20,31 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
|
||||
|
||||
// Load crash log bridge early so process-level error handlers can use it
|
||||
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
|
||||
|
||||
// SSH / network errors that must never crash the process.
|
||||
// ssh2 can emit multiple 'error' events per connection (e.g. ECONNRESET followed
|
||||
// by "Connection lost before handshake"). If a listener is consumed after the first
|
||||
// event, the second becomes an uncaught exception. These are non-fatal for the app.
|
||||
function isNonFatalNetworkError(err) {
|
||||
if (!err) return false;
|
||||
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||||
// never a reason to kill the entire multi-session app.
|
||||
if (err.level) return true;
|
||||
const code = err.code;
|
||||
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||||
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
case 'ECONNREFUSED':
|
||||
case 'ECONNABORTED':
|
||||
case 'ETIMEDOUT':
|
||||
case 'ENOTFOUND':
|
||||
case 'EHOSTUNREACH':
|
||||
case 'EHOSTDOWN':
|
||||
case 'ENETUNREACH':
|
||||
case 'ENETDOWN':
|
||||
case 'EADDRNOTAVAIL':
|
||||
case 'EPROTO':
|
||||
case 'EPERM':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions — log all, only re-throw truly fatal ones
|
||||
process.on('uncaughtException', (err) => {
|
||||
// Skip benign stream teardown errors — don't pollute crash logs with false positives
|
||||
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
|
||||
console.warn('Ignored stream error:', err.code);
|
||||
return;
|
||||
}
|
||||
// Non-fatal SSH/network errors: log but do NOT crash the process
|
||||
if (isNonFatalNetworkError(err)) {
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
const {
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
} = require("./bridges/processErrorGuards.cjs");
|
||||
const processErrorController = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
try { crashLogBridge.captureError(source, err); } catch {}
|
||||
},
|
||||
onFatalError(err, context) {
|
||||
uninstallProcessErrorHandlers();
|
||||
if (context?.origin === 'unhandledRejection') {
|
||||
console.error('Unhandled rejection:', context.reason);
|
||||
} else {
|
||||
console.error('Uncaught exception:', err);
|
||||
}
|
||||
console.warn('Non-fatal uncaught exception (suppressed):', err.message);
|
||||
return;
|
||||
}
|
||||
// Skip logging if already captured by unhandledRejection handler
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
}
|
||||
console.error('Uncaught exception:', err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
// Skip benign stream teardown errors
|
||||
const code = reason?.code;
|
||||
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
|
||||
// Non-fatal SSH/network errors: log but do NOT re-throw
|
||||
if (isNonFatalNetworkError(reason)) {
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.warn('Non-fatal unhandled rejection (suppressed):', reason?.message || reason);
|
||||
return;
|
||||
}
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.error('Unhandled rejection:', reason);
|
||||
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
|
||||
// can skip duplicate logging.
|
||||
const err = reason instanceof Error ? reason : new Error(String(reason));
|
||||
err.__fromUnhandledRejection = true;
|
||||
throw err;
|
||||
throw err;
|
||||
},
|
||||
logError(...args) {
|
||||
console.error(...args);
|
||||
},
|
||||
logWarn(...args) {
|
||||
console.warn(...args);
|
||||
},
|
||||
});
|
||||
let uninstallProcessErrorHandlers = installProcessErrorHandlers(process, processErrorController);
|
||||
|
||||
// Load Electron
|
||||
let electronModule;
|
||||
@@ -1013,6 +965,80 @@ async function createWindow() {
|
||||
return win;
|
||||
}
|
||||
|
||||
function waitForWindowToShow(win) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
reject(new Error("Main window was destroyed before first show."));
|
||||
return;
|
||||
}
|
||||
if (win.isVisible?.()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try { win.removeListener("show", handleShow); } catch {}
|
||||
try { win.removeListener("closed", handleClosed); } catch {}
|
||||
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
|
||||
};
|
||||
|
||||
const handleShow = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleClosed = () => {
|
||||
cleanup();
|
||||
reject(new Error("Main window closed before first show."));
|
||||
};
|
||||
const handleGone = (_event, details) => {
|
||||
cleanup();
|
||||
reject(new Error(`Renderer process exited before first show: ${details?.reason || "unknown"}`));
|
||||
};
|
||||
|
||||
win.once("show", handleShow);
|
||||
win.once("closed", handleClosed);
|
||||
win.webContents?.once?.("render-process-gone", handleGone);
|
||||
});
|
||||
}
|
||||
|
||||
let mainWindowStartupPromise = null;
|
||||
|
||||
async function createAndShowMainWindow() {
|
||||
if (mainWindowStartupPromise) return mainWindowStartupPromise;
|
||||
|
||||
mainWindowStartupPromise = (async () => {
|
||||
processErrorController.beginMainWindowStartup();
|
||||
try {
|
||||
const win = await createWindow();
|
||||
await waitForWindowToShow(win);
|
||||
void getWindowManager().waitForRendererReady(win, {
|
||||
timeoutMs: isDev ? 30000 : 15000,
|
||||
}).catch((err) => {
|
||||
console.warn("[Main] Renderer ready signal was late or missing after first show:", err?.message || err);
|
||||
});
|
||||
processErrorController.completeMainWindowStartup({ windowShown: true });
|
||||
return win;
|
||||
} catch (err) {
|
||||
processErrorController.completeMainWindowStartup({ windowShown: false });
|
||||
throw err;
|
||||
} finally {
|
||||
mainWindowStartupPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return mainWindowStartupPromise;
|
||||
}
|
||||
|
||||
function hasUsableWindow() {
|
||||
try {
|
||||
const windowManager = getWindowManager();
|
||||
return [windowManager.getMainWindow?.(), windowManager.getSettingsWindow?.()]
|
||||
.some((win) => windowManager.isWindowUsable?.(win, { requireVisible: true }));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showStartupError(err) {
|
||||
const title = "Netcatty";
|
||||
const code = err && typeof err === "object" ? err.code : null;
|
||||
@@ -1038,9 +1064,12 @@ if (!gotLock) {
|
||||
app.on("second-instance", () => {
|
||||
if (!focusMainWindow()) {
|
||||
// Window is missing or crashed — try to recreate it
|
||||
void createWindow().catch((err) => {
|
||||
void createAndShowMainWindow().catch((err) => {
|
||||
console.error("[Main] Failed to recreate window on second-instance:", err);
|
||||
showStartupError(err);
|
||||
if (!hasUsableWindow()) {
|
||||
try { app.quit(); } catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1058,9 +1087,17 @@ if (!gotLock) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
// Build and set application menu. A broken menu should not take down
|
||||
// the entire app — fall back to no custom menu and continue startup.
|
||||
try {
|
||||
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
} catch (err) {
|
||||
console.error("[Main] Failed to build application menu:", err);
|
||||
try {
|
||||
Menu.setApplicationMenu(null);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
@@ -1080,7 +1117,7 @@ if (!gotLock) {
|
||||
});
|
||||
|
||||
// Create the main window
|
||||
void createWindow().then(() => {
|
||||
void createAndShowMainWindow().then(() => {
|
||||
// Trigger auto-update check 5 s after window creation.
|
||||
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
|
||||
getAutoUpdateBridge().startAutoCheck(5000);
|
||||
@@ -1130,9 +1167,12 @@ if (!gotLock) {
|
||||
|
||||
if (focusMainWindow()) return;
|
||||
// Main window doesn't exist — create it even if other windows (e.g. settings) are open
|
||||
void createWindow().catch((err) => {
|
||||
void createAndShowMainWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
showStartupError(err);
|
||||
if (!hasUsableWindow()) {
|
||||
try { app.quit(); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
11
index.css
@@ -102,6 +102,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes split-panel-enter {
|
||||
0% {
|
||||
width: 0;
|
||||
|
||||
13
index.html
@@ -131,7 +131,7 @@
|
||||
.splash-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: hsl(var(--primary));
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.splash-spinner {
|
||||
@@ -195,15 +195,8 @@
|
||||
<!-- Splash screen: shown while React loads, hidden after first paint -->
|
||||
<div id="splash" class="splash-screen">
|
||||
<div class="splash-content">
|
||||
<svg class="splash-logo" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="currentColor" fill-opacity="0.1" />
|
||||
<path
|
||||
d="M14 16C14 14.8954 14.8954 14 16 14H32C33.1046 14 34 14.8954 34 16V32C34 33.1046 33.1046 34 32 34H16C14.8954 34 14 33.1046 14 32V16Z"
|
||||
stroke="currentColor" stroke-width="2" />
|
||||
<path d="M18 22L22 26L18 30" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M26 30H30" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<img class="splash-logo" src="/logo.svg" alt="netcatty" draggable="false" />
|
||||
|
||||
<div class="splash-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
131
infrastructure/ai/errorClassifier.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyError, sanitizeErrorMessage } from "./errorClassifier.ts";
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sanitizeErrorMessage — regression guard for pre-existing behavior
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("sanitizeErrorMessage strips absolute user paths", () => {
|
||||
const result = sanitizeErrorMessage("ENOENT at /Users/alice/project/file.ts");
|
||||
assert.match(result, /<path>/);
|
||||
assert.doesNotMatch(result, /alice/);
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts URL credentials", () => {
|
||||
const result = sanitizeErrorMessage("Failed https://api.example.com/v1?api_key=SECRET123");
|
||||
assert.match(result, /<url-redacted>/);
|
||||
assert.doesNotMatch(result, /SECRET123/);
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage truncates very long messages", () => {
|
||||
const long = "a".repeat(1000);
|
||||
const result = sanitizeErrorMessage(long);
|
||||
assert.ok(result.length < 600, `expected truncation, got ${result.length} chars`);
|
||||
assert.match(result, /\.\.\.$/);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — 413 detection
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError surfaces a friendly 413 message when statusCode is 413", () => {
|
||||
const err = Object.assign(new Error("Request failed with status 413"), {
|
||||
statusCode: 413,
|
||||
responseBody: "<html>nginx 413</html>",
|
||||
});
|
||||
const info = classifyError(err);
|
||||
assert.equal(info.type, "network");
|
||||
assert.match(info.message, /Request too large/i);
|
||||
assert.match(info.message, /client_max_body_size/i);
|
||||
assert.match(info.message, /Raw:/);
|
||||
});
|
||||
|
||||
test("classifyError detects 'Request Entity Too Large' in a string error", () => {
|
||||
const info = classifyError("413 Request Entity Too Large");
|
||||
assert.equal(info.type, "network");
|
||||
assert.match(info.message, /Request too large/i);
|
||||
});
|
||||
|
||||
test("classifyError handles 413 via the message when no statusCode field is set", () => {
|
||||
const info = classifyError(new Error("AI_APICallError: 413 payload rejected"));
|
||||
assert.equal(info.type, "network");
|
||||
assert.match(info.message, /Request too large/i);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — 502 / 503 / 504 upstream gateway
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError marks 502/503/504 as network+retryable", () => {
|
||||
for (const code of [502, 503, 504]) {
|
||||
const info = classifyError(Object.assign(new Error(`status ${code}`), { statusCode: code }));
|
||||
assert.equal(info.type, "network");
|
||||
assert.equal(info.retryable, true, `code ${code} should be retryable`);
|
||||
assert.match(info.message, new RegExp(String(code)));
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — HTML response body
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError detects HTML in responseBody even when status is unknown", () => {
|
||||
const err = Object.assign(new Error("Invalid JSON"), {
|
||||
responseBody: "<!DOCTYPE html>\n<html><body>nginx error</body></html>",
|
||||
});
|
||||
const info = classifyError(err);
|
||||
assert.equal(info.type, "provider");
|
||||
assert.match(info.message, /HTML error page/i);
|
||||
assert.match(info.message, /proxy/i);
|
||||
});
|
||||
|
||||
test("classifyError detects HTML directly embedded in the error message", () => {
|
||||
const info = classifyError("Parse failed: <html><body>...</body></html>");
|
||||
assert.equal(info.type, "provider");
|
||||
assert.match(info.message, /HTML error page/i);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — Zod / schema parse failures
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError surfaces a friendlier message for 'Expected \\'id\\' to be a string.'", () => {
|
||||
// This is the exact error pattern reported in #765.
|
||||
const info = classifyError("Expected 'id' to be a string.");
|
||||
assert.equal(info.type, "provider");
|
||||
assert.match(info.message, /could not be parsed/i);
|
||||
assert.match(info.message, /request-size limit/i);
|
||||
// Raw error must still be visible for debugging / user reports.
|
||||
assert.match(info.message, /Expected 'id' to be a string/);
|
||||
});
|
||||
|
||||
test("classifyError handles a variety of schema validation wordings", () => {
|
||||
for (const raw of [
|
||||
"Invalid JSON response: missing field",
|
||||
"Type validation failed: expected number",
|
||||
"Expected 'choices' to be an array.",
|
||||
]) {
|
||||
const info = classifyError(raw);
|
||||
assert.equal(info.type, "provider", `wording: ${raw}`);
|
||||
assert.match(info.message, /could not be parsed|HTML error page/i);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — fallthrough
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError falls through to 'unknown' for unclassified errors", () => {
|
||||
const info = classifyError(new Error("Some other provider failure"));
|
||||
assert.equal(info.type, "unknown");
|
||||
assert.match(info.message, /Some other provider failure/);
|
||||
});
|
||||
|
||||
test("classifyError handles null, undefined, and non-Error shapes without throwing", () => {
|
||||
assert.doesNotThrow(() => classifyError(null));
|
||||
assert.doesNotThrow(() => classifyError(undefined));
|
||||
assert.doesNotThrow(() => classifyError({ foo: "bar" }));
|
||||
assert.doesNotThrow(() => classifyError(42));
|
||||
});
|
||||
@@ -1,15 +1,173 @@
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
type ErrorInfo = NonNullable<ChatMessage['errorInfo']>;
|
||||
|
||||
/**
|
||||
* Convert a raw error string into display-safe error info.
|
||||
*
|
||||
* Intentionally avoids keyword-based "root cause" attribution because upstream
|
||||
* providers often return generic 4xx/5xx text that would be misclassified.
|
||||
* We show the sanitized upstream message directly instead.
|
||||
* Extract the human-readable message from anything that might surface as an
|
||||
* error (Error instance, string, SDK error object with `.message`, etc.).
|
||||
*/
|
||||
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
|
||||
const message = sanitizeErrorMessage(error).trim() || 'Unknown error';
|
||||
return { type: 'unknown', message, retryable: false };
|
||||
function extractMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message || '';
|
||||
if (typeof error === 'string') return error;
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const m = (error as { message: unknown }).message;
|
||||
if (typeof m === 'string') return m;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the HTTP status code out of an error when the SDK layer attached one.
|
||||
* Vercel AI SDK's APICallError exposes `.statusCode`; some shims use
|
||||
* `.status` or `.cause.statusCode`. Falls back to parsing the message text
|
||||
* when no structured field is available.
|
||||
*/
|
||||
function extractStatusCode(error: unknown, message: string): number | undefined {
|
||||
if (error && typeof error === 'object') {
|
||||
const obj = error as Record<string, unknown>;
|
||||
if (typeof obj.statusCode === 'number') return obj.statusCode;
|
||||
if (typeof obj.status === 'number') return obj.status;
|
||||
if (obj.cause && typeof obj.cause === 'object') {
|
||||
const causeStatus = (obj.cause as Record<string, unknown>).statusCode;
|
||||
if (typeof causeStatus === 'number') return causeStatus;
|
||||
}
|
||||
}
|
||||
// Last resort: look for a standalone 3-digit HTTP status in the message.
|
||||
// Bound by word boundaries to avoid picking up "in 413 ms" etc.
|
||||
const match = message.match(/\b(4\d{2}|5\d{2})\b/);
|
||||
if (match) return Number(match[1]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the response body out of an error object if the SDK attached it.
|
||||
* Nginx / CDN proxy error pages ship as HTML, so we can detect them here.
|
||||
*/
|
||||
function extractResponseBody(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const body = (error as Record<string, unknown>).responseBody;
|
||||
if (typeof body === 'string') return body;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function looksLikeHtml(text: string): boolean {
|
||||
if (!text) return false;
|
||||
const lower = text.toLowerCase();
|
||||
const trimmedStart = lower.trimStart().slice(0, 200);
|
||||
// Start-of-body: responseBody captured verbatim by the SDK lands here.
|
||||
if (
|
||||
trimmedStart.startsWith('<!doctype html') ||
|
||||
trimmedStart.startsWith('<html') ||
|
||||
trimmedStart.startsWith('<head') ||
|
||||
trimmedStart.startsWith('<body')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Embedded: some SDKs wrap the HTML body inside an error message like
|
||||
// "Parse failed: <html>...". Look for unmistakable HTML tags anywhere
|
||||
// in the text. Kept narrow to avoid flagging errors that casually
|
||||
// mention "html" as a word.
|
||||
if (
|
||||
lower.includes('<!doctype html') ||
|
||||
lower.includes('<html>') ||
|
||||
lower.includes('<html ') ||
|
||||
// Common nginx default error-page opener.
|
||||
/<center>\s*<h1>/.test(lower)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksLikeZodParseError(message: string): boolean {
|
||||
// Zod and Vercel AI SDK schema errors look like:
|
||||
// Expected 'id' to be a string.
|
||||
// Expected 'choices' to be an array.
|
||||
// Invalid JSON response: ...
|
||||
// Type validation failed: ...
|
||||
return (
|
||||
/\bExpected '[^']+' to be (a|an) /i.test(message) ||
|
||||
/\binvalid json response\b/i.test(message) ||
|
||||
/\btype validation failed\b/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an arbitrary error surface to display-safe error info shown in the
|
||||
* chat UI. Known hostile scenarios get a concrete, actionable message; the
|
||||
* raw SDK text is appended so users can still report it verbatim.
|
||||
*
|
||||
* Covers:
|
||||
* - HTTP 413 (proxy request-size limit, e.g. nginx client_max_body_size)
|
||||
* - HTTP 502/504 (upstream proxy failures)
|
||||
* - HTML error page returned in place of JSON (any proxy)
|
||||
* - Schema/parse failures ("Expected 'id' to be a string.") that typically
|
||||
* mean the server swapped the response body for an error page
|
||||
*/
|
||||
export function classifyError(error: unknown): ErrorInfo {
|
||||
const rawMessage = extractMessage(error).trim() || 'Unknown error';
|
||||
const statusCode = extractStatusCode(error, rawMessage);
|
||||
const responseBody = extractResponseBody(error);
|
||||
|
||||
const hasHtml =
|
||||
looksLikeHtml(rawMessage) ||
|
||||
(responseBody !== undefined && looksLikeHtml(responseBody));
|
||||
const looksLikeParseError = looksLikeZodParseError(rawMessage);
|
||||
|
||||
const sanitizedRaw = sanitizeErrorMessage(rawMessage);
|
||||
|
||||
if (statusCode === 413 || /\brequest entity too large\b/i.test(rawMessage)) {
|
||||
return {
|
||||
type: 'network',
|
||||
message:
|
||||
`Request too large (HTTP 413). The AI gateway rejected the payload — this usually means ` +
|
||||
`the request body exceeded the proxy's size limit (for example nginx \`client_max_body_size\`). ` +
|
||||
`Try sending a shorter message, fewer/smaller attachments, or raising the proxy limit.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
|
||||
return {
|
||||
type: 'network',
|
||||
message:
|
||||
`AI gateway error (HTTP ${statusCode}). The proxy in front of the provider returned an error — ` +
|
||||
`the upstream AI service may be unreachable or timing out.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHtml) {
|
||||
return {
|
||||
type: 'provider',
|
||||
message:
|
||||
`The server returned an HTML error page instead of a JSON response. ` +
|
||||
`This almost always means a proxy (nginx / CDN / gateway) between you and the AI provider ` +
|
||||
`intercepted the request — commonly due to a size limit, auth failure, or the upstream service being down.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (looksLikeParseError) {
|
||||
return {
|
||||
type: 'provider',
|
||||
message:
|
||||
`The AI response could not be parsed as a valid chat completion. ` +
|
||||
`A proxy may have replaced or truncated the response body, or the provider returned a non-standard format. ` +
|
||||
`If you just sent a large request, check for a request-size limit on any intermediate proxy.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'unknown', message: sanitizedRaw, retryable: false };
|
||||
}
|
||||
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
||||
|
||||
@@ -149,6 +149,7 @@ export const STORAGE_KEY_GROUP_CONFIGS = 'netcatty_group_configs_v1';
|
||||
|
||||
// Side Panel
|
||||
export const STORAGE_KEY_SIDE_PANEL_WIDTH = 'netcatty_side_panel_width';
|
||||
export const STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH = 'netcatty_workspace_focus_sidebar_width';
|
||||
|
||||
// Port Forwarding (transient cross-window broadcast key)
|
||||
export const STORAGE_KEY_PF_RECONNECT_CANCEL = '__netcatty_pf_cancel_reconnect';
|
||||
|
||||
@@ -1345,7 +1345,7 @@ export class CloudSyncManager {
|
||||
// entities we still have in base. The merge itself is correct if local
|
||||
// state is trustworthy — but a degraded local (keychain failure,
|
||||
// partial load) can make merge produce a smaller-than-expected result.
|
||||
const mergedShrink = detectSuspiciousShrink(mergeResult.payload, base);
|
||||
const mergedShrink = detectSuspiciousShrink(mergeResult.payload, base, remotePayload);
|
||||
const shouldBlockMerged = mergedShrink.suspicious && !overrideShrinkRequested;
|
||||
const shouldForceMerged = mergedShrink.suspicious && overrideShrinkRequested;
|
||||
if (shouldBlockMerged) {
|
||||
@@ -1440,9 +1440,28 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
// Shrink guard (no-conflict path): same rationale as the merge branch —
|
||||
// refuse a payload that drops entities versus the stored base.
|
||||
// refuse a payload that drops entities versus the stored base. When the
|
||||
// stored base is absent (first sync, re-auth, or decrypt failure) fall
|
||||
// back to the current remote payload if one exists — the guard must
|
||||
// have *some* reference to catch a degraded local from wiping the
|
||||
// cloud (#779).
|
||||
const directBase = await this.loadSyncBase(provider);
|
||||
const directShrink = detectSuspiciousShrink(payload, directBase);
|
||||
let directRemoteRef: SyncPayload | null = null;
|
||||
if (!directBase && checkResult.remoteFile) {
|
||||
try {
|
||||
directRemoteRef = await EncryptionService.decryptPayload(
|
||||
checkResult.remoteFile,
|
||||
this.masterPassword,
|
||||
);
|
||||
} catch {
|
||||
// Decrypt failure means we can't trust the remote contents as a
|
||||
// reference; leave `null` and let the guard return not-suspicious
|
||||
// rather than block on garbage. The upload itself will likely fail
|
||||
// downstream if the password mismatch is real.
|
||||
directRemoteRef = null;
|
||||
}
|
||||
}
|
||||
const directShrink = detectSuspiciousShrink(payload, directBase, directRemoteRef);
|
||||
const shouldBlockDirect = directShrink.suspicious && !overrideShrinkRequested;
|
||||
const shouldForceDirect = directShrink.suspicious && overrideShrinkRequested;
|
||||
if (shouldBlockDirect) {
|
||||
@@ -1808,6 +1827,18 @@ export class CloudSyncManager {
|
||||
'[CloudSyncManager] syncAll: connected providers hold divergent bases (multi-account setup?). Uploading the conflict-merged payload will replace each provider\'s current remote. See I-7 in PR #720 for context.',
|
||||
summaries,
|
||||
);
|
||||
// Surface the same finding to the UI so multi-account / intentionally
|
||||
// diverged configurations can be warned visibly instead of silently
|
||||
// having one provider's data merged over another's (#779 follow-up).
|
||||
this.emit({
|
||||
type: 'PROVIDERS_DIVERGED',
|
||||
summaries: summaries.map((s) => ({
|
||||
provider: s.provider as CloudProvider,
|
||||
hosts: s.hosts,
|
||||
keys: s.keys,
|
||||
snippets: s.snippets,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (diagError) {
|
||||
// Non-fatal diagnostic; never let it block the sync.
|
||||
@@ -1907,7 +1938,26 @@ export class CloudSyncManager {
|
||||
.map((r) => r.provider as CloudProvider);
|
||||
for (const provider of candidateProviders) {
|
||||
const providerBase = await this.loadSyncBase(provider);
|
||||
const finding = detectSuspiciousShrink(payload, providerBase);
|
||||
// When no stored base exists, fall back to the remote payload fetched
|
||||
// during the parallel check above — the shrink guard needs a reference
|
||||
// or it fails open and lets degraded local state overwrite remote
|
||||
// (#779). checkResults carries the per-provider remoteFile already.
|
||||
let providerRemoteRef: SyncPayload | null = null;
|
||||
if (!providerBase) {
|
||||
const entry = checkResults.find((r) => r.provider === provider);
|
||||
const remoteFile = entry?.check?.remoteFile;
|
||||
if (remoteFile) {
|
||||
try {
|
||||
providerRemoteRef = await EncryptionService.decryptPayload(
|
||||
remoteFile,
|
||||
this.masterPassword,
|
||||
);
|
||||
} catch {
|
||||
providerRemoteRef = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const finding = detectSuspiciousShrink(payload, providerBase, providerRemoteRef);
|
||||
if (finding.suspicious) {
|
||||
shrinkSuspectByProvider.push({ provider, finding });
|
||||
}
|
||||
|
||||
@@ -309,41 +309,69 @@ export const validateToken = async (accessToken: string): Promise<boolean> => {
|
||||
|
||||
const APP_FOLDER_PATH = '/drive/special/approot';
|
||||
|
||||
// Eventual-consistency retry for OneDrive "not found" lookups. The Graph API
|
||||
// can briefly 404 a file that was uploaded seconds ago from another device
|
||||
// (most commonly when the other device is syncing through the OneDrive
|
||||
// desktop client and the change has not yet reached Graph). Treating every
|
||||
// 404 as authoritative "cloud is empty" lets a second device proceed to an
|
||||
// empty-cloud upload path and overwrite real data (#779). We retry a small
|
||||
// bounded number of times with short backoff to flush through that window.
|
||||
const NOT_FOUND_RETRIES = 2;
|
||||
const NOT_FOUND_BACKOFF_MS = 1500;
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function retryOnNotFound<T>(
|
||||
fetchOnce: () => Promise<T | null>,
|
||||
): Promise<T | null> {
|
||||
let result = await fetchOnce();
|
||||
for (let attempt = 1; attempt <= NOT_FOUND_RETRIES && result === null; attempt++) {
|
||||
await sleep(NOT_FOUND_BACKOFF_MS * attempt);
|
||||
result = await fetchOnce();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure app folder exists and find sync file
|
||||
*/
|
||||
export const findSyncFile = async (accessToken: string): Promise<string | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveFindSyncFile) {
|
||||
const result = await bridge.onedriveFindSyncFile({
|
||||
accessToken,
|
||||
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
||||
});
|
||||
return result.fileId || null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const fetchOnce = async (): Promise<string | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveFindSyncFile) {
|
||||
const result = await bridge.onedriveFindSyncFile({
|
||||
accessToken,
|
||||
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
||||
});
|
||||
return result.fileId || null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to find sync file');
|
||||
}
|
||||
|
||||
const item: DriveItem = await response.json();
|
||||
return item.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to find sync file');
|
||||
}
|
||||
|
||||
const item: DriveItem = await response.json();
|
||||
return item.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return retryOnNotFound(fetchOnce);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -394,39 +422,43 @@ export const downloadSyncFile = async (
|
||||
accessToken: string,
|
||||
fileId?: string
|
||||
): Promise<SyncedFile | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveDownloadSyncFile) {
|
||||
const result = await bridge.onedriveDownloadSyncFile({
|
||||
accessToken,
|
||||
fileId,
|
||||
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
||||
});
|
||||
return (result.syncedFile as SyncedFile | null) || null;
|
||||
}
|
||||
try {
|
||||
// Can use either file ID or path
|
||||
const url = fileId
|
||||
? `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}/content`
|
||||
: `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}:/content`;
|
||||
const fetchOnce = async (): Promise<SyncedFile | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveDownloadSyncFile) {
|
||||
const result = await bridge.onedriveDownloadSyncFile({
|
||||
accessToken,
|
||||
fileId,
|
||||
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
||||
});
|
||||
return (result.syncedFile as SyncedFile | null) || null;
|
||||
}
|
||||
try {
|
||||
// Can use either file ID or path
|
||||
const url = fileId
|
||||
? `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}/content`
|
||||
: `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}:/content`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download sync file');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download sync file');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return retryOnNotFound(fetchOnce);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
163
package-lock.json
generated
@@ -1105,13 +1105,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.4",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
|
||||
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
|
||||
"version": "3.972.18",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz",
|
||||
"integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.12.0",
|
||||
"fast-xml-parser": "5.3.4",
|
||||
"@smithy/types": "^4.14.1",
|
||||
"fast-xml-parser": "5.5.8",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1158,7 +1158,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1804,6 +1803,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1825,6 +1825,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1841,6 +1842,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1855,6 +1857,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -3310,7 +3313,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
|
||||
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -5594,9 +5596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
|
||||
"integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
|
||||
"version": "4.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz",
|
||||
"integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
@@ -6106,6 +6108,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
@@ -6299,7 +6361,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
@@ -6380,7 +6441,6 @@
|
||||
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
@@ -6410,7 +6470,6 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -6961,7 +7020,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7012,7 +7070,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -7573,7 +7630,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -8316,7 +8372,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -8600,7 +8657,6 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -8982,6 +9038,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -9002,6 +9059,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -9231,7 +9289,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9683,10 +9740,10 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
|
||||
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
|
||||
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9695,7 +9752,24 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
|
||||
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.0",
|
||||
"strnum": "^2.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -10593,7 +10667,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -12083,7 +12156,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/debug": "^4.0.0",
|
||||
"debug": "^4.0.0",
|
||||
@@ -12701,8 +12773,7 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
@@ -12957,6 +13028,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -12969,7 +13041,6 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -13551,6 +13622,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -13729,6 +13815,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -13746,6 +13833,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -13936,7 +14024,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13946,7 +14033,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -15155,9 +15241,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
|
||||
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15277,6 +15363,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -15341,6 +15428,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -15415,7 +15503,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15530,7 +15617,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -15629,7 +15715,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15650,7 +15735,6 @@
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"bail": "^2.0.0",
|
||||
@@ -15989,7 +16073,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16083,7 +16166,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16362,7 +16444,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs application/state/*.test.ts domain/*.test.ts"
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/ai/*.test.ts components/terminal/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
|
||||
|
Before Width: | Height: | Size: 727 KiB After Width: | Height: | Size: 52 KiB |
BIN
public/icon.png
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 52 KiB |
@@ -1,12 +1,50 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 56 56'>
|
||||
<rect x='0' y='0' width='56' height='56' rx='12' fill='#2F7BFF'/>
|
||||
<rect x='10' y='13' width='36' height='24' rx='4' fill='#FFFFFF' stroke='#1D4FCF' stroke-opacity='0.12'/>
|
||||
<rect x='10' y='13' width='36' height='5' rx='4' fill='#E6EEFF'/>
|
||||
<circle cx='14' cy='15.5' r='1' fill='#1E4FD1'/>
|
||||
<circle cx='18' cy='15.5' r='1' fill='#1E4FD1' opacity='0.7'/>
|
||||
<circle cx='22' cy='15.5' r='1' fill='#1E4FD1' opacity='0.5'/>
|
||||
<path d='M16 28 L20 26 L16 24' stroke='#1E4FD1' fill='none' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/>
|
||||
<path d='M24 30 H30' stroke='#1E4FD1' stroke-width='1.6' stroke-linecap='round'/>
|
||||
<path d='M36 33 C40 36,42 38,42 42 C42 45,40 47,37 47' stroke='white' fill='none' stroke-width='3.2' stroke-linecap='round'/>
|
||||
<rect x='34' y='44' width='6' height='5' rx='1' fill='white' stroke='#1E4FD1'/>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||
<defs>
|
||||
<clipPath id="round">
|
||||
<rect x="100.0" y="100.0" width="824" height="824" rx="185" ry="185" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#round)">
|
||||
<rect x="100.0" y="100.0" width="824" height="824" fill="#002551" />
|
||||
<g transform="translate(161.80 161.80) scale(0.5585)">
|
||||
<g><path style="opacity:1" fill="#f9f9f9" d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#1f2657" d="M 706.5,245.5 C 677.258,242.344 647.925,240.677 618.5,240.5C 649.662,238.284 680.995,239.784 712.5,245C 710.527,245.495 708.527,245.662 706.5,245.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#18214c" d="M 231.5,324.5 C 229.375,356.62 228.042,388.62 227.5,420.5C 226.104,392.965 226.604,365.298 229,337.5C 229.17,331.677 230.003,327.344 231.5,324.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#0c1943" d="M 1026.5,359.5 C 1027.92,371.971 1028.59,384.637 1028.5,397.5C 1028.5,405.008 1028.17,412.341 1027.5,419.5C 1026.5,399.674 1026.17,379.674 1026.5,359.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#505c83" d="M 817.5,544.5 C 815.162,546.04 812.495,546.706 809.5,546.5C 811.905,545.232 814.572,544.565 817.5,544.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#919ab0" d="M 445.5,545.5 C 448.152,545.41 450.485,546.076 452.5,547.5C 449.848,547.59 447.515,546.924 445.5,545.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#022551" d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#032551" d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#0c1a4d" d="M 849.5,574.5 C 822.908,568.314 799.574,574.314 779.5,592.5C 781.087,589.732 782.92,587.065 785,584.5C 785.333,584.833 785.667,585.167 786,585.5C 792.05,581.554 798.217,577.721 804.5,574C 813.233,572.086 822.066,570.919 831,570.5C 837.645,570.207 843.812,571.54 849.5,574.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#98a2bf" d="M 423.5,572.5 C 419.684,573.482 415.684,574.149 411.5,574.5C 415.183,572.75 419.183,572.083 423.5,572.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#9ea6be" d="M 145.5,576.5 C 145.59,579.152 144.924,581.485 143.5,583.5C 143.41,580.848 144.076,578.515 145.5,576.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#132152" d="M 435.5,572.5 C 431.5,572.5 427.5,572.5 423.5,572.5C 419.183,572.083 415.183,572.75 411.5,574.5C 389.242,579.57 372.909,592.403 362.5,613C 356.408,617.241 350.075,617.574 343.5,614C 337.996,608.137 337.163,601.637 341,594.5C 343.929,589.631 347.096,584.965 350.5,580.5C 351.515,581.46 351.515,582.627 350.5,584C 346.551,587.055 344.718,590.888 345,595.5C 344.586,595.043 344.086,594.709 343.5,594.5C 340.638,598.733 339.805,603.4 341,608.5C 347.943,616.139 355.276,616.472 363,609.5C 363.684,608.216 363.517,607.049 362.5,606C 366.772,603.96 369.606,600.126 371,594.5C 374.005,593.423 376.839,591.423 379.5,588.5C 379.291,587.914 378.957,587.414 378.5,587C 379.572,585.716 380.905,585.383 382.5,586C 398.213,573.156 415.88,568.656 435.5,572.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#6c7794" d="M 742.5,596.5 C 741.841,599.455 741.508,602.455 741.5,605.5C 740.848,604.551 740.514,603.385 740.5,602C 740.393,599.779 741.06,597.946 742.5,596.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#6f7b97" d="M 1117.5,604.5 C 1118.77,606.905 1119.43,609.572 1119.5,612.5C 1117.96,610.162 1117.29,607.495 1117.5,604.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#a8aec5" d="M 135.5,622.5 C 135.802,626.79 135.136,630.79 133.5,634.5C 133.717,630.295 134.383,626.295 135.5,622.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#677393" d="M 653.5,662.5 C 634.473,662.218 615.473,662.551 596.5,663.5C 597.263,662.732 598.263,662.232 599.5,662C 617.671,661.171 635.671,661.338 653.5,662.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#032551" d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#01103f" d="M 132.5,640.5 C 131.835,647.48 131.502,654.48 131.5,661.5C 130.669,675.994 130.169,690.661 130,705.5C 128.188,682.722 128.854,660.055 132,637.5C 132.483,638.448 132.649,639.448 132.5,640.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#7c869d" d="M 1119.5,745.5 C 1119.71,748.495 1119.04,751.162 1117.5,753.5C 1117.57,750.572 1118.23,747.905 1119.5,745.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#7581a0" d="M 706.5,791.5 C 705.737,792.268 704.737,792.768 703.5,793C 695.323,793.823 687.323,793.656 679.5,792.5C 688.533,792.716 697.533,792.383 706.5,791.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#a7aec3" d="M 1028.5,883.5 C 1032.79,883.198 1036.79,883.864 1040.5,885.5C 1036.29,885.283 1032.29,884.617 1028.5,883.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#f9f9f9" d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#f8f8f9" d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#adb2c9" d="M 233.5,904.5 C 227.444,906.453 221.111,907.453 214.5,907.5C 220.536,905.419 226.869,904.419 233.5,904.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#bec4d7" d="M 210.5,908.5 C 205.5,910.943 200.166,912.61 194.5,913.5C 199.5,911.057 204.834,909.39 210.5,908.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#9ba0b8" d="M 884.5,984.5 C 884.277,988.162 883.277,991.495 881.5,994.5C 881.723,990.838 882.723,987.505 884.5,984.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#9aa5bc" d="M 1133.5,985.5 C 1134.92,987.515 1135.59,989.848 1135.5,992.5C 1134.08,990.485 1133.41,988.152 1133.5,985.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#adb1c6" d="M 118.5,996.5 C 117.815,1004.82 117.482,1013.15 117.5,1021.5C 116.835,1018.69 116.502,1015.69 116.5,1012.5C 116.429,1006.93 117.096,1001.6 118.5,996.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#c9d0dc" d="M 1135.5,992.5 C 1136.96,998.434 1137.63,1004.6 1137.5,1011C 1137.5,1015.02 1137.17,1018.85 1136.5,1022.5C 1136.59,1012.48 1136.26,1002.48 1135.5,992.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#b5bfcb" d="M 948.5,1025.5 C 948.5,1038.5 948.5,1051.5 948.5,1064.5C 947.167,1064.5 945.833,1064.5 944.5,1064.5C 945.209,1063.6 946.209,1063.26 947.5,1063.5C 947.171,1050.66 947.505,1037.99 948.5,1025.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#8193aa" d="M 232.5,1079.5 C 235.152,1079.41 237.485,1080.08 239.5,1081.5C 236.848,1081.59 234.515,1080.92 232.5,1079.5 Z"/></g>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="104.0" y="104.0"
|
||||
width="816" height="816"
|
||||
rx="181.0" ry="181.0"
|
||||
fill="none" stroke="#ffffff" stroke-opacity="0.4"
|
||||
stroke-width="8" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 615 B After Width: | Height: | Size: 692 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 512 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 942 B After Width: | Height: | Size: 754 B |