Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1985 lines
72 KiB
TypeScript
1985 lines
72 KiB
TypeScript
|
|
|
|
import React, { useCallback, useEffect, 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,
|
|
UploadedFile,
|
|
DiscoveredAgent,
|
|
ExternalAgentConfig,
|
|
} from '../infrastructure/ai/types';
|
|
import type { VaultNote } from '../domain/models';
|
|
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
|
import {
|
|
filterAgentModelPresetsForCliVersion,
|
|
getAgentModelPresets,
|
|
resolveAgentCliVersion,
|
|
resolveAgentModelSelection,
|
|
} from '../infrastructure/ai/types';
|
|
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
|
import { toast } from './ui/toast';
|
|
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
|
import {
|
|
getReadyUserSkillOptions,
|
|
getNextSelectedUserSkillSlugsMap,
|
|
type UserSkillOption,
|
|
} from './ai/userSkillsState';
|
|
import { subscribeUserSkillsStatusChanged } from './ai/userSkillsStatusEvents';
|
|
import {
|
|
applyDraftEntrySelection,
|
|
applyHistorySessionSelection,
|
|
resolveDisplayedPanelView,
|
|
resolveDisplayedSession,
|
|
shouldForceDraftViewSync,
|
|
} from './ai/aiPanelViewState';
|
|
import {
|
|
endSendForKey,
|
|
tryBeginSendForKey,
|
|
} from './ai/draftSendGate';
|
|
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
|
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
|
|
import {
|
|
buildPromptWithTerminalSelectionAttachments,
|
|
isInlineTextAttachment,
|
|
} from '../application/state/terminalSelectionAttachment';
|
|
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
|
|
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
|
import {
|
|
useAIChatStreaming,
|
|
getNetcattyBridge,
|
|
isAIChatSessionStreaming,
|
|
type DefaultTargetSessionHint,
|
|
} from '../application/state/useAIChatStreaming';
|
|
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
|
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
|
|
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
|
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
|
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
|
import { registerGrantPersister } from '../infrastructure/ai/shared/approvalGate';
|
|
import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
|
|
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
|
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
|
|
import { useConversationExport } from './ai/hooks/useConversationExport';
|
|
import { useAgentContextUsage } from '../application/state/useAgentCompactionUi';
|
|
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
|
import {
|
|
buildCursorListModelsAgentEnv,
|
|
buildSdkRuntimeModelCacheKey,
|
|
sdkRuntimeModelCache,
|
|
generateId,
|
|
agentModelPresetsShallowEqual,
|
|
mergeFallbackThinkingLevels,
|
|
normalizeStoredAgentModelSelection,
|
|
normalizeSdkRuntimeModelPresets,
|
|
shouldAdoptSdkCurrentModel,
|
|
shouldLoadSdkRuntimeModels,
|
|
shouldUseStoredAgentModel,
|
|
type SdkRuntimeModelCatalog,
|
|
} from './AIChatSidePanelHelpers';
|
|
import { AIChatPanelContent } from './AIChatPanelContent';
|
|
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
|
import {
|
|
getAIPanelProfilerProps,
|
|
profileAIPanelCalculation,
|
|
} from './ai/aiPanelDiagnostics';
|
|
import { scheduleWhenAiComposerIdle, warmAiMarkdownRenderer } from './ai/aiMarkdownWarmup';
|
|
|
|
type UserSkillsStatusResult = { ok: boolean; skills?: Array<{
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
description: string;
|
|
status: 'ready' | 'warning';
|
|
}> } | null;
|
|
type UserSkillsStatusLoadResult = UserSkillsStatusResult | undefined;
|
|
type SdkRuntimeModelTarget = {
|
|
agentId: string;
|
|
cacheKey: string;
|
|
sdkBackend: string;
|
|
agentEnv?: Record<string, string>;
|
|
agentCommand?: string;
|
|
codexRuntime?: 'sdk' | 'app-server';
|
|
};
|
|
type SteerWarning = {
|
|
reason: 'not-steerable' | 'busy' | 'inactive' | 'unsupported' | 'cancelled' | 'failed';
|
|
turnKind?: 'review' | 'compact';
|
|
};
|
|
|
|
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 {
|
|
// Empty-panel activation delay used to land in the expand → first-type window.
|
|
return false;
|
|
}
|
|
|
|
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={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 px-2 flex items-center`}>
|
|
<div className="h-6 w-32 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,
|
|
persistContextCompaction,
|
|
providers,
|
|
activeProviderId,
|
|
activeModelId,
|
|
defaultAgentId,
|
|
toolIntegrationMode,
|
|
externalAgents,
|
|
setExternalAgents,
|
|
agentModelMap,
|
|
setAgentModel,
|
|
agentProviderMap,
|
|
setAgentProvider,
|
|
agentThinkingMap,
|
|
setAgentThinking,
|
|
updateProvider,
|
|
globalPermissionMode,
|
|
setGlobalPermissionMode,
|
|
commandBlocklist,
|
|
commandTimeout,
|
|
responseIdleTimeout,
|
|
maxIterations = 20,
|
|
webSearchConfig,
|
|
quickMessages = [],
|
|
scopeType,
|
|
scopeTargetId,
|
|
scopeHostIds,
|
|
scopeLabel,
|
|
focusedSessionId,
|
|
terminalSessions = [],
|
|
resolveExecutorContext,
|
|
isVisible = true,
|
|
notes = [],
|
|
hosts = [],
|
|
snippets = [],
|
|
onOpenVaultNote,
|
|
onOpenVaultHost,
|
|
onOpenVaultSnippet,
|
|
onOpenVaultSection,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
|
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
|
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, string>>({});
|
|
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
|
|
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
|
|
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,
|
|
steerExternalAgent,
|
|
reportStreamError,
|
|
activeCompaction,
|
|
} = useAIChatStreaming({
|
|
maxIterations,
|
|
addMessageToSession,
|
|
updateLastMessage,
|
|
updateMessageById,
|
|
persistContextCompaction,
|
|
});
|
|
|
|
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 workspaceMemberTerminalIds = useMemo(() => {
|
|
if (scopeType !== 'workspace') return undefined;
|
|
return new Set(
|
|
terminalSessions
|
|
.map((session) => session.sessionId)
|
|
.filter((sessionId): sessionId is string => Boolean(sessionId)),
|
|
);
|
|
}, [scopeType, terminalSessions]);
|
|
|
|
// Use live sessions for history + view resolution. Deferring the list used to
|
|
// lag one paint behind createSession, so normalizePanelView treated the new
|
|
// chat as missing and the draft-sync effect forced showDraftView — which
|
|
// parked the just-sent session into history under StrictMode.
|
|
const historySessions = useMemo(
|
|
() => profileAIPanelCalculation(
|
|
'AIChatSidePanel.historySessions',
|
|
() => getScopedHistorySessions(
|
|
sessions,
|
|
scopeType,
|
|
scopeTargetId,
|
|
scopeHostIds,
|
|
activeTerminalSessionIds,
|
|
workspaceMemberTerminalIds,
|
|
activeSessionIdMap,
|
|
),
|
|
),
|
|
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds, activeSessionIdMap],
|
|
);
|
|
|
|
const explicitPanelView = panelViewByScope[scopeKey];
|
|
const currentDraft = draftsByScope[scopeKey] ?? null;
|
|
const pendingComposerTextRef = useRef<string | null>(null);
|
|
const visibleHistorySessionIds = useMemo(
|
|
() => new Set(historySessions.map((session) => session.id)),
|
|
[historySessions],
|
|
);
|
|
const persistedSessionId = resolveInheritedAIActiveSessionId({
|
|
scopeType,
|
|
scopeTargetId,
|
|
activeSessionIdMap,
|
|
memberTerminalIds: terminalSessions.map((session) => session.sessionId).filter(Boolean),
|
|
preferredTerminalId: focusedSessionId,
|
|
visibleSessionIds: visibleHistorySessionIds,
|
|
});
|
|
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 isSteering = activeSessionId != null && steeringSessionId === activeSessionId;
|
|
const currentAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? defaultAgentId;
|
|
const observedContextUsage = useAgentContextUsage(activeSessionId);
|
|
const inputValue = pendingComposerTextRef.current ?? currentDraft?.text ?? '';
|
|
const files = currentDraft?.attachments ?? [];
|
|
const panelViewRef = useRef(normalizedPanelView);
|
|
panelViewRef.current = normalizedPanelView;
|
|
const permissionModeRef = useRef(globalPermissionMode);
|
|
permissionModeRef.current = globalPermissionMode;
|
|
const sendEpochRef = useRef(0);
|
|
useEffect(() => () => {
|
|
sendEpochRef.current += 1;
|
|
}, [scopeKey]);
|
|
const currentDraftRef = useRef(currentDraft);
|
|
if (pendingComposerTextRef.current != null) {
|
|
const pending = pendingComposerTextRef.current;
|
|
if (currentDraft && currentDraft.text === pending) {
|
|
pendingComposerTextRef.current = null;
|
|
currentDraftRef.current = currentDraft;
|
|
} else if (currentDraftRef.current?.text === pending) {
|
|
if (currentDraft && currentDraftRef.current) {
|
|
currentDraftRef.current.attachments = currentDraft.attachments;
|
|
currentDraftRef.current.selectedUserSkillSlugs = currentDraft.selectedUserSkillSlugs;
|
|
currentDraftRef.current.agentId = currentDraft.agentId;
|
|
}
|
|
} else {
|
|
const base = currentDraft ?? currentDraftRef.current ?? {
|
|
text: '',
|
|
agentId: currentAgentId,
|
|
attachments: [],
|
|
selectedUserSkillSlugs: [],
|
|
updatedAt: Date.now(),
|
|
};
|
|
currentDraftRef.current = { ...base, text: pending };
|
|
}
|
|
} else {
|
|
currentDraftRef.current = currentDraft;
|
|
}
|
|
const activeSessionRef = useRef(activeSession);
|
|
activeSessionRef.current = activeSession;
|
|
|
|
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;
|
|
|
|
return scheduleWhenAiComposerIdle(() => {
|
|
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
|
}, { initialDelayMs: 250 });
|
|
}, [isVisible, terminalSessions, activeSessionId]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
// Predicate must match normalizePanelView's list (scoped history), not the
|
|
// global store — out-of-scope sessions must still demote to draft.
|
|
if (!shouldForceDraftViewSync(
|
|
explicitPanelView,
|
|
normalizedPanelView,
|
|
(sessionId) => historySessions.some((session) => session.id === sessionId),
|
|
)) {
|
|
return;
|
|
}
|
|
showDraftView(scopeKey);
|
|
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, 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;
|
|
// Keep ownership while an explicit session is still displayable in this
|
|
// scope (normalize has not demoted it). Out-of-scope / missing ids clear.
|
|
if (
|
|
explicitPanelView?.mode === 'session'
|
|
&& historySessions.some((session) => session.id === explicitPanelView.sessionId)
|
|
) {
|
|
return;
|
|
}
|
|
if (persistedSessionId == null) return;
|
|
setActiveSessionId(null);
|
|
}, [
|
|
isVisible,
|
|
normalizedPanelView.mode,
|
|
explicitPanelView,
|
|
historySessions,
|
|
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 discardPendingComposerText = useCallback(() => {
|
|
pendingComposerTextRef.current = null;
|
|
}, []);
|
|
|
|
const clearScopeDraft = useCallback((options?: { keepPendingText?: boolean }) => {
|
|
if (!options?.keepPendingText) discardPendingComposerText();
|
|
clearDraftForScope(scopeKey);
|
|
}, [clearDraftForScope, discardPendingComposerText, scopeKey]);
|
|
|
|
const enterScopeDraftMode = useCallback((agentId: string, preserveSessionView = false) => {
|
|
applyDraftEntrySelection({
|
|
ensureDraft: () => ensureScopeDraft(agentId),
|
|
showDraftView: showScopeDraftView,
|
|
preserveSessionView,
|
|
});
|
|
}, [ensureScopeDraft, showScopeDraftView]);
|
|
|
|
const flushDraftText = useCallback(() => {
|
|
const pending = pendingComposerTextRef.current;
|
|
if (pending == null) return;
|
|
if (panelViewRef.current.mode !== 'draft') {
|
|
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
|
}
|
|
updateScopeDraft(currentAgentId, (current) => (
|
|
current.text === pending ? current : { ...current, text: pending }
|
|
));
|
|
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
|
|
|
const setInputValue = useCallback((value: string) => {
|
|
const base = currentDraftRef.current ?? {
|
|
text: '',
|
|
agentId: currentAgentId,
|
|
attachments: [],
|
|
selectedUserSkillSlugs: [],
|
|
updatedAt: Date.now(),
|
|
};
|
|
pendingComposerTextRef.current = value;
|
|
currentDraftRef.current = { ...base, text: value, updatedAt: Date.now() };
|
|
}, [currentAgentId]);
|
|
|
|
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]);
|
|
|
|
// External turns keep the tools from launch; changed settings cannot enable note reads mid-turn.
|
|
const canMentionNotes = currentAgentId === 'catty' || (toolIntegrationMode === 'mcp' && !isStreaming);
|
|
const validateNoteMentions = useCallback((attachments: UploadedFile[]) => {
|
|
if (!canMentionNotes && attachments.some(isVaultNoteAttachment)) {
|
|
toast.warning(t('ai.chat.mentionNoteUnavailable'));
|
|
return false;
|
|
}
|
|
if (!vaultNoteReferencesFit(attachments)) {
|
|
toast.warning(t('ai.chat.mentionNoteTooMany'));
|
|
return false;
|
|
}
|
|
return true;
|
|
}, [canMentionNotes, t]);
|
|
|
|
/** Mention Note: attach a Vault → Notes entry as inline context for the next send. */
|
|
const mentionNote = useCallback((note: VaultNote) => {
|
|
if (!canMentionNotes) return;
|
|
const upload = createVaultNoteAttachment({ id: note.id, title: note.title.trim() || t('ai.chat.untitledNote') });
|
|
if (!upload) {
|
|
toast.error(t('ai.chat.mentionNoteInvalid', {
|
|
title: String(note.title || '').trim() || t('ai.chat.untitledNote'),
|
|
}));
|
|
return;
|
|
}
|
|
const existing = currentDraftRef.current?.attachments ?? [];
|
|
if (!validateNoteMentions([...existing.filter((file) => file.vaultNoteId !== upload.vaultNoteId), upload])) return;
|
|
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
|
updateDraft(scopeKey, currentAgentId, (current) => ({
|
|
...current,
|
|
attachments: [
|
|
...current.attachments.filter((file) => file.vaultNoteId !== upload.vaultNoteId),
|
|
upload,
|
|
],
|
|
}));
|
|
}, [canMentionNotes, validateNoteMentions, updateDraft, currentAgentId, enterScopeDraftMode, scopeKey, t]);
|
|
|
|
useEffect(() => {
|
|
if (isVisible) return undefined;
|
|
flushDraftText();
|
|
return undefined;
|
|
}, [flushDraftText, isVisible]);
|
|
|
|
useEffect(() => () => {
|
|
flushDraftText();
|
|
}, [flushDraftText, scopeKey]);
|
|
|
|
useEffect(() => {
|
|
flushDraftText();
|
|
}, [activeSessionId, flushDraftText]);
|
|
|
|
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();
|
|
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
|
void loadUserSkillsStatus(bridge)
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
if (result === undefined) return;
|
|
applyUserSkillsStatus(result);
|
|
})
|
|
.catch(() => {});
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
cancelIdle();
|
|
};
|
|
}, [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) return;
|
|
void bridge.aiSyncProviders(providers);
|
|
}, [isVisible, providers]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
const bridge = getNetcattyBridge();
|
|
if (!bridge?.aiSyncWebSearch) return;
|
|
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,
|
|
schedule: scheduleWhenAiComposerIdle,
|
|
});
|
|
|
|
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],
|
|
);
|
|
|
|
// Hide the ring until a real context_snapshot arrives. A synthetic 0% fallback
|
|
// misleads users when reopening long sessions (looks empty until the next turn).
|
|
const contextUsage = currentAgentId === 'catty' ? observedContextUsage : null;
|
|
|
|
// Catty-only manual compact. External agents own their own compaction.
|
|
// No minimum message count: short sessions may still run /compact (even if
|
|
// there is little to summarize).
|
|
const canCompact = currentAgentId === 'catty'
|
|
&& Boolean(activeSessionId)
|
|
&& !isStreaming
|
|
&& Boolean(effectiveActiveProvider)
|
|
&& Boolean(effectiveActiveModelId.trim());
|
|
|
|
const providersRef = useRef(providers);
|
|
providersRef.current = providers;
|
|
|
|
const handleAgentProviderModelSelect = useCallback(
|
|
(providerId: string, modelId: string, contextWindow?: number) => {
|
|
setAgentProvider(currentAgentId, providerId);
|
|
setAgentModel(currentAgentId, modelId);
|
|
const sanitized = sanitizeContextWindow(contextWindow);
|
|
if (!updateProvider || sanitized == null) return;
|
|
const provider = providersRef.current.find((item) => item.id === providerId);
|
|
if (!provider) return;
|
|
if (provider.modelContextWindows?.[modelId] === sanitized) return;
|
|
updateProvider(providerId, {
|
|
modelContextWindows: {
|
|
...(provider.modelContextWindows ?? {}),
|
|
[modelId]: sanitized,
|
|
},
|
|
});
|
|
},
|
|
[currentAgentId, setAgentProvider, setAgentModel, updateProvider],
|
|
);
|
|
|
|
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({ codexPath: getManualAgentCommand(currentAgentConfig) }) 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, currentAgentConfig]);
|
|
|
|
const agentModelMapRef = useRef(agentModelMap);
|
|
agentModelMapRef.current = agentModelMap;
|
|
|
|
const buildExternalAgentRuntimeModelTarget = useCallback((agent: ExternalAgentConfig | undefined): SdkRuntimeModelTarget | null => {
|
|
if (!agent) return null;
|
|
const sdkBackend = getExternalAgentSdkBackend(agent);
|
|
if (!sdkBackend) return null;
|
|
// Cursor: re-inject auth mode for list-models (same as run-turn). Persisted
|
|
// agent.env strips NETCATTY_CURSOR_*; without this, main defaults to api-key
|
|
// and the UI falls back to curated CURSOR_MODEL_PRESETS (#2562).
|
|
const agentEnv = sdkBackend === 'cursor'
|
|
? buildCursorListModelsAgentEnv(agent)
|
|
: agent.env;
|
|
return {
|
|
agentId: agent.id,
|
|
cacheKey: buildSdkRuntimeModelCacheKey({ ...agent, env: agentEnv }),
|
|
sdkBackend,
|
|
agentEnv,
|
|
agentCommand: getManualAgentCommand(agent),
|
|
codexRuntime: sdkBackend === 'codex' ? (agent.codexRuntime ?? 'sdk') : undefined,
|
|
};
|
|
}, []);
|
|
|
|
const externalAgentsRef = useRef(externalAgents);
|
|
externalAgentsRef.current = externalAgents;
|
|
|
|
const applySdkRuntimeModelCatalog = useCallback((
|
|
agentId: string,
|
|
catalog: SdkRuntimeModelCatalog,
|
|
options: { adoptCurrentModel?: boolean } = {},
|
|
) => {
|
|
const agent = externalAgentsRef.current.find((item) => item.id === agentId);
|
|
const runtimePresets = mergeFallbackThinkingLevels(
|
|
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
|
|
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
|
|
);
|
|
const storedModelId = agentModelMapRef.current[agentId];
|
|
if (runtimePresets.length === 0) {
|
|
setRuntimeAgentModelPresets((prev) => {
|
|
if (!(agentId in prev)) return prev;
|
|
const { [agentId]: _removed, ...rest } = prev;
|
|
return rest;
|
|
});
|
|
} else {
|
|
setRuntimeAgentModelPresets((prev) => (
|
|
agentModelPresetsShallowEqual(prev[agentId], runtimePresets)
|
|
? prev
|
|
: { ...prev, [agentId]: runtimePresets }
|
|
));
|
|
}
|
|
|
|
const normalizedStoredModelId = normalizeStoredAgentModelSelection(
|
|
storedModelId,
|
|
runtimePresets,
|
|
);
|
|
if (normalizedStoredModelId && normalizedStoredModelId !== storedModelId) {
|
|
setAgentModel(agentId, normalizedStoredModelId);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
options.adoptCurrentModel
|
|
&& catalog.currentModelId
|
|
&& shouldAdoptSdkCurrentModel(catalog.currentModelId, storedModelId, runtimePresets)
|
|
) {
|
|
setAgentModel(agentId, catalog.currentModelId);
|
|
}
|
|
}, [setAgentModel]);
|
|
|
|
const loadSdkRuntimeModelCatalog = useCallback((
|
|
target: SdkRuntimeModelTarget,
|
|
options: { force?: boolean; logErrors?: boolean } = {},
|
|
): Promise<SdkRuntimeModelCatalog | null> => {
|
|
const bridge = getNetcattyBridge();
|
|
if (!bridge?.aiSdkAgentListModels) return Promise.resolve(null);
|
|
|
|
return sdkRuntimeModelCache.refresh(
|
|
target.cacheKey,
|
|
async () => {
|
|
const result = await bridge.aiSdkAgentListModels!(
|
|
target.sdkBackend,
|
|
undefined,
|
|
undefined,
|
|
`models_${target.agentId}`,
|
|
target.agentEnv,
|
|
target.agentCommand,
|
|
target.codexRuntime,
|
|
);
|
|
if (!result?.ok || !Array.isArray(result.models)) {
|
|
throw new Error(result?.error || 'Failed to load SDK agent models');
|
|
}
|
|
setRuntimeModelWarnings((current) => {
|
|
const next = { ...current };
|
|
if (result.warning && target.codexRuntime === 'app-server') {
|
|
next[target.agentId] = t('ai.codex.appServer.modelCatalogWarning');
|
|
console.warn('[AIChatSidePanel] Codex App Server model catalog unavailable:', result.warning);
|
|
} else {
|
|
delete next[target.agentId];
|
|
}
|
|
return next;
|
|
});
|
|
return {
|
|
currentModelId: result.currentModelId ?? null,
|
|
models: result.models,
|
|
};
|
|
},
|
|
{ force: options.force },
|
|
).catch((err) => {
|
|
if (target.codexRuntime === 'app-server') {
|
|
setRuntimeModelWarnings((current) => ({
|
|
...current,
|
|
[target.agentId]: t('ai.codex.appServer.modelCatalogWarning'),
|
|
}));
|
|
}
|
|
if (options.logErrors !== false) {
|
|
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
|
|
}
|
|
return null;
|
|
});
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
if (!currentAgentConfig) return;
|
|
if (!shouldLoadSdkRuntimeModels(currentAgentConfig) && !isCodexManagedAgent) return;
|
|
|
|
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
|
if (!target) return;
|
|
|
|
const cached = sdkRuntimeModelCache.read(target.cacheKey);
|
|
if (cached) {
|
|
applySdkRuntimeModelCatalog(target.agentId, cached);
|
|
}
|
|
|
|
// Respect renderer TTL / in-flight coalescing for all SDK agents including
|
|
// OpenCode. Forced refresh used to re-spawn opencode on every effect re-run
|
|
// even when the user never selected OpenCode (#2184). Manual refresh still
|
|
// passes force via the model selector path. Defer the network refresh so
|
|
// expand → first type does not wait on CLI model listing.
|
|
let cancelled = false;
|
|
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
|
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
|
|
if (cancelled || !catalog) return;
|
|
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
cancelIdle();
|
|
};
|
|
}, [
|
|
isVisible,
|
|
currentAgentConfig,
|
|
isCodexManagedAgent,
|
|
buildExternalAgentRuntimeModelTarget,
|
|
loadSdkRuntimeModelCatalog,
|
|
applySdkRuntimeModelCatalog,
|
|
]);
|
|
|
|
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
|
|
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
|
|
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent && !isCodexAppServer;
|
|
|
|
const agentModelPresets = useMemo(() => {
|
|
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
|
|
if (hasCodexCustomConfig) {
|
|
if (runtimePresets) {
|
|
return runtimePresets;
|
|
}
|
|
if (codexConfigModel) {
|
|
return [{ id: codexConfigModel, name: codexConfigModel }];
|
|
}
|
|
return [];
|
|
}
|
|
if (runtimePresets) return runtimePresets;
|
|
const presets = getAgentModelPresets(
|
|
currentAgentConfig?.command,
|
|
getExternalAgentSdkBackend(currentAgentConfig),
|
|
);
|
|
// BYO Codex CLI: hide GPT-5.6 when CLI < 0.144.0 (stored probe or discovery).
|
|
const cliVersion = resolveAgentCliVersion(currentAgentConfig, discoveredAgents);
|
|
return filterAgentModelPresetsForCliVersion(presets, cliVersion);
|
|
}, [
|
|
currentAgentConfig,
|
|
currentAgentId,
|
|
runtimeAgentModelPresets,
|
|
hasCodexCustomConfig,
|
|
codexConfigModel,
|
|
discoveredAgents,
|
|
]);
|
|
|
|
const selectedAgentModel = useMemo(() => {
|
|
const stored = agentModelMap[currentAgentId];
|
|
if (shouldUseStoredAgentModel(stored, agentModelPresets, currentAgentConfig)) {
|
|
return normalizeStoredAgentModelSelection(stored, agentModelPresets) ?? stored;
|
|
}
|
|
if (agentModelPresets.length > 0) {
|
|
// Cursor CLI login defaults to `auto` (subscription quota routing).
|
|
if (currentAgentConfig?.cursorAuthMode === 'cli-login') {
|
|
const autoPreset = agentModelPresets.find((preset) => preset.id === 'auto');
|
|
if (autoPreset) return resolveAgentModelSelection(autoPreset);
|
|
}
|
|
// Use catalog defaultThinkingLevel — do not pick last array entry
|
|
// (that made GPT-5.6 Sol default to ultra).
|
|
return resolveAgentModelSelection(agentModelPresets[0]);
|
|
}
|
|
return undefined;
|
|
}, [currentAgentConfig, currentAgentId, agentModelMap, agentModelPresets]);
|
|
|
|
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
|
|
const canSendCurrentAgent = useMemo(
|
|
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
|
|
[inputAgentId, externalAgents, isSending],
|
|
);
|
|
|
|
const handleAgentModelSelect = useCallback((modelId: string) => {
|
|
setAgentModel(currentAgentId, modelId);
|
|
}, [currentAgentId, setAgentModel]);
|
|
|
|
const selectedCattyThinking = agentThinkingMap.catty;
|
|
const handleCattyThinkingSelect = useCallback((level: string) => {
|
|
setAgentThinking('catty', level);
|
|
}, [setAgentThinking]);
|
|
|
|
|
|
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;
|
|
if (!validateNoteMentions(draft?.attachments ?? [])) return;
|
|
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,
|
|
vaultNoteId: file.vaultNoteId,
|
|
vaultNoteTitle: file.vaultNoteTitle,
|
|
previewText: file.previewText,
|
|
lineCount: file.lineCount,
|
|
}));
|
|
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
|
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
|
|
// Note-only sends (empty text + a mentioned note) still need a usable
|
|
// session title: fall back to the first mentioned note's title so these
|
|
// conversations don't all show up as "Untitled" in history.
|
|
const noteOnlyTitle = attachments.find(
|
|
(attachment) => isVaultNoteAttachment(attachment) && attachment.vaultNoteTitle,
|
|
)?.vaultNoteTitle ?? '';
|
|
const titleText = trimmed || noteOnlyTitle.trim();
|
|
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) => !isInlineTextAttachment(attachment));
|
|
const isDraftMode = currentPanelView.mode === 'draft';
|
|
|
|
flushDraftText();
|
|
const submittedText = draft?.text ?? '';
|
|
const keepPendingAfterSend = () => {
|
|
const pending = pendingComposerTextRef.current;
|
|
return pending != null && pending !== submittedText;
|
|
};
|
|
const sendGateKey = currentSessionView?.id ?? `draft:${scopeKey}`;
|
|
if (!tryBeginSendForKey(sendGateKey)) {
|
|
return;
|
|
}
|
|
let sessionSendGateKey: string | null = null;
|
|
const sendEpoch = sendEpochRef.current;
|
|
const isSendStale = () => sendEpochRef.current !== sendEpoch;
|
|
setIsSending(true);
|
|
|
|
try {
|
|
const sendBridge = getNetcattyBridge();
|
|
if (sendBridge?.aiSyncProviders && providers.length > 0) {
|
|
await sendBridge.aiSyncProviders(providers);
|
|
}
|
|
if (sendBridge?.aiSyncWebSearch) {
|
|
await sendBridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
|
}
|
|
let sendSelectedAgentModel = selectedAgentModel;
|
|
if (currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
|
|
const runtimeTarget = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
|
if (runtimeTarget) {
|
|
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
|
|
if (catalog) {
|
|
applySdkRuntimeModelCatalog(runtimeTarget.agentId, catalog, { adoptCurrentModel: true });
|
|
const runtimePresets = mergeFallbackThinkingLevels(
|
|
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
|
|
getAgentModelPresets(
|
|
currentAgentConfig.command,
|
|
getExternalAgentSdkBackend(currentAgentConfig),
|
|
),
|
|
);
|
|
const storedModelId = agentModelMapRef.current[sendAgentId];
|
|
if (
|
|
catalog.currentModelId
|
|
&& shouldAdoptSdkCurrentModel(catalog.currentModelId, storedModelId, runtimePresets)
|
|
) {
|
|
sendSelectedAgentModel = catalog.currentModelId;
|
|
} else if (shouldUseStoredAgentModel(storedModelId, runtimePresets)) {
|
|
sendSelectedAgentModel = normalizeStoredAgentModelSelection(
|
|
storedModelId,
|
|
runtimePresets,
|
|
) ?? storedModelId ?? sendSelectedAgentModel;
|
|
} else if (runtimePresets[0]) {
|
|
sendSelectedAgentModel = resolveAgentModelSelection(runtimePresets[0]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
setIsSending(false);
|
|
const sendPermissionMode = permissionModeRef.current;
|
|
if (isSendStale()) return;
|
|
if (currentAgentId !== sendAgentId) return;
|
|
const liveView = panelViewRef.current;
|
|
if (liveView.mode !== currentPanelView.mode) return;
|
|
if (
|
|
currentPanelView.mode === 'session'
|
|
&& liveView.mode === 'session'
|
|
&& liveView.sessionId !== currentPanelView.sessionId
|
|
) {
|
|
return;
|
|
}
|
|
void warmAiMarkdownRenderer();
|
|
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({ keepPendingText: keepPendingAfterSend() });
|
|
showScopeSessionView(createdSession.id);
|
|
setActiveSessionId(createdSession.id);
|
|
sessionSendGateKey = sessionId;
|
|
if (!tryBeginSendForKey(sessionSendGateKey)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
|
|
if (isAIChatSessionStreaming(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({ keepPendingText: keepPendingAfterSend() });
|
|
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({ keepPendingText: keepPendingAfterSend() });
|
|
showScopeSessionView(sessionId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
addMessageToSession(sessionId, {
|
|
id: generateId(), role: 'user', content: trimmed,
|
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
timestamp: Date.now(),
|
|
});
|
|
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
|
showScopeSessionView(sessionId);
|
|
setActiveSessionId(sessionId);
|
|
setStreamingForScope(sessionId, true);
|
|
|
|
const assistantMsgId = generateId();
|
|
addMessageToSession(sessionId, {
|
|
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
|
model: isExternalAgent
|
|
? (sendSelectedAgentModel || 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, assistantMsgId, modelPrompt, agentConfig, abortController, modelAttachments, {
|
|
existingSessionId: existingExternalSessionId,
|
|
updateExternalSessionId: updateSessionExternalSessionId,
|
|
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
|
terminalSessions,
|
|
defaultTargetSession,
|
|
providers,
|
|
selectedAgentModel: sendSelectedAgentModel,
|
|
toolIntegrationMode,
|
|
selectedUserSkillSlugs: selectedSkillSlugs,
|
|
permissionMode: sendPermissionMode,
|
|
});
|
|
} catch (err) {
|
|
reportStreamError(sessionId, abortController.signal, err);
|
|
}
|
|
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
|
setStreamingForScope(sessionId, false);
|
|
abortControllersRef.current.delete(sessionId);
|
|
autoTitleSession(sessionId, titleText);
|
|
} else {
|
|
const toolScope = {
|
|
type: scopeType,
|
|
targetId: scopeTargetId,
|
|
label: scopeLabel,
|
|
} as const;
|
|
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
|
|
activeProvider: sendActiveProvider,
|
|
activeModelId: sendActiveModelId,
|
|
reasoningEffort: selectedCattyThinking,
|
|
scopeType,
|
|
scopeTargetId,
|
|
scopeLabel,
|
|
globalPermissionMode: sendPermissionMode,
|
|
commandBlocklist,
|
|
commandTimeout,
|
|
responseIdleTimeout,
|
|
terminalSessions,
|
|
webSearchConfig,
|
|
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
|
autoTitleSession,
|
|
selectedUserSkillSlugs: selectedSkillSlugs,
|
|
titleText,
|
|
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
|
}
|
|
} finally {
|
|
setIsSending(false);
|
|
endSendForKey(sendGateKey);
|
|
if (sessionSendGateKey && sessionSendGateKey !== sendGateKey) {
|
|
endSendForKey(sessionSendGateKey);
|
|
}
|
|
}
|
|
}, [
|
|
validateNoteMentions,
|
|
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
|
activeModelId, externalAgents,
|
|
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
|
setStreamingForScope,
|
|
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
|
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
|
|
scopeType, scopeTargetId, scopeHostIds, scopeLabel, commandBlocklist, commandTimeout, responseIdleTimeout, webSearchConfig, buildExecutorContextForScope,
|
|
toolIntegrationMode,
|
|
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
|
flushDraftText, currentAgentConfig, buildExternalAgentRuntimeModelTarget,
|
|
loadSdkRuntimeModelCatalog, applySdkRuntimeModelCatalog,
|
|
]);
|
|
|
|
const handleCompact = useCallback(async () => {
|
|
const session = activeSessionRef.current;
|
|
if (
|
|
currentAgentId !== 'catty'
|
|
|| !session
|
|
|| isStreaming
|
|
|| isAIChatSessionStreaming(session.id)
|
|
|| !effectiveActiveProvider
|
|
|| !effectiveActiveModelId.trim()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!tryBeginSendForKey(session.id)) return;
|
|
|
|
const controller = new AbortController();
|
|
abortControllersRef.current.set(session.id, controller);
|
|
setStreamingForScope(session.id, true);
|
|
try {
|
|
await sendToCattyAgent(
|
|
session.id,
|
|
scopeKey,
|
|
'',
|
|
controller,
|
|
session,
|
|
'',
|
|
{
|
|
activeProvider: effectiveActiveProvider,
|
|
activeModelId: effectiveActiveModelId,
|
|
reasoningEffort: selectedCattyThinking,
|
|
scopeType,
|
|
scopeTargetId,
|
|
scopeLabel,
|
|
globalPermissionMode,
|
|
commandBlocklist,
|
|
commandTimeout,
|
|
responseIdleTimeout,
|
|
terminalSessions,
|
|
webSearchConfig,
|
|
getExecutorContext: () => buildExecutorContextForScope({
|
|
type: scopeType,
|
|
targetId: scopeTargetId,
|
|
label: scopeLabel,
|
|
}),
|
|
autoTitleSession: () => {},
|
|
selectedUserSkillSlugs: [],
|
|
forceCompaction: true,
|
|
},
|
|
);
|
|
} finally {
|
|
setStreamingForScope(session.id, false);
|
|
if (abortControllersRef.current.get(session.id) === controller) {
|
|
abortControllersRef.current.delete(session.id);
|
|
}
|
|
endSendForKey(session.id);
|
|
}
|
|
}, [
|
|
abortControllersRef,
|
|
buildExecutorContextForScope,
|
|
commandBlocklist,
|
|
commandTimeout,
|
|
responseIdleTimeout,
|
|
currentAgentId,
|
|
effectiveActiveModelId,
|
|
effectiveActiveProvider,
|
|
selectedCattyThinking,
|
|
globalPermissionMode,
|
|
isStreaming,
|
|
scopeKey,
|
|
scopeLabel,
|
|
scopeTargetId,
|
|
scopeType,
|
|
sendToCattyAgent,
|
|
setStreamingForScope,
|
|
terminalSessions,
|
|
webSearchConfig,
|
|
]);
|
|
|
|
const handleSteer = useCallback(async () => {
|
|
const sessionId = activeSessionRef.current?.id;
|
|
const draft = currentDraftRef.current;
|
|
if (!sessionId || !draft || steeringSessionId || !canSteerCurrentTurn) return;
|
|
|
|
if (!validateNoteMentions(draft.attachments)) return;
|
|
const trimmed = draft.text.trim();
|
|
const attachments = draft.attachments.map((file) => ({
|
|
base64Data: file.base64Data,
|
|
mediaType: file.mediaType,
|
|
filename: file.filename,
|
|
filePath: file.filePath,
|
|
terminalSelection: file.terminalSelection,
|
|
vaultNoteId: file.vaultNoteId,
|
|
vaultNoteTitle: file.vaultNoteTitle,
|
|
previewText: file.previewText,
|
|
lineCount: file.lineCount,
|
|
}));
|
|
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
|
if (!trimmed && !hasInlineTextAttachments) return;
|
|
|
|
const userMessageId = generateId();
|
|
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
|
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
|
|
setSteerWarnings(current => {
|
|
const next = { ...current };
|
|
delete next[sessionId];
|
|
return next;
|
|
});
|
|
setSteeringSessionId(sessionId);
|
|
try {
|
|
const result = await steerExternalAgent({
|
|
chatSessionId: sessionId,
|
|
userMessageId,
|
|
userText: trimmed,
|
|
prompt: modelPrompt,
|
|
attachments,
|
|
attachedImages: modelAttachments,
|
|
});
|
|
if (result.status === 'accepted') {
|
|
if (currentDraftRef.current === draft) clearScopeDraft();
|
|
return;
|
|
}
|
|
if (result.status !== 'cancelled') {
|
|
setSteerWarnings(current => ({
|
|
...current,
|
|
[sessionId]: { reason: result.status, turnKind: result.turnKind },
|
|
}));
|
|
}
|
|
} finally {
|
|
setSteeringSessionId(current => current === sessionId ? null : current);
|
|
}
|
|
}, [
|
|
validateNoteMentions, canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
|
|
|
|
const stopStreamingForSession = useCallback(async (sessionId: string) => {
|
|
const controller = abortControllersRef.current.get(sessionId);
|
|
setStreamingForScope(sessionId, false);
|
|
updateLastMessage(sessionId, (msg) => ({
|
|
...msg,
|
|
statusText: '',
|
|
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
|
|
}));
|
|
await stopAgentTurn({
|
|
chatSessionId: sessionId,
|
|
abortController: controller,
|
|
bridge: getNetcattyBridge(),
|
|
reason: 'user',
|
|
});
|
|
await getAgentRuntime().waitForActiveTurn(sessionId);
|
|
if (controller && abortControllersRef.current.get(sessionId) === controller) {
|
|
abortControllersRef.current.delete(sessionId);
|
|
}
|
|
}, [setStreamingForScope, updateLastMessage, abortControllersRef]);
|
|
|
|
const { addGrant } = useAIPermissionGrantsState();
|
|
|
|
useEffect(() => {
|
|
return registerGrantPersister((rule) => { addGrant(rule); });
|
|
}, [addGrant]);
|
|
|
|
const handleStop = useCallback(() => {
|
|
if (!activeSessionId) return;
|
|
setSteeringSessionId(current => current === activeSessionId ? null : current);
|
|
setSteerWarnings(current => {
|
|
const next = { ...current };
|
|
delete next[activeSessionId];
|
|
return next;
|
|
});
|
|
stopStreamingForSession(activeSessionId);
|
|
}, [activeSessionId, stopStreamingForSession]);
|
|
|
|
useEffect(() => {
|
|
if (!activeSessionId || isStreaming) return;
|
|
setSteeringSessionId(current => current === activeSessionId ? null : current);
|
|
setSteerWarnings(current => {
|
|
if (!current[activeSessionId]) return current;
|
|
const next = { ...current };
|
|
delete next[activeSessionId];
|
|
return next;
|
|
});
|
|
}, [activeSessionId, isStreaming]);
|
|
|
|
const steerWarning = useMemo(() => {
|
|
if (!activeSessionId) return undefined;
|
|
const warning = steerWarnings[activeSessionId];
|
|
if (!warning) return undefined;
|
|
if (warning.reason === 'not-steerable' && warning.turnKind === 'review') {
|
|
return t('ai.codex.steer.notSteerableReview');
|
|
}
|
|
if (warning.reason === 'not-steerable' && warning.turnKind === 'compact') {
|
|
return t('ai.codex.steer.notSteerableCompact');
|
|
}
|
|
if (warning.reason === 'busy') return t('ai.codex.steer.busy');
|
|
if (warning.reason === 'inactive') return t('ai.codex.steer.inactive');
|
|
if (warning.reason === 'unsupported') return t('ai.codex.steer.unsupported');
|
|
return t('ai.codex.steer.failed');
|
|
}, [activeSessionId, steerWarnings, t]);
|
|
|
|
const handleSelectSession = useCallback(
|
|
(sessionId: string) => {
|
|
applyHistorySessionSelection(sessionId, {
|
|
showSessionView: showScopeSessionView,
|
|
setActiveSessionId,
|
|
closeHistory: () => setShowHistory(false),
|
|
});
|
|
},
|
|
[setActiveSessionId, showScopeSessionView],
|
|
);
|
|
|
|
const handleDeleteSession = useCallback(
|
|
async (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)) {
|
|
await 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]);
|
|
|
|
// Hidden retained panels keep the composer mounted (parent is `hidden` +
|
|
// inert) so reopen does not remount ChatInput. Skip the message list.
|
|
return (
|
|
<React.Profiler {...getAIPanelProfilerProps('AIChatSidePanel.Active')}>
|
|
<div
|
|
className="h-full min-h-0"
|
|
data-section={isVisible ? undefined : 'ai-chat-panel-retained'}
|
|
aria-hidden={isVisible ? undefined : true}
|
|
inert={isVisible ? undefined : true}
|
|
>
|
|
<AIChatPanelContent
|
|
parked={!isVisible}
|
|
sending={isSending}
|
|
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}
|
|
activeCompaction={
|
|
activeCompaction?.sessionId === activeSessionId ? activeCompaction : null
|
|
}
|
|
contextUsage={contextUsage}
|
|
canCompact={canCompact}
|
|
inputValue={inputValue}
|
|
setInputValue={setInputValue}
|
|
handleSend={handleSend}
|
|
handleCompact={handleCompact}
|
|
handleSteer={handleSteer}
|
|
handleStop={handleStop}
|
|
canSteer={canSteerCurrentTurn}
|
|
isSteering={isSteering}
|
|
steerWarning={steerWarning}
|
|
lockTurnConfiguration={Boolean(isSending || (isStreaming && isCodexAppServer))}
|
|
canSendCurrentAgent={canSendCurrentAgent}
|
|
providerDisplayName={providerDisplayName}
|
|
modelDisplayName={modelDisplayName}
|
|
modelCatalogWarning={runtimeModelWarnings[currentAgentId]}
|
|
agentModelPresets={agentModelPresets}
|
|
selectedAgentModel={selectedAgentModel}
|
|
handleAgentModelSelect={handleAgentModelSelect}
|
|
cattyConfiguredProviders={cattyConfiguredProviders}
|
|
effectiveActiveProvider={effectiveActiveProvider}
|
|
effectiveActiveModelId={effectiveActiveModelId}
|
|
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
|
|
selectedCattyThinking={selectedCattyThinking}
|
|
handleCattyThinkingSelect={handleCattyThinkingSelect}
|
|
files={files}
|
|
addFiles={addFiles}
|
|
removeFile={removeFile}
|
|
onMentionNote={canMentionNotes ? mentionNote : undefined}
|
|
terminalSessions={terminalSessions}
|
|
selectedUserSkills={selectedUserSkills}
|
|
userSkillOptions={userSkillOptions}
|
|
quickMessages={quickMessages}
|
|
addSelectedUserSkill={addSelectedUserSkill}
|
|
removeSelectedUserSkill={removeSelectedUserSkill}
|
|
globalPermissionMode={globalPermissionMode}
|
|
setGlobalPermissionMode={setGlobalPermissionMode}
|
|
notes={notes}
|
|
hosts={hosts}
|
|
snippets={snippets}
|
|
onOpenVaultNote={onOpenVaultNote}
|
|
onOpenVaultHost={onOpenVaultHost}
|
|
onOpenVaultSnippet={onOpenVaultSnippet}
|
|
onOpenVaultSection={onOpenVaultSection}
|
|
/>
|
|
</div>
|
|
</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',
|
|
'persistContextCompaction',
|
|
'providers',
|
|
'activeProviderId',
|
|
'activeModelId',
|
|
'defaultAgentId',
|
|
'toolIntegrationMode',
|
|
'externalAgents',
|
|
'setExternalAgents',
|
|
'agentModelMap',
|
|
'setAgentModel',
|
|
'agentProviderMap',
|
|
'setAgentProvider',
|
|
'agentThinkingMap',
|
|
'setAgentThinking',
|
|
'updateProvider',
|
|
'globalPermissionMode',
|
|
'setGlobalPermissionMode',
|
|
'commandBlocklist',
|
|
'commandTimeout',
|
|
'responseIdleTimeout',
|
|
'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.focusedSessionId ?? '') !== (next.focusedSessionId ?? '')) 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;
|
|
if (prev.notes !== next.notes) return false;
|
|
if (prev.hosts !== next.hosts) return false;
|
|
if (prev.snippets !== next.snippets) return false;
|
|
if (prev.onOpenVaultNote !== next.onOpenVaultNote) return false;
|
|
if (prev.onOpenVaultHost !== next.onOpenVaultHost) return false;
|
|
if (prev.onOpenVaultSnippet !== next.onOpenVaultSnippet) return false;
|
|
if (prev.onOpenVaultSection !== next.onOpenVaultSection) return false;
|
|
|
|
const scopeKey = `${prev.scopeType}:${prev.scopeTargetId ?? ''}`;
|
|
if (
|
|
prev.sessions === next.sessions
|
|
&& draftsByScopeEqualIgnoringComposerText(prev.draftsByScope, next.draftsByScope, scopeKey)
|
|
) {
|
|
let restEqual = true;
|
|
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
|
|
if (key === 'sessions' || key === 'draftsByScope') continue;
|
|
if (prev[key] !== next[key]) {
|
|
restEqual = false;
|
|
break;
|
|
}
|
|
}
|
|
if (restEqual) return true;
|
|
}
|
|
|
|
// Sibling stream thrash: full sessions array identity always changes. Only
|
|
// exact-scope session object refs matter for this panel's active chat —
|
|
// plus the currently selected session, which may be a host-matched history
|
|
// resume whose stored scope.targetId is an older terminal.
|
|
// Fuzzy history still receives the full list; drawer open forces re-render
|
|
// via isVisible / other prop paths when the user actually needs it.
|
|
// Keep visibleSessionIds in sync with the live panel so inheritance that
|
|
// skips non-history chats does not leave memo pinned to a different id.
|
|
const resolveSelectedSessionId = (
|
|
props: AIChatSidePanelProps,
|
|
): string | null => {
|
|
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
|
const memberTerminalIds = (props.terminalSessions ?? [])
|
|
.map((session) => session.sessionId)
|
|
.filter((sessionId): sessionId is string => Boolean(sessionId));
|
|
const workspaceMemberTerminalIds = props.scopeType === 'workspace'
|
|
? new Set(memberTerminalIds)
|
|
: undefined;
|
|
const activeTerminalSessionIds = new Set<string>();
|
|
for (const [sessionScopeKey, sessionId] of Object.entries(props.activeSessionIdMap) as Array<[string, string | null]>) {
|
|
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
|
|
if (sessionScopeKey === scopeKey) continue;
|
|
activeTerminalSessionIds.add(sessionId);
|
|
}
|
|
const visibleSessionIds = new Set(
|
|
getScopedHistorySessions(
|
|
props.sessions,
|
|
props.scopeType,
|
|
props.scopeTargetId,
|
|
props.scopeHostIds,
|
|
activeTerminalSessionIds,
|
|
workspaceMemberTerminalIds,
|
|
props.activeSessionIdMap,
|
|
).map((session) => session.id),
|
|
);
|
|
return resolveInheritedAIActiveSessionId({
|
|
scopeType: props.scopeType,
|
|
scopeTargetId: props.scopeTargetId,
|
|
activeSessionIdMap: props.activeSessionIdMap,
|
|
memberTerminalIds,
|
|
preferredTerminalId: props.focusedSessionId,
|
|
visibleSessionIds,
|
|
});
|
|
};
|
|
const selectedSessionId = resolveSelectedSessionId(prev)
|
|
?? resolveSelectedSessionId(next);
|
|
if (!exactScopeAISessionsEqual(
|
|
prev.sessions,
|
|
next.sessions,
|
|
prev.scopeType,
|
|
prev.scopeTargetId,
|
|
selectedSessionId,
|
|
)) {
|
|
return false;
|
|
}
|
|
// History drawer / recent list need create/delete/title/updatedAt chrome.
|
|
// Only when the panel is (or becomes) visible so hidden retained panels do
|
|
// not re-render on every sibling stream that bumps updatedAt.
|
|
const prevVisible = prev.isVisible ?? true;
|
|
const nextVisible = next.isVisible ?? true;
|
|
if ((prevVisible || nextVisible) && !aiSessionIdSetEqual(prev.sessions, next.sessions)) {
|
|
return false;
|
|
}
|
|
|
|
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
|
|
if (key === 'sessions') continue;
|
|
if (key === 'draftsByScope') {
|
|
if (!draftsByScopeEqualIgnoringComposerText(prev.draftsByScope, next.draftsByScope, scopeKey)) {
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
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 };
|