Files
Netcatty/components/AIChatSidePanel.tsx
陈大猫 324253f23a [codex] Optimize terminal side panel performance
Optimize terminal side panel performance, keep process status labels visible, and improve heavy panel loading behavior.
2026-06-12 00:47:17 +08:00

1211 lines
42 KiB
TypeScript

import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import type {
AIDraft,
AIPanelView,
AgentModelPreset,
AISessionScope,
DiscoveredAgent,
} from '../infrastructure/ai/types';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { getAgentModelPresets } from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import {
getReadyUserSkillOptions,
getNextSelectedUserSkillSlugsMap,
type UserSkillOption,
} from './ai/userSkillsState';
import { subscribeUserSkillsStatusChanged } from './ai/userSkillsStatusEvents';
import {
applyDraftEntrySelection,
applyHistorySessionSelection,
panelViewsEqual,
resolveDisplayedPanelView,
resolveDisplayedSession,
} from './ai/aiPanelViewState';
import {
endDraftSend,
tryBeginDraftSend,
} from './ai/draftSendGate';
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
import {
buildPromptWithTerminalSelectionAttachments,
isTerminalSelectionAttachment,
} from '../application/state/terminalSelectionAttachment';
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import {
useAIChatStreaming,
getNetcattyBridge,
isAIChatSessionStreaming,
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import { generateId, modelPresetsContainId, shouldLoadSdkRuntimeModels } from './AIChatSidePanelHelpers';
import { AIChatPanelContent } from './AIChatPanelContent';
import {
getAIPanelProfilerProps,
profileAIPanelCalculation,
} from './ai/aiPanelDiagnostics';
type UserSkillsStatusResult = { ok: boolean; skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}> } | null;
type UserSkillsStatusLoadResult = UserSkillsStatusResult | undefined;
const USER_SKILLS_STATUS_CACHE_TTL_MS = 60_000;
let userSkillsStatusCache: {
version: number;
result: UserSkillsStatusResult;
updatedAt: number;
} | null = null;
let userSkillsStatusPromise: {
version: number;
promise: Promise<UserSkillsStatusLoadResult>;
} | null = null;
let userSkillsStatusCacheVersion = 0;
function invalidateUserSkillsStatusCache() {
userSkillsStatusCacheVersion += 1;
userSkillsStatusCache = null;
userSkillsStatusPromise = null;
}
if (typeof window !== 'undefined') {
subscribeUserSkillsStatusChanged(invalidateUserSkillsStatusCache);
}
function loadUserSkillsStatus(
bridge: ReturnType<typeof getNetcattyBridge>,
): Promise<UserSkillsStatusLoadResult> {
const requestVersion = userSkillsStatusCacheVersion;
if (!bridge?.aiUserSkillsGetStatus) {
userSkillsStatusCache = { version: requestVersion, result: null, updatedAt: Date.now() };
return Promise.resolve(null);
}
if (
userSkillsStatusCache
&& userSkillsStatusCache.version === requestVersion
&& Date.now() - userSkillsStatusCache.updatedAt < USER_SKILLS_STATUS_CACHE_TTL_MS
) {
return Promise.resolve(userSkillsStatusCache.result);
}
if (!userSkillsStatusPromise || userSkillsStatusPromise.version !== requestVersion) {
const promise = bridge.aiUserSkillsGetStatus()
.then((result) => {
if (userSkillsStatusCacheVersion !== requestVersion) return undefined;
userSkillsStatusCache = { version: requestVersion, result, updatedAt: Date.now() };
return result;
})
.catch(() => {
if (userSkillsStatusCacheVersion !== requestVersion) return undefined;
userSkillsStatusCache = { version: requestVersion, result: null, updatedAt: Date.now() };
return null;
})
.finally(() => {
if (userSkillsStatusPromise?.version === requestVersion) {
userSkillsStatusPromise = null;
}
});
userSkillsStatusPromise = { version: requestVersion, promise };
}
return userSkillsStatusPromise.promise;
}
export function hasAIChatSidePanelRetainedContent(props: Pick<
AIChatSidePanelProps,
'activeSessionIdMap' | 'draftsByScope' | 'sessions' | 'scopeTargetId' | 'scopeType'
>): boolean {
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
const activeSession = sessionId
? props.sessions.find((session) => session.id === sessionId)
: null;
if (activeSession && activeSession.messages.length > 0) {
return true;
}
const draft = props.draftsByScope[scopeKey] ?? null;
return Boolean(
draft
&& (
draft.text.trim().length > 0
|| draft.attachments.length > 0
|| draft.selectedUserSkillSlugs.length > 0
),
);
}
export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): boolean {
if (props.isVisible ?? true) {
return true;
}
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
if (hasAIChatSidePanelRetainedContent(props)) {
return true;
}
return isAIChatSessionStreaming(sessionId);
}
function shouldDelayAIChatSidePanelActivation(props: AIChatSidePanelProps): boolean {
if (!(props.isVisible ?? true)) return false;
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
if (isAIChatSessionStreaming(sessionId)) return false;
return !hasAIChatSidePanelRetainedContent(props);
}
function schedulePanelActivation(callback: () => void): () => void {
let timeoutId: number | null = null;
if (typeof requestAnimationFrame === 'function') {
const rafId = requestAnimationFrame(() => {
timeoutId = window.setTimeout(callback, 0);
});
return () => {
cancelAnimationFrame(rafId);
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}
timeoutId = window.setTimeout(callback, 0);
return () => {
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}
const AIChatSidePanelPreparing = React.memo(function AIChatSidePanelPreparing() {
const { t } = useI18n();
return (
<div className="flex h-full flex-col bg-background" data-section="ai-chat-panel-preparing">
<div className="shrink-0 border-b border-border/50 px-2.5 py-1.5">
<div className="h-8 w-36 rounded-md bg-muted/45" />
</div>
<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" />
{t('ai.chat.preparing')}
</div>
</div>
</div>
);
});
const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
setActiveSessionId: setActiveSessionIdForScope,
ensureDraftForScope,
updateDraft,
showDraftView,
showSessionView,
clearDraftForScope,
addDraftFiles,
removeDraftFile,
createSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,
providers,
activeProviderId,
activeModelId,
defaultAgentId,
toolIntegrationMode,
externalAgents,
setExternalAgents,
agentModelMap,
setAgentModel,
agentProviderMap,
setAgentProvider,
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
maxIterations = 20,
webSearchConfig,
quickMessages = [],
scopeType,
scopeTargetId,
scopeHostIds,
scopeLabel,
terminalSessions = [],
resolveExecutorContext,
isVisible = true,
}) => {
const { t } = useI18n();
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
const [showHistory, setShowHistory] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
const [userSkillsStatusVersion, setUserSkillsStatusVersion] = useState(0);
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
terminalSessionsRef.current = terminalSessions;
const resolveExecutorContextRef = useRef(resolveExecutorContext);
resolveExecutorContextRef.current = resolveExecutorContext;
const {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
} = useAIChatStreaming({
maxIterations,
addMessageToSession,
updateLastMessage,
updateMessageById,
});
const setActiveSessionId = useCallback((id: string | null) => {
setActiveSessionIdForScope(scopeKey, id);
}, [scopeKey, setActiveSessionIdForScope]);
const activeTerminalSessionIds = useMemo(() => {
const sessionIds = new Set<string>();
const entries = Object.entries(activeSessionIdMap) as Array<[string, string | null]>;
for (const [sessionScopeKey, sessionId] of entries) {
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
if (sessionScopeKey === scopeKey) continue;
sessionIds.add(sessionId);
}
return sessionIds;
}, [activeSessionIdMap, scopeKey]);
const deferredSessions = useDeferredValue(sessions);
const historySessions = useMemo(
() => profileAIPanelCalculation(
'AIChatSidePanel.historySessions',
() => getScopedHistorySessions(
deferredSessions,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
),
),
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
);
const explicitPanelView = panelViewByScope[scopeKey];
const currentDraft = draftsByScope[scopeKey] ?? null;
const persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
const normalizedPanelView = useMemo<AIPanelView>(
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
);
const activeSession = useMemo(
() => resolveDisplayedSession(normalizedPanelView, historySessions),
[normalizedPanelView, historySessions],
);
const activeSessionId = normalizedPanelView.mode === 'session' ? normalizedPanelView.sessionId : null;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const currentAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? defaultAgentId;
const inputValue = currentDraft?.text ?? '';
const files = currentDraft?.attachments ?? [];
const panelViewRef = useRef(normalizedPanelView);
panelViewRef.current = normalizedPanelView;
const currentDraftRef = useRef(currentDraft);
currentDraftRef.current = currentDraft;
const activeSessionRef = useRef(activeSession);
activeSessionRef.current = activeSession;
const draftSendInFlightRef = useRef(false);
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
if (scopeType === 'terminal' && scopeTargetId) {
const target = terminalSessions.find((session) => session.sessionId === scopeTargetId);
if (target) {
return {
...target,
source: 'scope-target',
};
}
}
if (connectedSessions.length === 1) {
return {
...connectedSessions[0],
source: 'only-connected-in-scope',
};
}
return undefined;
}, [terminalSessions, scopeType, scopeTargetId]);
useEffect(() => {
if (!isVisible) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiMcpUpdateSessions) return;
const timeoutId = window.setTimeout(() => {
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
}, 250);
return () => {
window.clearTimeout(timeoutId);
};
}, [isVisible, terminalSessions, activeSessionId]);
useEffect(() => {
if (!isVisible) return;
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) return;
showDraftView(scopeKey);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
useEffect(() => {
if (!activeSession) return;
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
}, [
activeSession,
activeSessionIdMap,
scopeKey,
isVisible,
setActiveSessionId,
]);
useEffect(() => {
if (!isVisible) return;
if (normalizedPanelView.mode !== 'draft') return;
if (persistedSessionId == null) return;
setActiveSessionId(null);
}, [isVisible, normalizedPanelView.mode, persistedSessionId, setActiveSessionId]);
const ensureScopeDraft = useCallback((agentId: string) => {
ensureDraftForScope(scopeKey, agentId);
}, [ensureDraftForScope, scopeKey]);
const updateScopeDraft = useCallback((
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
) => {
updateDraft(scopeKey, fallbackAgentId, updater);
}, [scopeKey, updateDraft]);
const showScopeDraftView = useCallback(() => {
showDraftView(scopeKey);
}, [scopeKey, showDraftView]);
const showScopeSessionView = useCallback((sessionId: string) => {
showSessionView(scopeKey, sessionId);
}, [scopeKey, showSessionView]);
const clearScopeDraft = useCallback(() => {
clearDraftForScope(scopeKey);
}, [clearDraftForScope, scopeKey]);
const enterScopeDraftMode = useCallback((agentId: string, preserveSessionView = false) => {
applyDraftEntrySelection({
ensureDraft: () => ensureScopeDraft(agentId),
showDraftView: showScopeDraftView,
preserveSessionView,
});
}, [ensureScopeDraft, showScopeDraftView]);
const setInputValue = useCallback((value: string) => {
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateScopeDraft(currentAgentId, (draft) => ({
...draft,
text: value,
}));
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
const addFiles = useCallback(async (inputFiles: File[]) => {
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
await addDraftFiles(scopeKey, currentAgentId, inputFiles);
}, [addDraftFiles, currentAgentId, enterScopeDraftMode, scopeKey]);
const removeFile = useCallback((fileId: string) => {
removeDraftFile(scopeKey, currentAgentId, fileId);
}, [removeDraftFile, scopeKey, currentAgentId]);
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
const applyUserSkillsStatus = (result: { ok: boolean; skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}> } | null | undefined) => {
const nextOptions = getReadyUserSkillOptions(result);
setUserSkillOptions(nextOptions);
const draft = currentDraftRef.current;
if (!draft) {
return;
}
const nextSelectedUserSkillSlugs =
getNextSelectedUserSkillSlugsMap(
{ [scopeKey]: draft.selectedUserSkillSlugs },
result,
)[scopeKey] ?? [];
const selectedUserSkillsChanged =
nextSelectedUserSkillSlugs.length !== draft.selectedUserSkillSlugs.length
|| nextSelectedUserSkillSlugs.some((slug, index) => slug !== draft.selectedUserSkillSlugs[index]);
if (!selectedUserSkillsChanged) {
return;
}
updateScopeDraft(draft.agentId, (currentScopeDraft) => ({
...currentScopeDraft,
selectedUserSkillSlugs: nextSelectedUserSkillSlugs,
}));
};
const bridge = getNetcattyBridge();
void loadUserSkillsStatus(bridge)
.then((result) => {
if (cancelled) return;
if (result === undefined) return;
applyUserSkillsStatus(result);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft, userSkillsStatusVersion]);
useEffect(() => {
const handleUserSkillsChanged = () => {
setUserSkillsStatusVersion((version) => version + 1);
};
return subscribeUserSkillsStatusChanged(handleUserSkillsChanged);
}, []);
useEffect(() => {
if (!isVisible) return;
const bridge = getNetcattyBridge();
if (bridge?.aiSyncProviders && providers.length > 0) {
void bridge.aiSyncProviders(providers);
}
}, [isVisible, providers]);
useEffect(() => {
if (!isVisible) return;
const bridge = getNetcattyBridge();
if (bridge?.aiSyncWebSearch) {
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
}, [isVisible, webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
const {
discoveredAgents,
isDiscovering,
rediscover,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents, { enabled: isVisible });
const handleEnableDiscoveredAgent = useCallback(
(agent: DiscoveredAgent) => {
const config = enableAgent(agent);
setExternalAgents?.((prev) => [...prev, config]);
},
[enableAgent, setExternalAgents],
);
const messages = activeSession?.messages ?? [];
const selectedUserSkillSlugs = useMemo(
() => currentDraft?.selectedUserSkillSlugs ?? [],
[currentDraft],
);
const selectedUserSkills = useMemo(
() =>
selectedUserSkillSlugs.map((slug) => {
const option = userSkillOptions.find((skill) => skill.slug === slug);
return option ?? { id: slug, slug, name: slug, description: '' };
}),
[selectedUserSkillSlugs, userSkillOptions],
);
const { handleExport } = useConversationExport(activeSession);
const activeProvider = useMemo(
() => providers.find((p) => p.id === activeProviderId),
[providers, activeProviderId],
);
const cattyAgentProvider = useMemo(() => {
const overrideId = agentProviderMap['catty'];
if (overrideId) {
const p = providers.find((cfg) => cfg.id === overrideId);
if (p) return p;
}
return activeProvider;
}, [agentProviderMap, providers, activeProvider]);
const cattyAgentModelId = useMemo(() => {
const trim = (s: string | undefined | null): string => (s ?? '').trim();
const overrideId = agentProviderMap['catty'];
const overrideProvider = overrideId
? providers.find((cfg) => cfg.id === overrideId)
: undefined;
if (overrideProvider) {
return trim(agentModelMap['catty']) || trim(overrideProvider.defaultModel);
}
return trim(cattyAgentProvider?.defaultModel) || trim(activeModelId);
}, [agentModelMap, agentProviderMap, providers, cattyAgentProvider, activeModelId]);
const effectiveActiveProvider = currentAgentId === 'catty' ? cattyAgentProvider : activeProvider;
const effectiveActiveModelId = currentAgentId === 'catty' ? cattyAgentModelId : activeModelId;
const cattyConfiguredProviders = useMemo(
() => (currentAgentId === 'catty' ? providers : []),
[currentAgentId, providers],
);
const handleAgentProviderModelSelect = useCallback(
(providerId: string, modelId: string) => {
setAgentProvider(currentAgentId, providerId);
setAgentModel(currentAgentId, modelId);
},
[currentAgentId, setAgentProvider, setAgentModel],
);
const providerDisplayName = effectiveActiveProvider?.name ?? '';
const modelDisplayName = effectiveActiveModelId || effectiveActiveProvider?.defaultModel || '';
const currentAgentConfig = useMemo(
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
[currentAgentId, externalAgents],
);
const isCodexManagedAgent = useMemo(
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
[currentAgentConfig],
);
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
useEffect(() => {
if (!isVisible) return;
setCodexCustomConfigResolved(false);
if (!isCodexManagedAgent) {
setCodexConfigModel(null);
return;
}
const bridge = getNetcattyBridge();
if (!bridge?.aiCodexGetIntegration) return;
let cancelled = false;
void Promise.resolve(
bridge.aiCodexGetIntegration() as Promise<CodexIntegrationStatus>,
).then((info) => {
if (cancelled) return;
const hasCustom = info?.state === 'connected_custom_config';
setCodexConfigModel(info?.customConfig?.model ?? null);
setCodexCustomConfigResolved(hasCustom);
}).catch(() => {
if (!cancelled) {
setCodexConfigModel(null);
setCodexCustomConfigResolved(false);
}
});
return () => { cancelled = true; };
}, [isVisible, isCodexManagedAgent, currentAgentId]);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!isVisible) return;
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
if (!sdkBackend) return;
if (!shouldLoadSdkRuntimeModels(currentAgentConfig) && !isCodexManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiSdkAgentListModels) return;
let cancelled = false;
void bridge.aiSdkAgentListModels(
sdkBackend,
undefined,
undefined,
`models_${currentAgentId}`,
currentAgentConfig.env,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
if (result.models.length === 0) {
setRuntimeAgentModelPresets((prev) => {
if (!(currentAgentId in prev)) return prev;
const { [currentAgentId]: _removed, ...rest } = prev;
return rest;
});
return;
}
const runtimePresets = result.models ?? [];
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: runtimePresets,
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !modelPresetsContainId(runtimePresets, storedModelId))) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
if (!cancelled) {
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
}
});
return () => {
cancelled = true;
};
}, [isVisible, currentAgentConfig, currentAgentId, isCodexManagedAgent, setAgentModel]);
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
if (hasCodexCustomConfig) {
if (runtimePresets) {
return runtimePresets;
}
if (codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
return [];
}
return runtimePresets ?? getAgentModelPresets(currentAgentConfig?.command);
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
const selectedAgentModel = useMemo(() => {
const stored = agentModelMap[currentAgentId];
if (stored && modelPresetsContainId(agentModelPresets, stored)) {
return stored;
}
if (agentModelPresets.length > 0) {
const first = agentModelPresets[0];
if (first.thinkingLevels?.length) {
return `${first.id}/${first.thinkingLevels[first.thinkingLevels.length - 1]}`;
}
return first.id;
}
return undefined;
}, [currentAgentId, agentModelMap, agentModelPresets]);
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
const handleNewChat = useCallback(() => {
clearScopeDraft();
updateScopeDraft(currentAgentId, () => ({
text: '',
agentId: currentAgentId,
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: Date.now(),
}));
showScopeDraftView();
setShowHistory(false);
}, [clearScopeDraft, currentAgentId, showScopeDraftView, updateScopeDraft]);
const handleOpenSettings = useCallback(() => {
void openSettingsWindow();
}, [openSettingsWindow]);
/** Ref to always access latest sessions (avoids stale closure in autoTitleSession). */
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
/** Auto-title a session from the first user message if untitled. */
const autoTitleSession = useCallback((sessionId: string, text: string) => {
const s = sessionsRef.current.find(x => x.id === sessionId);
if (s && (!s.title || s.title === 'New Chat')) {
updateSessionTitle(sessionId, text.length > 50 ? text.slice(0, 50) + '...' : text);
}
}, [updateSessionTitle]);
const buildExecutorContextForScope = useCallback((scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}): ExecutorContext => {
const resolved = resolveExecutorContextRef.current?.(scope);
if (resolved) return resolved;
return {
sessions: terminalSessionsRef.current,
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName: scope.type === 'workspace' ? scope.label : undefined,
};
}, []);
const addSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateScopeDraft(currentAgentId, (draft) => {
if (draft.selectedUserSkillSlugs.includes(normalizedSlug)) {
return draft;
}
return {
...draft,
selectedUserSkillSlugs: [...draft.selectedUserSkillSlugs, normalizedSlug],
};
});
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
const removeSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateScopeDraft(currentAgentId, (draft) => {
const nextSelectedUserSkillSlugs = draft.selectedUserSkillSlugs.filter(
(entry) => entry !== normalizedSlug,
);
if (nextSelectedUserSkillSlugs.length === draft.selectedUserSkillSlugs.length) {
return draft;
}
return {
...draft,
selectedUserSkillSlugs: nextSelectedUserSkillSlugs,
};
});
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
const handleSend = useCallback(async () => {
const draft = currentDraftRef.current;
const currentPanelView = panelViewRef.current;
const currentSessionView = activeSessionRef.current;
const trimmed = draft?.text.trim() ?? '';
const sendScopeKey = scopeKey;
const attachments = (draft?.attachments ?? []).map((file) => ({
base64Data: file.base64Data,
mediaType: file.mediaType,
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
const isDraftMode = currentPanelView.mode === 'draft';
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
return;
}
try {
let sessionId = currentSessionView?.id ?? null;
let currentSession = currentSessionView ?? null;
if (isDraftMode) {
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const createdSession = createSession(scope, sendAgentId);
sessionId = createdSession.id;
currentSession = createdSession;
clearScopeDraft();
showScopeSessionView(createdSession.id);
setActiveSessionId(createdSession.id);
}
if (!sessionId) {
return;
}
const isExternalAgent = sendAgentId !== 'catty';
const sendActiveProvider = isExternalAgent ? activeProvider : effectiveActiveProvider;
const sendActiveModelId = isExternalAgent ? activeModelId : effectiveActiveModelId;
if (!isExternalAgent && !sendActiveProvider) {
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
clearScopeDraft();
showScopeSessionView(sessionId);
}
return;
}
if (!isExternalAgent && !sendActiveModelId.trim()) {
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
clearScopeDraft();
showScopeSessionView(sessionId);
}
return;
}
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
clearScopeDraft();
showScopeSessionView(sessionId);
setActiveSessionId(sessionId);
setStreamingForScope(sessionId, true);
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent
? (selectedAgentModel || agentConfig?.name || 'external')
: (sendActiveModelId || sendActiveProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : sendActiveProvider?.providerId,
});
const abortController = new AbortController();
abortControllersRef.current.set(sessionId, abortController);
currentSession = currentSession ?? sessionsRef.current.find((session) => session.id === sessionId) ?? null;
if (isExternalAgent) {
if (!agentConfig) {
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
setStreamingForScope(sessionId, false);
return;
}
try {
const existingExternalSessionId = currentSession?.externalSessionId;
await sendToExternalAgent(sessionId, modelPrompt, agentConfig, abortController, modelAttachments, {
existingSessionId: existingExternalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
terminalSessions,
defaultTargetSession,
providers,
selectedAgentModel,
toolIntegrationMode,
selectedUserSkillSlugs: selectedSkillSlugs,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
}
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
} else {
const toolScope = {
type: scopeType,
targetId: scopeTargetId,
label: scopeLabel,
} as const;
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider: sendActiveProvider,
activeModelId: sendActiveModelId,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
titleText: trimmed,
}, modelAttachments.length > 0 ? modelAttachments : undefined);
}
} finally {
if (isDraftMode) {
endDraftSend(draftSendInFlightRef);
}
}
}, [
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeHostIds, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
toolIntegrationMode,
clearScopeDraft, showScopeSessionView, setActiveSessionId,
]);
const stopStreamingForSession = useCallback((sessionId: string) => {
const controller = abortControllersRef.current.get(sessionId);
controller?.abort();
abortControllersRef.current.delete(sessionId);
setStreamingForScope(sessionId, false);
updateLastMessage(sessionId, (msg) => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
}));
clearAllPendingApprovals(sessionId);
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(sessionId);
bridge?.aiSdkAgentCancel?.('', sessionId);
}, [setStreamingForScope, updateLastMessage, abortControllersRef]);
const handleStop = useCallback(() => {
if (!activeSessionId) return;
stopStreamingForSession(activeSessionId);
}, [activeSessionId, stopStreamingForSession]);
const handleSelectSession = useCallback(
(sessionId: string) => {
applyHistorySessionSelection(sessionId, {
showSessionView: showScopeSessionView,
setActiveSessionId,
closeHistory: () => setShowHistory(false),
});
},
[setActiveSessionId, showScopeSessionView],
);
const handleDeleteSession = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
const deletingActiveSession =
activeSessionId === sessionId
|| persistedSessionId === sessionId
|| (
explicitPanelView?.mode === 'session'
&& explicitPanelView.sessionId === sessionId
);
const deletingLastScopedSession =
historySessions.length === 1 && historySessions[0]?.id === sessionId;
const deletedSessionAgentId =
historySessions.find((session) => session.id === sessionId)?.agentId
?? currentAgentId;
if (abortControllersRef.current.has(sessionId) || streamingSessionIds.has(sessionId)) {
stopStreamingForSession(sessionId);
}
deleteSession(sessionId, scopeKey);
if (deletingActiveSession || deletingLastScopedSession) {
setShowHistory(false);
ensureScopeDraft(deletedSessionAgentId);
}
},
[
activeSessionId,
abortControllersRef,
currentAgentId,
deleteSession,
ensureScopeDraft,
explicitPanelView,
historySessions,
persistedSessionId,
scopeKey,
stopStreamingForSession,
streamingSessionIds,
],
);
const handleAgentChange = useCallback((agentId: string) => {
showScopeDraftView();
ensureScopeDraft(agentId);
updateScopeDraft(agentId, (draft) => ({
...selectDraftForAgentSwitch(
draft,
agentId,
Boolean(activeSessionRef.current?.messages.length),
),
}));
setShowHistory(false);
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
return (
<React.Profiler {...getAIPanelProfilerProps('AIChatSidePanel.Active')}>
<AIChatPanelContent
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
handleAgentChange={handleAgentChange}
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
rediscover={rediscover}
handleOpenSettings={handleOpenSettings}
activeSession={activeSession}
handleExport={handleExport}
showHistory={showHistory}
setShowHistory={setShowHistory}
handleNewChat={handleNewChat}
historySessions={historySessions}
activeSessionId={activeSessionId}
handleSelectSession={handleSelectSession}
handleDeleteSession={handleDeleteSession}
messages={messages}
isStreaming={isStreaming}
inputValue={inputValue}
setInputValue={setInputValue}
handleSend={handleSend}
handleStop={handleStop}
canSendCurrentAgent={canSendCurrentAgent}
providerDisplayName={providerDisplayName}
modelDisplayName={modelDisplayName}
agentModelPresets={agentModelPresets}
selectedAgentModel={selectedAgentModel}
handleAgentModelSelect={handleAgentModelSelect}
cattyConfiguredProviders={cattyConfiguredProviders}
effectiveActiveProvider={effectiveActiveProvider}
effectiveActiveModelId={effectiveActiveModelId}
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
files={files}
addFiles={addFiles}
removeFile={removeFile}
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
quickMessages={quickMessages}
addSelectedUserSkill={addSelectedUserSkill}
removeSelectedUserSkill={removeSelectedUserSkill}
globalPermissionMode={globalPermissionMode}
setGlobalPermissionMode={setGlobalPermissionMode}
/>
</React.Profiler>
);
};
const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'sessions',
'activeSessionIdMap',
'draftsByScope',
'panelViewByScope',
'setActiveSessionId',
'ensureDraftForScope',
'updateDraft',
'showDraftView',
'showSessionView',
'clearDraftForScope',
'addDraftFiles',
'removeDraftFile',
'createSession',
'deleteSession',
'updateSessionTitle',
'updateSessionExternalSessionId',
'addMessageToSession',
'updateLastMessage',
'updateMessageById',
'providers',
'activeProviderId',
'activeModelId',
'defaultAgentId',
'toolIntegrationMode',
'externalAgents',
'setExternalAgents',
'agentModelMap',
'setAgentModel',
'agentProviderMap',
'setAgentProvider',
'globalPermissionMode',
'setGlobalPermissionMode',
'commandBlocklist',
'maxIterations',
'webSearchConfig',
'quickMessages',
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
export function aiChatSidePanelPropsAreEqual(
prev: AIChatSidePanelProps,
next: AIChatSidePanelProps,
): boolean {
const prevKeep = shouldKeepAIChatSidePanelMounted(prev);
const nextKeep = shouldKeepAIChatSidePanelMounted(next);
if (!prevKeep && !nextKeep) {
return true;
}
if (prevKeep !== nextKeep) {
return false;
}
if (prev.scopeType !== next.scopeType) return false;
if (prev.scopeTargetId !== next.scopeTargetId) return false;
if (prev.scopeLabel !== next.scopeLabel) return false;
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
if (prev.scopeHostIds !== next.scopeHostIds) return false;
if (prev.terminalSessions !== next.terminalSessions) return false;
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
if (prev[key] !== next[key]) return false;
}
return true;
}
const AIChatSidePanel = React.memo(function AIChatSidePanel(props: AIChatSidePanelProps) {
const shouldKeepMounted = shouldKeepAIChatSidePanelMounted(props);
const shouldDelayActivation = shouldKeepMounted && shouldDelayAIChatSidePanelActivation(props);
const activationKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const [activationReady, setActivationReady] = useState(!shouldDelayActivation);
useEffect(() => {
if (!shouldDelayActivation) {
setActivationReady(true);
return undefined;
}
setActivationReady(false);
return schedulePanelActivation(() => setActivationReady(true));
}, [activationKey, shouldDelayActivation]);
if (!shouldKeepMounted) return null;
if (shouldDelayActivation && !activationReady) {
return <AIChatSidePanelPreparing />;
}
// Keep hidden panels alive only when they contain real work (messages, draft
// content, or an active stream). Empty hidden panels can drop their heavy
// input/agent-picker subtree and remount cheaply when shown again.
return <AIChatSidePanelActive {...props} />;
}, aiChatSidePanelPropsAreEqual);
AIChatSidePanel.displayName = 'AIChatSidePanel';
export default AIChatSidePanel;
export { AIChatSidePanel };
export type { AIChatSidePanelProps };