Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 5329a47d23 fix: stabilize rAF text batching and diagnostics tests
Polyfill requestAnimationFrame for Node, flush pending text before
steer buffering, bypass rAF while steer is in flight, and stub
global localStorage for AI panel diagnostics adapter tests.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:40:50 +00:00
Cursor Agentand陈大猫 162b5a599f chore: package size, dead code, and consolidation cleanup
Remove unused direct deps, exclude Vite-bundled renderer packages from
electron-builder, delete high-confidence dead code/no-op transfer warm
wiring, consolidate cloud provider IDs and KnownHost/SFTP helpers, and
land UI/AGENTS quick wins (rAF text batching, notes draft debounce,
streaming plain text, tempDirBridge, diagnostics adapter, External MCP
setters).

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:38:16 +00:00
33 changed files with 466 additions and 470 deletions

No files matched your search

@@ -1,25 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
isTransferPoolIdleReclaimDisabled,
resolveSftpTransferPoolIdleTtlMs,
} from "../../../infrastructure/config/sftpTransferPool.ts";
test("default pool idle TTL is five minutes", () => {
assert.equal(DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS, 5 * 60_000);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => null), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
});
test("accepts preset values including never-reclaim zero", () => {
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 60_000), 60_000);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 0), 0);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 123), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
});
test("transfer pool idle reclaim helper is deprecated and always reclaim-enabled", () => {
// Channels close when idle; SSH park is separate. The helper always returns false.
assert.equal(isTransferPoolIdleReclaimDisabled(0), false);
assert.equal(isTransferPoolIdleReclaimDisabled(300_000), false);
});
+2 -9
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
@@ -206,15 +207,7 @@ const createKnownHostFromSftpHostKeyInfo = (
hostKeyInfo: SftpHostKeyInfo,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
export const useSftpConnections = ({
hosts,
@@ -2,25 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from "react";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export function useWarmSftpTransferPool(params: {
hostIds: readonly string[];
activeHostId?: string;
warmTransferPoolForHost?: (hostId: string) => void | Promise<void>;
}) {
const warmRef = useRef(params.warmTransferPoolForHost);
warmRef.current = params.warmTransferPoolForHost;
const hostIdsKey = [...new Set([
...params.hostIds,
...(params.activeHostId ? [params.activeHostId] : []),
])].sort().join("\u0000");
useEffect(() => {
for (const hostId of hostIdsKey.split("\u0000").filter(Boolean)) {
void warmRef.current?.(hostId);
}
}, [hostIdsKey]);
}
export function useReportSftpTransferOwnerActivity(params: {
ownerId: string;
activeTransfersCount: number;
@@ -144,12 +144,26 @@ export function readExternalMcpMode(): ExternalMcpMode {
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
}
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
const normalized = normalizeExternalMcpMode(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
return normalized;
}
export function readExternalMcpIdleTimeoutMinutes(): number {
return normalizeExternalMcpIdleTimeoutMinutes(
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
);
}
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
return normalized;
}
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
export function readExternalMcpFocusOnHostOpen(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
@@ -187,6 +201,13 @@ export function readSessionIdleTimeoutMinutes(): number {
);
}
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
return normalized;
}
export function shouldStartExternalMcpOnStartup({
enabled,
mode,
-11
View File
@@ -399,14 +399,6 @@ export const useSftpState = (
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
);
/**
* @deprecated No-op. SSH transport idle park keeps connections warm; opening
* a background transfer channel is unnecessary and could re-trigger MFA.
*/
const warmTransferPoolForHost = useCallback(async (_hostId: string) => {
// Intentionally empty — unified transport registry owns keep-alive.
}, []);
/** True after browse channels were soft-closed while this owner stayed mounted. */
const browseParkedRef = useRef(false);
const browseLifecycleGenRef = useRef(0);
@@ -824,7 +816,6 @@ export const useSftpState = (
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
warmTransferPoolForHost,
};
const methodsRef = useRef(currentMethods);
methodsRef.current = currentMethods;
@@ -907,8 +898,6 @@ export const useSftpState = (
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
warmTransferPoolForHost: (...args: Parameters<typeof warmTransferPoolForHost>) =>
methodsRef.current.warmTransferPoolForHost(...args),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
-7
View File
@@ -27,7 +27,6 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import {
useReportSftpTransferOwnerActivity,
useWarmSftpTransferPool,
} from "../application/state/sftp/useSftpTransferLifecycle";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import {
@@ -300,12 +299,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
useWarmSftpTransferPool({
hostIds: connectedHosts.map((entry) => entry.host.id),
activeHostId: activeHost?.protocol === "serial" ? undefined : activeHost?.id,
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
});
const { getConnectionCacheKey, leftPane } = sftp;
useEffect(() => {
-6
View File
@@ -29,7 +29,6 @@ import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from ".
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { useWarmSftpTransferPool } from "../application/state/sftp/useSftpTransferLifecycle";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { toast } from "./ui/toast";
@@ -186,11 +185,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
useWarmSftpTransferPool({
hostIds: connectedHosts.map((entry) => entry.host.id),
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
});
// Register this useSftpState's writeTextFileByConnection with the bridge so
// the editor tab's save path can reach the active SFTP session. The bridge
// supports multiple simultaneous writers (SftpSidePanel inside terminals
+15 -6
View File
@@ -38,16 +38,24 @@ test("resolved approval state retains only tool calls still present in messages"
assert.deepEqual([...next], [["live-call", false]]);
});
test("assistant content stays plain only when markdown is hidden", () => {
test("assistant content stays plain when markdown is hidden or streaming", () => {
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
}), false);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: true,
}), true);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
isStreaming: true,
}), true);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
isStreaming: false,
}), false);
});
test("ChatMessageList renders markdown for the streaming assistant message", () => {
test("ChatMessageList renders plain text for the streaming assistant message", () => {
const messages: ChatMessage[] = [
{
id: "user-1",
@@ -75,15 +83,16 @@ test("ChatMessageList renders markdown for the streaming assistant message", ()
),
);
assert.match(markup, /data-ai-content="markdown"/);
assert.match(markup, /data-ai-content="plain"/);
assert.match(markup, /streaming-body/);
assert.doesNotMatch(markup, /data-ai-content="plain"/);
assert.doesNotMatch(markup, /data-ai-content="markdown"/);
});
test("streaming markdown keeps the renderer in its animating mode", () => {
test("streaming assistant content skips Streamdown while the turn is live", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(source, /LazyMessageResponse isAnimating=\{!!isThisStreaming\}/);
assert.match(source, /isStreaming: !!isThisStreaming/);
assert.match(source, /shouldRenderAssistantAsPlainText\(\{[\s\S]*?isStreaming/);
});
test("ChatMessageList hydrates markdown after streaming settles", () => {
+4 -2
View File
@@ -113,8 +113,9 @@ export function shouldProvideVaultArtifactNavigation({
export function shouldRenderAssistantAsPlainText(options: {
hideMarkdown: boolean;
isStreaming?: boolean;
}): boolean {
return options.hideMarkdown;
return options.hideMarkdown || !!options.isStreaming;
}
const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]';
@@ -702,6 +703,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
: shouldRenderAssistantAsPlainText({
hideMarkdown,
isStreaming: !!isThisStreaming,
})
? (
<div
@@ -714,7 +716,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
: (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
<div data-ai-content="markdown">
<LazyMessageResponse isAnimating={!!isThisStreaming}>
<LazyMessageResponse isAnimating={false}>
{message.content}
</LazyMessageResponse>
</div>
+10 -5
View File
@@ -2,14 +2,19 @@ import assert from 'node:assert/strict';
import test from 'node:test';
const storage = new Map<string, string>();
const localStorageStub = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, value); },
removeItem: (key: string) => { storage.delete(key); },
};
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: localStorageStub,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
localStorage: localStorageStub,
},
});
+8 -4
View File
@@ -1,7 +1,12 @@
import type React from 'react';
import {
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = 'netcatty.aiDebug.hide';
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = 'netcatty.aiDebug.profile';
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
export const AI_PANEL_FORCE_HIDE_SHELL = false;
@@ -17,9 +22,8 @@ export type AIPanelDiagnosticPart =
| 'toolcalls';
function readLocalStorageValue(key: string): string {
if (typeof window === 'undefined') return '';
try {
return window.localStorage.getItem(key) ?? '';
return localStorageAdapter.readString(key) ?? '';
} catch {
return '';
}
+135 -37
View File
@@ -401,6 +401,75 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
return cleaned;
}, [onUpdateNotes]);
const NOTE_DRAFT_DEBOUNCE_MS = 300;
const draftNoteIdRef = useRef<string | null>(null);
const draftTitleRef = useRef<string | null>(null);
const draftContentRef = useRef<string | null>(null);
const draftTimerRef = useRef<number | null>(null);
const [draftNoteId, setDraftNoteId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState<string | null>(null);
const [draftContent, setDraftContent] = useState<string | null>(null);
const clearDraftTimer = useCallback(() => {
if (draftTimerRef.current !== null) {
window.clearTimeout(draftTimerRef.current);
draftTimerRef.current = null;
}
}, []);
const flushNoteDraft = useCallback(() => {
clearDraftTimer();
const noteId = draftNoteIdRef.current;
if (!noteId) return;
const title = draftTitleRef.current;
const content = draftContentRef.current;
draftNoteIdRef.current = null;
draftTitleRef.current = null;
draftContentRef.current = null;
setDraftNoteId(null);
setDraftTitle(null);
setDraftContent(null);
if (title === null && content === null) return;
commitNotes(sortedNotesRef.current.map((note) => {
if (note.id !== noteId) return note;
return {
...note,
...(title !== null ? { title } : {}),
...(content !== null ? { content } : {}),
updatedAt: Date.now(),
};
}));
}, [clearDraftTimer, commitNotes]);
const scheduleNoteDraftFlush = useCallback(() => {
clearDraftTimer();
draftTimerRef.current = window.setTimeout(() => {
draftTimerRef.current = null;
flushNoteDraft();
}, NOTE_DRAFT_DEBOUNCE_MS);
}, [clearDraftTimer, flushNoteDraft]);
const updateNoteDraft = useCallback((noteId: string, fields: { title?: string; content?: string }) => {
if (draftNoteIdRef.current && draftNoteIdRef.current !== noteId) {
flushNoteDraft();
}
draftNoteIdRef.current = noteId;
setDraftNoteId(noteId);
if (fields.title !== undefined) {
draftTitleRef.current = fields.title;
setDraftTitle(fields.title);
}
if (fields.content !== undefined) {
draftContentRef.current = fields.content;
setDraftContent(fields.content);
}
scheduleNoteDraftFlush();
}, [flushNoteDraft, scheduleNoteDraftFlush]);
useEffect(() => () => {
flushNoteDraft();
}, [flushNoteDraft]);
const noteTree = useMemo(() => {
const tree = buildNoteTree(groups, sortedNotes);
return {
@@ -410,6 +479,26 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}, [groupOrderByPath, groups, sortedNotes]);
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
const overlayNote = sortedNotes.find((note) => note.id === overlayNoteId) ?? null;
const selectedNoteView = selectedNote && draftNoteId === selectedNote.id
? {
...selectedNote,
...(draftTitle !== null ? { title: draftTitle } : {}),
...(draftContent !== null ? { content: draftContent } : {}),
}
: selectedNote;
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
? {
...overlayNote,
...(draftTitle !== null ? { title: draftTitle } : {}),
...(draftContent !== null ? { content: draftContent } : {}),
}
: overlayNote;
useEffect(() => {
if (!draftNoteIdRef.current) return;
if (draftNoteIdRef.current === selectedNoteId || draftNoteIdRef.current === overlayNoteId) return;
flushNoteDraft();
}, [flushNoteDraft, overlayNoteId, selectedNoteId]);
useEffect(() => {
const urls = activeDownloadUrlsRef.current;
@@ -497,9 +586,18 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
const collapseAllGroups = () => setExpandedGroups(new Set());
const saveNote = (nextNote: VaultNote) => {
flushNoteDraft();
commitNotes(sortedNotes.map((note) => (note.id === nextNote.id ? nextNote : note)));
};
const saveNoteTitleDraft = (note: VaultNote, title: string) => {
updateNoteDraft(note.id, { title });
};
const saveNoteContentDraft = (note: VaultNote, content: string) => {
updateNoteDraft(note.id, { content });
};
const handleOpenHostFromNote = useCallback((host: Host, noteId: string) => {
onOpenHost?.(host, { noteId });
}, [onOpenHost]);
@@ -1515,41 +1613,41 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
{!isSidebarMode && (
<main className="flex min-w-0 flex-1 flex-col bg-background">
{selectedNote ? (
{selectedNoteView ? (
<>
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
<div className="min-w-0 flex-1">
<input
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
value={selectedNote.title}
value={selectedNoteView.title}
placeholder={t("notes.title.placeholder")}
onChange={(event) => saveNote({
...selectedNote,
title: event.target.value,
updatedAt: Date.now(),
})}
onChange={(event) => saveNoteTitleDraft(selectedNoteView, event.target.value)}
onBlur={() => flushNoteDraft()}
/>
</div>
{renderNoteExportButton(selectedNote)}
{renderNoteExportButton(selectedNoteView)}
{renderNoteModeToggle()}
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="min-h-full w-full px-8 pt-2 pb-6">
<LazyLoadBoundary name="Notes editor" resetKey={selectedNote.id}>
<div
className="min-h-full w-full px-8 pt-2 pb-6"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey={selectedNoteView.id}>
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
key={selectedNote.id}
value={selectedNote.content}
key={selectedNoteView.id}
value={selectedNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
previewEmptyLabel={t("notes.preview.empty")}
onChange={(content) => saveNote({
...selectedNote,
content,
updatedAt: Date.now(),
})}
onChange={(content) => saveNoteContentDraft(selectedNoteView, content)}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNote.id)}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
onOpenExternalLink={openExternal}
/>
</Suspense>
@@ -1582,7 +1680,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
)}
</div>
{isSidebarMode && overlayNote && (
{isSidebarMode && overlayNoteView && (
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
@@ -1602,7 +1700,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
<TooltipContent side="bottom">{t("common.back")}</TooltipContent>
</Tooltip>
<div className="min-w-0 flex-1 truncate px-1 text-xs font-medium leading-4 text-foreground">
{overlayNote.title || t("notes.title.placeholder")}
{overlayNoteView.title || t("notes.title.placeholder")}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col bg-background">
@@ -1610,35 +1708,35 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
<div className="min-w-0 flex-1">
<input
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
value={overlayNote.title}
value={overlayNoteView.title}
placeholder={t("notes.title.placeholder")}
onChange={(event) => saveNote({
...overlayNote,
title: event.target.value,
updatedAt: Date.now(),
})}
onChange={(event) => saveNoteTitleDraft(overlayNoteView, event.target.value)}
onBlur={() => flushNoteDraft()}
/>
</div>
{renderNoteExportButton(overlayNote)}
{renderNoteExportButton(overlayNoteView)}
{renderNoteModeToggle()}
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="min-h-full w-full px-4 pt-2 pb-6">
<LazyLoadBoundary name="Notes editor" resetKey={overlayNote.id}>
<div
className="min-h-full w-full px-4 pt-2 pb-6"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
<LazyLoadBoundary name="Notes editor" resetKey={overlayNoteView.id}>
<Suspense fallback={<InlineMarkdownEditorFallback />}>
<InlineMarkdownEditor
key={overlayNote.id}
value={overlayNote.content}
key={overlayNoteView.id}
value={overlayNoteView.content}
placeholder={t("notes.editor.placeholder")}
editorMode={noteEditorMode}
onChange={(content) => saveNote({
...overlayNote,
content,
updatedAt: Date.now(),
})}
onChange={(content) => saveNoteContentDraft(overlayNoteView, content)}
previewEmptyLabel={t("notes.preview.empty")}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNote.id)}
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
onOpenExternalLink={openExternal}
/>
</Suspense>
@@ -1,4 +1,5 @@
import type { KnownHost } from "../../domain/models";
import { createKnownHostFromHostKeyInfo } from "../../domain/knownHosts";
import { toHostKeyInfo, type HostKeyVerificationRequest } from "../terminal/hostKeyVerification";
import type { HostKeyInfo } from "../terminal/TerminalHostKeyVerification";
@@ -45,12 +46,4 @@ export const createKnownHostFromPortForwardHostKeyInfo = (
hostKeyInfo: HostKeyInfo,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
@@ -2,26 +2,19 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
normalizeExternalMcpIdleTimeoutMinutes,
normalizeExternalMcpMode,
normalizeSessionIdleTimeoutMinutes,
readExternalMcpFocusOnHostOpen,
readExternalMcpIdleTimeoutMinutes,
readExternalMcpMode,
readExternalMcpSilentSessions,
readSessionIdleTimeoutMinutes,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpIdleTimeoutMinutes,
writeExternalMcpMode,
writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes,
type ExternalMcpMode,
useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState";
import {
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
} from "../../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../../infrastructure/persistence/localStorageAdapter";
import { emitAIStateChanged } from "../../../../application/state/aiStateEvents";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
@@ -328,26 +321,20 @@ export const ExternalMcpCard: React.FC = () => {
}, []);
const setMode = useCallback((nextMode: ExternalMcpMode) => {
const normalized = normalizeExternalMcpMode(nextMode);
const normalized = writeExternalMcpMode(nextMode);
setModeRaw(normalized);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
const normalized = writeExternalMcpIdleTimeoutMinutes(minutes);
setIdleTimeoutRaw(normalized);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
pushConfig(mode, normalized, sessionIdleTimeoutMinutes);
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
const normalized = writeSessionIdleTimeoutMinutes(minutes);
setSessionIdleTimeoutRaw(normalized);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
pushConfig(mode, idleTimeoutMinutes, normalized);
}, [idleTimeoutMinutes, mode, pushConfig]);
+10 -19
View File
@@ -24,6 +24,16 @@ import {
import React from 'react';
import type { LucideIcon } from 'lucide-react';
import { SftpFileEntry } from '../../types';
import {
formatDate as formatDateFromState,
isNavigableDirectory,
} from '../../application/state/sftp/utils';
export { isNavigableDirectory };
/** Accept undefined timestamps from file-list rows. */
export const formatDate = (timestamp: number | undefined): string =>
formatDateFromState(timestamp ?? 0);
// Pre-built icon maps for O(1) lookup in getFileIcon
type IconDef = [LucideIcon, string?];
@@ -199,17 +209,6 @@ export const formatTransferBytes = (bytes: number): string => {
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
/**
* Format date as YYYY-MM-DD hh:mm in local timezone
*/
export const formatDate = (timestamp: number | undefined): string => {
if (!timestamp) return '--';
const date = new Date(timestamp);
if (isNaN(date.getTime())) return '--';
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
/**
* Format speed with appropriate unit
*/
@@ -339,14 +338,6 @@ export const sortSftpEntries = (
return sorted;
};
/**
* Check if an entry is navigable like a directory
* This includes regular directories and symlinks that point to directories
*/
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
};
/**
* Check if a file is hidden
* - Windows: checks the `hidden` attribute (set by localFsBridge)
@@ -4,7 +4,6 @@
*/
import { Highlighter, Plus, Trash2, RotateCcw } from 'lucide-react';
import React, { useState, useCallback, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host, KeywordHighlightRule } from '../../types';
import { Button } from '../ui/button';
@@ -77,7 +76,7 @@ export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverPr
}
const newRule: KeywordHighlightRule = {
id: uuidv4(),
id: crypto.randomUUID(),
label: newRuleLabel.trim(),
patterns: [newRulePattern.trim()],
color: newRuleColor,
@@ -446,52 +446,3 @@ export function resolveAutocompleteAnchorInViewport(
expandUpward: shouldExpandAutocompleteUpward(cursorY, spaceBelow, spaceAbove, estimatedPopupHeight),
};
}
/** @deprecated Use resolveAutocompleteAnchorInViewport with the xterm container. */
export function calculatePopupPosition(
term: XTerm,
itemCount: number,
): {
position: { x: number; y: number };
cursorLineTop: number;
cursorLineBottom: number;
expandUpward: boolean;
} {
const termElement = term.element;
if (!termElement) {
return {
position: { x: 0, y: 0 },
cursorLineTop: 0,
cursorLineBottom: 0,
expandUpward: false,
};
}
const dims = getXTermCellDimensions(term);
const buffer = term.buffer.active;
const cursorX = buffer.cursorX;
const cursorY = buffer.cursorY;
const cursorLineTop = cursorY * dims.height;
const cursorLineBottom = (cursorY + 1) * dims.height;
const estimatedPopupHeight = estimatePopupHeight(itemCount);
const totalRows = term.rows;
const spaceBelow = (totalRows - cursorY - 1) * dims.height;
const spaceAbove = cursorY * dims.height;
const expandUpward = shouldExpandAutocompleteUpward(cursorY, spaceBelow, spaceAbove, estimatedPopupHeight);
if (expandUpward) {
return {
position: { x: cursorX * dims.width, y: cursorY * dims.height },
cursorLineTop,
cursorLineBottom,
expandUpward: true,
};
}
return {
position: { x: cursorX * dims.width, y: (cursorY + 1) * dims.height + 4 },
cursorLineTop,
cursorLineBottom,
expandUpward: false,
};
}
+5 -8
View File
@@ -1,5 +1,6 @@
import type { Host, KnownHost } from "../../types";
import type { HostKeyInfo } from "./TerminalHostKeyVerification";
import { createKnownHostFromHostKeyInfo as createKnownHostFromHostKeyInfoDomain } from "../../domain/knownHosts";
export type HostKeyVerificationRequest = {
hostname: string;
@@ -28,12 +29,8 @@ export const createKnownHostFromHostKeyInfo = (
host: Pick<Host, "port">,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || host.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
): KnownHost => createKnownHostFromHostKeyInfoDomain(hostKeyInfo, {
defaultPort: host.port,
now,
idSuffix,
});
@@ -971,9 +971,6 @@ const resolveLastPaintBufferLine = (
export const getTerminalLineTimestampLedgerCount = (term: XTerm): number =>
getTimestampStore(term).ledger.length;
/** @deprecated No-op; paint reads the ledger directly. */
export const materializeTimestampLedgerToMarkers = (_term: XTerm): number => 0;
const internTerminalLineTimestampLabel = (
store: TimestampStore,
date: Date,
-99
View File
@@ -1,99 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils.ts";
export interface CodeTextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "spellCheck"> {
showLineNumbers?: boolean;
/** Applied to the outer bordered container when line numbers are shown. */
wrapperClassName?: string;
}
function countLines(value: string): number {
if (!value) return 1;
return value.split("\n").length;
}
const CodeTextarea = React.forwardRef<HTMLTextAreaElement, CodeTextareaProps>(
({ className, wrapperClassName, value, showLineNumbers = true, onScroll, ...props }, ref) => {
const gutterRef = React.useRef<HTMLDivElement>(null);
const text = typeof value === "string" ? value : String(value ?? "");
const lineCount = countLines(text);
const lineNumbers = React.useMemo(
() => Array.from({ length: lineCount }, (_, i) => i + 1),
[lineCount],
);
const gutterWidthCh = Math.max(2, String(lineCount).length) + 1;
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (gutterRef.current) {
gutterRef.current.scrollTop = e.currentTarget.scrollTop;
}
onScroll?.(e);
};
const editorClass = cn(
"w-full flex-1 resize-none border-0 bg-transparent px-2 py-2 font-mono text-xs leading-5",
"placeholder:text-muted-foreground focus-visible:outline-none",
"whitespace-pre overflow-auto",
className,
);
if (!showLineNumbers) {
return (
<textarea
ref={ref}
value={value}
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs leading-5",
"placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
"whitespace-pre",
className,
)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
data-gramm="false"
onScroll={onScroll}
{...props}
/>
);
}
return (
<div
className={cn(
"flex w-full overflow-hidden rounded-md border border-input bg-background transition-colors",
"focus-within:border-ring",
wrapperClassName,
)}
>
<div
ref={gutterRef}
aria-hidden
className="shrink-0 overflow-hidden border-r border-border/60 bg-muted/30 py-2 pl-2 pr-1.5 select-none"
style={{ width: `${gutterWidthCh}ch` }}
>
<pre className="font-mono text-[11px] leading-5 text-muted-foreground text-right m-0">
{lineNumbers.join("\n")}
</pre>
</div>
<textarea
ref={ref}
value={value}
className={editorClass}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
data-gramm="false"
onScroll={handleScroll}
{...props}
/>
</div>
);
},
);
CodeTextarea.displayName = "CodeTextarea";
export { CodeTextarea };
-6
View File
@@ -429,10 +429,4 @@ export const healPoisonedSecretsForMerge = (
};
};
/** @deprecated Prefer healPoisonedSecretsForMerge(remote, local, base). */
export const healPoisonedRemoteSecretsForMerge = (
remote: SyncPayload,
local: SyncPayload,
base: SyncPayload | null | undefined,
): SyncPayload => healPoisonedSecretsForMerge(remote, local, base);
+35
View File
@@ -190,3 +190,38 @@ export const normalizeKnownHosts = (knownHosts: KnownHost[]): KnownHost[] => {
});
return changed ? next : knownHosts;
};
/** Minimal host-key shape shared by terminal / SFTP / port-forward verification. */
export type HostKeyInfoLike = {
hostname: string;
port?: number;
keyType: string;
fingerprint: string;
publicKey?: string;
knownHostId?: string;
};
/**
* Create a KnownHost record from verified host-key info.
* Call sites may supply a fallback port (e.g. the SSH host's configured port).
*/
export const createKnownHostFromHostKeyInfo = (
hostKeyInfo: HostKeyInfoLike,
options?: {
defaultPort?: number;
now?: number;
idSuffix?: string;
},
): KnownHost => {
const now = options?.now ?? Date.now();
const idSuffix = options?.idSuffix ?? Math.random().toString(36).slice(2, 11);
return {
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || options?.defaultPort || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
};
};
+17 -34
View File
@@ -11,6 +11,13 @@ import type {
ConvergentSyncEnvelopeV2,
ConvergentSyncStateV2,
} from './convergentSync';
import {
BUILTIN_CLOUD_PROVIDERS,
isBuiltinCloudProvider,
providerConnectionStorageKey,
type BuiltinCloudProvider,
type CloudProviderId,
} from './cloudProviderIds';
export type {
ConvergentFieldConflict,
@@ -18,6 +25,16 @@ export type {
ConvergentSyncStateV2,
} from './convergentSync';
export {
BUILTIN_CLOUD_PROVIDERS,
isBuiltinCloudProvider,
providerConnectionStorageKey,
type BuiltinCloudProvider,
};
/** Built-in short IDs or namespaced plugin contribution IDs. */
export type CloudProvider = CloudProviderId;
// ============================================================================
// Security State Machine
// ============================================================================
@@ -54,25 +71,6 @@ export type ConflictResolution =
// Cloud Provider Types
// ============================================================================
/**
* Supported cloud storage providers.
* Built-in short IDs remain stable; plugin sync Providers use namespaced
* contribution IDs (e.g. com.example.backup.sync).
*/
export type BuiltinCloudProvider = 'github' | 'google' | 'onedrive' | 'webdav' | 's3';
export type CloudProvider = BuiltinCloudProvider | (string & {});
export const BUILTIN_CLOUD_PROVIDERS: readonly BuiltinCloudProvider[] = [
'github',
'google',
'onedrive',
'webdav',
's3',
] as const;
export const isBuiltinCloudProvider = (provider: string): provider is BuiltinCloudProvider =>
(BUILTIN_CLOUD_PROVIDERS as readonly string[]).includes(provider);
export type WebDAVAuthType = 'basic' | 'digest' | 'token';
export interface WebDAVConfig {
@@ -712,21 +710,6 @@ export const SYNC_STORAGE_KEYS = {
CONVERGENT_PROVIDER_BASELINE: 'netcatty_convergent_sync_provider_baseline_v2',
} as const;
/** Resolve the localStorage key used for a provider connection (built-in or plugin). */
export function providerConnectionStorageKey(provider: CloudProvider): string {
if (isBuiltinCloudProvider(provider)) {
const map: Record<BuiltinCloudProvider, string> = {
github: SYNC_STORAGE_KEYS.PROVIDER_GITHUB,
google: SYNC_STORAGE_KEYS.PROVIDER_GOOGLE,
onedrive: SYNC_STORAGE_KEYS.PROVIDER_ONEDRIVE,
webdav: SYNC_STORAGE_KEYS.PROVIDER_WEBDAV,
s3: SYNC_STORAGE_KEYS.PROVIDER_S3,
};
return map[provider];
}
return `netcatty_provider_plugin_v1:${provider}`;
}
// ============================================================================
// Constants
// ============================================================================
+17
View File
@@ -94,6 +94,23 @@ module.exports = {
'!node_modules/monaco-editor/**/*',
'!node_modules/react/**/*',
'!node_modules/react-dom/**/*',
'!node_modules/ai/**/*',
'!node_modules/@ai-sdk/**/*',
'!node_modules/@mdxeditor/**/*',
'!node_modules/streamdown/**/*',
'!node_modules/@streamdown/**/*',
'!node_modules/@tanstack/react-virtual/**/*',
'!node_modules/pinyin-pro/**/*',
'!node_modules/re2js/**/*',
'!node_modules/@eslint-community/regexpp/**/*',
'!node_modules/clsx/**/*',
'!node_modules/tailwind-merge/**/*',
'!node_modules/use-stick-to-bottom/**/*',
'!node_modules/lexical/**/*',
'!node_modules/@lexical/**/*',
'!node_modules/@codemirror/**/*',
'!node_modules/shiki/**/*',
'!node_modules/@shiki/**/*',
// Heavy cloud completion specs are intentionally not bundled. The main
// process filters the same prefixes so dev and packaged builds behave
// consistently.
+2 -1
View File
@@ -9,12 +9,13 @@ const fs = require("node:fs");
const path = require("node:path");
const { BaseAgent } = require("ssh2/lib/agent.js");
const { parseKey } = require("ssh2/lib/protocol/keyParser.js");
const tempDirBridge = require("./tempDirBridge.cjs");
const DEBUG_SSH = process.env.NETCATTY_SSH_DEBUG === "1";
// Debug logger (disabled by default)
const logFile = DEBUG_SSH
? path.join(require("os").tmpdir(), "netcatty-agent.log")
? tempDirBridge.getTempFilePath("netcatty-agent.log")
: null;
const log = (msg, data) => {
if (!DEBUG_SSH) return;
@@ -926,7 +926,7 @@ main();
*/
function cleanupStaleEtTempDirs() {
try {
const tempDir = tempDirBridge.getTempDir?.() || path.join(os.tmpdir(), "Netcatty");
const tempDir = tempDirBridge.getTempDir();
if (!fs.existsSync(tempDir)) return;
const entries = fs.readdirSync(tempDir);
for (const entry of entries) {
@@ -396,6 +396,14 @@ test('accepted steering keeps buffered results for existing tool calls on their
assert.equal(session.messages[3].content, '');
onEvent?.({ type: 'text-delta', textDelta: 'continued after steer' });
// Text deltas are rAF-batched like Catty; wait one frame before asserting.
await new Promise<void>((resolve) => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => resolve());
return;
}
setTimeout(resolve, 0);
});
assert.equal(session.messages[3].content, 'continued after steer');
onDone?.();
@@ -162,6 +162,59 @@ async function runExternalTurn(
maybeCreateAssistantMsg();
ui.updateMessageById(sessionId, activeAssistantMessageId, updater);
};
let pendingText = '';
let rafId: number | null = null;
const appendTextToActiveAssistant = (textChunk: string) => {
updateActiveAssistant(msg => ({
...msg,
content: msg.content + textChunk,
statusText: undefined,
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
}));
};
const flushPendingText = () => {
if (pendingText) {
const textChunk = pendingText;
pendingText = '';
runOrBufferUiOperation(() => appendTextToActiveAssistant(textChunk));
}
rafId = null;
};
const scheduleFrame = (cb: () => void): number => (
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(cb)
: setTimeout(cb, 0) as unknown as number
);
const cancelFrame = (id: number): void => {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(id);
return;
}
clearTimeout(id);
};
const cancelPendingTextFlush = () => {
if (rafId !== null) {
cancelFrame(rafId);
rafId = null;
}
};
const enqueueTextDelta = (textChunk: string) => {
if (!textChunk) return;
// While steering buffers UI ops, skip rAF so text enters the buffer immediately.
if (steerInFlight) {
runOrBufferUiOperation(() => appendTextToActiveAssistant(textChunk));
return;
}
pendingText += textChunk;
if (rafId === null) {
rafId = scheduleFrame(flushPendingText);
}
};
const flushTextBeforeNonTextEvent = () => {
cancelPendingTextFlush();
flushPendingText();
};
const toolNamesByCallId = new Map<string, string>();
const toolCallMessageIds = new Map<string, string>();
@@ -190,28 +243,29 @@ async function runExternalTurn(
updateActiveAssistant(msg => ({ ...msg, usage }));
};
const callbacks: SdkAgentCallbacks = {
onTextDelta: (text: string) => runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({
...msg,
content: msg.content + text,
statusText: undefined,
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
}));
}),
onThinkingDelta: (text: string) => runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({
...msg,
thinking: (msg.thinking || '') + text,
}));
}),
onThinkingDone: () => runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({
...msg,
thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
}));
}),
onTextDelta: (text: string) => {
enqueueTextDelta(text);
},
onThinkingDelta: (text: string) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({
...msg,
thinking: (msg.thinking || '') + text,
}));
});
},
onThinkingDone: () => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({
...msg,
thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
}));
});
},
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
const id = toolCallId || `tc_${Date.now()}`;
maybeCreateAssistantMsg();
@@ -226,6 +280,7 @@ async function runExternalTurn(
});
},
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
flushTextBeforeNonTextEvent();
const existingToolCallMessageId = toolCallMessageIds.get(toolCallId);
runOrBufferUiOperation(() => {
const effectiveToolName = toolName ?? toolNamesByCallId.get(toolCallId);
@@ -264,27 +319,47 @@ async function runExternalTurn(
flushBeforeSteerBoundary: existingToolCallMessageId !== undefined,
});
},
onFileChange: (activity) => runOrBufferUiOperation(() => updateActivity(activity)),
onWebSearch: (activity) => runOrBufferUiOperation(() => updateActivity(activity)),
onPlanUpdate: (activity) => runOrBufferUiOperation(() => updateActivity(activity)),
onWarning: (activity) => runOrBufferUiOperation(() => updateActivity(activity)),
onUsage: (usage: AgentUsage) => runOrBufferUiOperation(() => {
actualUsageReported = true;
updateUsage(usage);
}),
onStatus: (message: string) => runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
}),
onFileChange: (activity) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateActivity(activity));
},
onWebSearch: (activity) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateActivity(activity));
},
onPlanUpdate: (activity) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateActivity(activity));
},
onWarning: (activity) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateActivity(activity));
},
onUsage: (usage: AgentUsage) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
actualUsageReported = true;
updateUsage(usage);
});
},
onStatus: (message: string) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
});
},
onHook: (hookEvent: string, payload: Record<string, unknown>) => {
// Surface lifecycle hooks as status text so the user sees tool activity.
const toolName = (payload.toolName as string) || '';
if (hookEvent === 'PreToolUse' && toolName) {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({ ...msg, statusText: `Running ${toolName}…` }));
});
} else if (hookEvent === 'Notification') {
const message = (payload.message as string) || '';
if (message) {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => {
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
});
@@ -305,10 +380,13 @@ async function runExternalTurn(
context.updateExternalSessionId?.(sessionId, externalSessionId);
},
onError: (error: string) => {
flushTextBeforeNonTextEvent();
ui.reportStreamError(sessionId, signal, error);
ui.setStreamingForScope(sessionId, false);
},
onDone: () => {},
onDone: () => {
flushTextBeforeNonTextEvent();
},
};
const liveTurn: LiveExternalTurn = {
@@ -320,6 +398,8 @@ async function runExternalTurn(
async steer(steerInput) {
if (steerInFlight) return { status: 'busy' };
if (ended || signal.aborted) return { status: 'cancelled' };
// Commit any rAF-batched text before steering buffers UI ops.
flushTextBeforeNonTextEvent();
steerInFlight = true;
const result = await steerSdkAgentTurn(
netcattyBridge,
@@ -388,6 +468,7 @@ async function runExternalTurn(
const estimatedUsage = resolveEstimatedUsageFallback(trimmed, actualUsageReported);
if (estimatedUsage) {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateUsage(estimatedUsage));
ctx.emit({
id: `usage-${ctx.turnId}`,
@@ -401,6 +482,7 @@ async function runExternalTurn(
} finally {
ended = true;
liveTurn.ended = true;
flushTextBeforeNonTextEvent();
clearCodebuddyElicitationsForChat(sessionId);
if (steerInFlight) {
steerInFlight = false;
-34
View File
@@ -1,34 +0,0 @@
/**
* @deprecated Transfer channel pool no longer parks SSH connections.
* Keep-alive is owned by the main-process SSH transport registry
* (`sshTransportIdleTtl.ts` / settings "SSH connection keep-alive").
*
* These symbols remain for import stability in older tests/call sites.
*/
import {
DEFAULT_SSH_TRANSPORT_IDLE_TTL_MS,
SSH_TRANSPORT_IDLE_TTL_PRESETS_MS,
resolveSshTransportIdleTtlMs,
} from "./sshTransportIdleTtl.ts";
/** @deprecated Use DEFAULT_SSH_TRANSPORT_IDLE_TTL_MS */
export const DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS = DEFAULT_SSH_TRANSPORT_IDLE_TTL_MS;
/** @deprecated Use SSH_TRANSPORT_IDLE_TTL_PRESETS_MS */
export const SFTP_TRANSFER_POOL_IDLE_TTL_PRESETS_MS = SSH_TRANSPORT_IDLE_TTL_PRESETS_MS;
/** @deprecated */
export type SftpTransferPoolIdleTtlMs = (typeof SFTP_TRANSFER_POOL_IDLE_TTL_PRESETS_MS)[number];
/** @deprecated Use resolveSshTransportIdleTtlMs */
export function resolveSftpTransferPoolIdleTtlMs(
readStoredValue: () => number | null | undefined,
): number {
return resolveSshTransportIdleTtlMs(readStoredValue);
}
/** @deprecated Transfer channels close when idle; always treated as reclaim-enabled. */
export function isTransferPoolIdleReclaimDisabled(_idleTtlMs: number): boolean {
return false;
}
+4
View File
@@ -201,6 +201,10 @@ export const STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN = 'netcatty_ai_exter
export const STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES = 'netcatty_ai_session_idle_timeout_minutes_v1';
/** External MCP: whether host_open sessions stay hidden from the tab bar (default false). */
export const STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS = 'netcatty_ai_external_mcp_silent_sessions_v1';
/** AI panel diagnostic hide list (comma-separated part names). */
export const STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE = 'netcatty.aiDebug.hide';
/** AI panel React profiler toggle. */
export const STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE = 'netcatty.aiDebug.profile';
// SFTP Transfer Concurrency
export const STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY = 'netcatty_sftp_transfer_concurrency_v1';
+17 -19
View File
@@ -17,9 +17,7 @@
"@ai-sdk/anthropic": "^4.0.0",
"@ai-sdk/google": "^4.0.0",
"@ai-sdk/openai": "^4.0.0",
"@anthropic-ai/sdk": "0.111.0",
"@aws-sdk/client-s3": "^3.956.0",
"@connectrpc/connect": "^1.7.0",
"@eslint-community/regexpp": "4.12.2",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/mona-sans": "^5.2.8",
@@ -74,7 +72,6 @@
"streamdown": "^2.4.0",
"tailwind-merge": "3.4.0",
"use-stick-to-bottom": "^1.1.3",
"uuid": "^13.0.0",
"webdav": "^5.8.0",
"zmodem.js": "^0.1.10",
"zod": "^4.3.6"
@@ -366,6 +363,8 @@
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.111.0.tgz",
"integrity": "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
@@ -1596,6 +1595,7 @@
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)",
"optional": true,
"peer": true
},
"node_modules/@buttercup/fetch": {
@@ -2026,6 +2026,7 @@
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-1.7.0.tgz",
"integrity": "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==",
"license": "Apache-2.0",
"optional": true,
"peerDependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
@@ -7629,7 +7630,9 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
@@ -11605,7 +11608,9 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
"license": "Unlicense",
"optional": true,
"peer": true
},
"node_modules/fast-uri": {
"version": "3.1.0",
@@ -13117,6 +13122,8 @@
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
@@ -17821,6 +17828,8 @@
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
@@ -18321,7 +18330,9 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
@@ -18786,19 +18797,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/uvu": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
-3
View File
@@ -70,9 +70,7 @@
"@ai-sdk/anthropic": "^4.0.0",
"@ai-sdk/google": "^4.0.0",
"@ai-sdk/openai": "^4.0.0",
"@anthropic-ai/sdk": "0.111.0",
"@aws-sdk/client-s3": "^3.956.0",
"@connectrpc/connect": "^1.7.0",
"@eslint-community/regexpp": "4.12.2",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/mona-sans": "^5.2.8",
@@ -127,7 +125,6 @@
"streamdown": "^2.4.0",
"tailwind-merge": "3.4.0",
"use-stick-to-bottom": "^1.1.3",
"uuid": "^13.0.0",
"webdav": "^5.8.0",
"zmodem.js": "^0.1.10",
"zod": "^4.3.6"
+31
View File
@@ -128,6 +128,37 @@ test("build.files trims release-only dependency payloads", () => {
}
});
test("build.files excludes Vite-bundled renderer-only packages", () => {
const files = config.files;
for (const glob of [
"!node_modules/react/**/*",
"!node_modules/react-dom/**/*",
"!node_modules/@radix-ui/**/*",
"!node_modules/ai/**/*",
"!node_modules/@ai-sdk/**/*",
"!node_modules/@mdxeditor/**/*",
"!node_modules/streamdown/**/*",
"!node_modules/@streamdown/**/*",
"!node_modules/@tanstack/react-virtual/**/*",
"!node_modules/pinyin-pro/**/*",
"!node_modules/re2js/**/*",
"!node_modules/@eslint-community/regexpp/**/*",
"!node_modules/clsx/**/*",
"!node_modules/tailwind-merge/**/*",
"!node_modules/use-stick-to-bottom/**/*",
"!node_modules/lexical/**/*",
"!node_modules/@lexical/**/*",
"!node_modules/@codemirror/**/*",
"!node_modules/shiki/**/*",
"!node_modules/@shiki/**/*",
]) {
assert.ok(
files.includes(glob),
`build.files must exclude Vite-bundled renderer package: ${glob}`,
);
}
});
test("linux packaging uses multi-size build/icons instead of a single 1024px override", async () => {
assert.equal(
config.linux.icon,