Optimize terminal side panel performance, keep process status labels visible, and improve heavy panel loading behavior.
1211 lines
42 KiB
TypeScript
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 };
|