mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
* Fix #954: unify Tooltip styling + replace native selects Replace native HTML title= tooltips and native <select> dropdowns with the existing Radix-based Tooltip / Select components so they share the app's rounded styling, theme tokens and i18n pipeline. Adds a global TooltipProvider in AppWithProviders so every descendant Tooltip works without a per-file Provider wrapper. Scope (driven by the issue #954 examples and "全部都处理" follow-up): - TerminalLayer toolbar: Add Terminal / Split View / SFTP / Scripts / Theme / AI Chat / Move panel / Close panel. - TopTabs middle bar: quick switcher, more tabs, AI assistant, theme toggle, settings; window-control buttons (min/max/close), tray close and hotkey reset/disable have their native title dropped per the user's explicit opt-out ("可以不用Tooltip,直接全局禁用 原生title 属性"). - AI panels: AIChatSidePanel session history / new chat / delete, ConversationExport, AgentSelector, ChatInput attach / expand / permission, ModelSelector, ProviderCard, ai-elements/tool-call. - SFTP: SftpSidePanel header, SftpBreadcrumb, SftpFileRow, SftpPaneToolbar, SftpTabBar, SftpTransferQueue. - Settings: SettingsPage close, SettingsAppearanceTab theme/accent swatches, SettingsFileAssociationsTab edit/remove, SettingsSystemTab crash-log paths and global hotkey reset. - Host vault: HostDetailsPanel (clear / suggestions / show-password / key path / browse key), GroupDetailsPanel, KnownHostsManager, ConnectionLogsManager, KeychainManager, SyncStatusButton, CloudSyncSettings, LogView, QuickSwitcher, ScriptsSidePanel, Terminal status bar copy-host + broadcast/focus, ZmodemProgressIndicator. - Terminal subcomponents: HostKeywordHighlightPopover, TerminalComposeBar, TerminalConnectionDialog, TerminalSearchBar. - Editor: TextEditorPane (subtitle, search, wrap, promote-to-tab). - TrayPanel session rows and port-forwarding rows. Native <select> migrated to custom Select component: - SerialConnectModal (data bits, stop bits, parity, flow control) - SerialHostDetailsPanel (same four fields) - HostDetailsPanel backspace behavior - GroupDetailsPanel backspace behavior - SettingsTerminalTab local shell picker - terminal/ThemeSidePanel font weight Hardcoded English strings extracted to i18n. New keys for both en and zh-CN: terminal.layer.*, topTabs.*, ai.chat.* (sessionHistory, attach, collapse, expand, enableAgent), zmodem.*, settings.shortcuts. resetToDefault. Inline help text on SnippetsManager package-name input removed because the same hint is already shown in a visible <p> below the input. Existing per-file <TooltipProvider> wrappers (SnippetsManager, ScriptsSidePanel, SelectHostPanel, RuleCard, HostDetailsPanel proxy section) are left in place — they nest harmlessly under the global provider and stay self-sufficient for component tests. Tests: - tsc clean for changed files (pre-existing repo-wide errors unrelated to this PR). - All 802 tests pass (3 skipped pre-existing). - HostDetailsPanel.proxyProfile.test and TextEditorPane.test updated to wrap with TooltipProvider, matching the runtime context now needed by the migrated components. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix #954: wrap Settings + Tray windows with TooltipProvider Settings and the tray panel mount as separate Electron windows with their own React root in index.tsx, so they do not inherit the global TooltipProvider added under AppWithProviders. After the unified Tooltip migration, any settings tab that used a Tooltip (Appearance, Application, FileAssociations, System, Shortcuts, Terminal, AI ProviderCard, AI ModelSelector) — and TrayPanel — threw "Tooltip must be used within TooltipProvider" and rendered nothing. Wrap both branches with TooltipProvider at the same level as ToastProvider in index.tsx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1261 lines
46 KiB
TypeScript
1261 lines
46 KiB
TypeScript
/**
|
|
* AIChatSidePanel - Main AI chat interface side panel
|
|
*
|
|
* Zed-style agent panel with agent selector, scoped chat sessions,
|
|
* message list, input area, and session history drawer.
|
|
*
|
|
* Core logic is decomposed into focused hooks:
|
|
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
|
|
* - useConversationExport: export formats & object URL lifecycle
|
|
*/
|
|
|
|
import {
|
|
History,
|
|
Plus,
|
|
Trash2,
|
|
X,
|
|
} from 'lucide-react';
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { cn } from '../lib/utils';
|
|
import { useI18n } from '../application/i18n/I18nProvider';
|
|
import { useWindowControls } from '../application/state/useWindowControls';
|
|
import type {
|
|
AIDraft,
|
|
AIPanelView,
|
|
AIPermissionMode,
|
|
AIToolIntegrationMode,
|
|
AgentModelPreset,
|
|
AISession,
|
|
AISessionScope,
|
|
ChatMessage,
|
|
DiscoveredAgent,
|
|
ExternalAgentConfig,
|
|
ProviderConfig,
|
|
WebSearchConfig,
|
|
} from '../infrastructure/ai/types';
|
|
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
|
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
|
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
|
import { Button } from './ui/button';
|
|
import { ScrollArea } from './ui/scroll-area';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
|
import AgentSelector from './ai/AgentSelector';
|
|
import ChatInput from './ai/ChatInput';
|
|
import ChatMessageList from './ai/ChatMessageList';
|
|
import ConversationExport from './ai/ConversationExport';
|
|
import {
|
|
getReadyUserSkillOptions,
|
|
getNextSelectedUserSkillSlugsMap,
|
|
type UserSkillOption,
|
|
} from './ai/userSkillsState';
|
|
import {
|
|
applyDraftEntrySelection,
|
|
applyHistorySessionSelection,
|
|
resolveDisplayedPanelView,
|
|
resolveDisplayedSession,
|
|
} from './ai/aiPanelViewState';
|
|
import {
|
|
endDraftSend,
|
|
tryBeginDraftSend,
|
|
} from './ai/draftSendGate';
|
|
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
|
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
|
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
|
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
|
import {
|
|
useAIChatStreaming,
|
|
getNetcattyBridge,
|
|
type DefaultTargetSessionHint,
|
|
} from './ai/hooks/useAIChatStreaming';
|
|
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
|
|
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
|
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
|
import { useConversationExport } from './ai/hooks/useConversationExport';
|
|
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
|
|
|
function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
|
if (preset.thinkingLevels?.length) {
|
|
return preset.thinkingLevels.some((level) => `${preset.id}/${level}` === modelId);
|
|
}
|
|
return preset.id === modelId;
|
|
}
|
|
|
|
function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
|
|
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
|
|
}
|
|
|
|
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
|
if (!agent) return false;
|
|
const tokens = [
|
|
agent.id,
|
|
agent.name,
|
|
agent.icon,
|
|
agent.command,
|
|
agent.acpCommand,
|
|
]
|
|
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
|
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
|
return tokens.some((token) => token.includes('copilot'));
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Props
|
|
// -------------------------------------------------------------------
|
|
|
|
interface AIChatSidePanelProps {
|
|
// Session state (per-scope)
|
|
sessions: AISession[];
|
|
activeSessionIdMap: Record<string, string | null>;
|
|
draftsByScope: Partial<Record<string, AIDraft>>;
|
|
panelViewByScope: Partial<Record<string, AIPanelView>>;
|
|
setActiveSessionId: (scopeKey: string, id: string | null) => void;
|
|
ensureDraftForScope: (scopeKey: string, agentId: string) => void;
|
|
updateDraft: (
|
|
scopeKey: string,
|
|
fallbackAgentId: string,
|
|
updater: (draft: AIDraft) => AIDraft,
|
|
) => void;
|
|
showDraftView: (scopeKey: string) => void;
|
|
showSessionView: (scopeKey: string, sessionId: string) => void;
|
|
clearDraftForScope: (scopeKey: string) => void;
|
|
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
|
|
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
|
|
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
|
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
|
updateSessionTitle: (sessionId: string, title: string) => void;
|
|
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
|
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
|
updateLastMessage: (
|
|
sessionId: string,
|
|
updater: (msg: ChatMessage) => ChatMessage,
|
|
) => void;
|
|
updateMessageById: (
|
|
sessionId: string,
|
|
messageId: string,
|
|
updater: (msg: ChatMessage) => ChatMessage,
|
|
) => void;
|
|
// Provider config
|
|
providers: ProviderConfig[];
|
|
activeProviderId: string;
|
|
activeModelId: string;
|
|
|
|
// Agent info
|
|
defaultAgentId: string;
|
|
toolIntegrationMode: AIToolIntegrationMode;
|
|
externalAgents: ExternalAgentConfig[];
|
|
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
|
agentModelMap: Record<string, string>;
|
|
setAgentModel: (agentId: string, modelId: string) => void;
|
|
|
|
// Safety
|
|
globalPermissionMode: AIPermissionMode;
|
|
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
|
commandBlocklist?: string[];
|
|
maxIterations?: number;
|
|
|
|
// Web search
|
|
webSearchConfig?: WebSearchConfig | null;
|
|
|
|
// Context
|
|
scopeType: 'terminal' | 'workspace';
|
|
scopeTargetId?: string;
|
|
scopeHostIds?: string[];
|
|
scopeLabel?: string;
|
|
|
|
// Terminal session context (from parent)
|
|
terminalSessions?: Array<{
|
|
sessionId: string;
|
|
hostId: string;
|
|
hostname: string;
|
|
label: string;
|
|
os?: string;
|
|
username?: string;
|
|
protocol?: string;
|
|
shellType?: string;
|
|
deviceType?: string;
|
|
connected: boolean;
|
|
}>;
|
|
resolveExecutorContext?: (scope: {
|
|
type: 'terminal' | 'workspace';
|
|
targetId?: string;
|
|
label?: string;
|
|
}) => ExecutorContext;
|
|
|
|
// Visibility
|
|
isVisible?: boolean;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------
|
|
|
|
function generateId(): string {
|
|
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Component
|
|
// -------------------------------------------------------------------
|
|
|
|
const AIChatSidePanelInner: 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,
|
|
globalPermissionMode,
|
|
setGlobalPermissionMode,
|
|
commandBlocklist,
|
|
maxIterations = 20,
|
|
webSearchConfig,
|
|
scopeType,
|
|
scopeTargetId,
|
|
scopeHostIds,
|
|
scopeLabel,
|
|
terminalSessions = [],
|
|
resolveExecutorContext,
|
|
isVisible = true,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
// ── Per-scope state ──
|
|
// Derive scope key for per-scope isolation
|
|
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
|
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
|
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
|
|
const { openSettingsWindow } = useWindowControls();
|
|
const terminalSessionsRef = useRef(terminalSessions);
|
|
terminalSessionsRef.current = terminalSessions;
|
|
const resolveExecutorContextRef = useRef(resolveExecutorContext);
|
|
resolveExecutorContextRef.current = resolveExecutorContext;
|
|
|
|
// ── Streaming hook ──
|
|
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 historySessions = useMemo(
|
|
() =>
|
|
sessions
|
|
.map((session) => ({
|
|
session,
|
|
matchRank: getSessionScopeMatchRank(
|
|
session,
|
|
scopeType,
|
|
scopeTargetId,
|
|
scopeHostIds,
|
|
activeTerminalSessionIds,
|
|
),
|
|
}))
|
|
.filter(({ matchRank }) => matchRank > 0)
|
|
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
|
|
.map(({ session }) => session),
|
|
[sessions, 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]);
|
|
|
|
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
|
useEffect(() => {
|
|
const bridge = getNetcattyBridge();
|
|
if (bridge?.aiMcpUpdateSessions) {
|
|
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
|
}
|
|
}, [terminalSessions, scopeKey, activeSessionId]);
|
|
|
|
useEffect(() => {
|
|
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
|
|
showDraftView(scopeKey);
|
|
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
|
|
|
useEffect(() => {
|
|
if (!activeSession) return;
|
|
|
|
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
|
|
setActiveSessionId(activeSession.id);
|
|
}
|
|
}, [
|
|
activeSession,
|
|
activeSessionIdMap,
|
|
scopeKey,
|
|
isVisible,
|
|
setActiveSessionId,
|
|
]);
|
|
|
|
// When the resolved view is draft but activeSessionIdMap still points at a
|
|
// previously-shown session, clear that stale entry. Otherwise
|
|
// activeTerminalTargetIds keeps claiming ownership of the old session's
|
|
// target and getSessionScopeMatchRank suppresses matching history from
|
|
// other terminals until another action rewrites the map.
|
|
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();
|
|
if (!bridge?.aiUserSkillsGetStatus) {
|
|
applyUserSkillsStatus(null);
|
|
return;
|
|
}
|
|
|
|
void bridge.aiUserSkillsGetStatus()
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
applyUserSkillsStatus(result);
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
applyUserSkillsStatus(null);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
|
|
|
|
// Sync provider configs to main process so it can decrypt API keys server-side.
|
|
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
|
|
useEffect(() => {
|
|
const bridge = getNetcattyBridge();
|
|
if (bridge?.aiSyncProviders && providers.length > 0) {
|
|
void bridge.aiSyncProviders(providers);
|
|
}
|
|
}, [providers]);
|
|
|
|
// Sync web search config to main process (allowlist + encrypted API key for server-side decryption).
|
|
// Note: This is fire-and-forget; if the first search fires before sync completes, it will fail
|
|
// with a clear error and succeed on retry. Making this blocking would require async tool creation.
|
|
useEffect(() => {
|
|
const bridge = getNetcattyBridge();
|
|
if (bridge?.aiSyncWebSearch) {
|
|
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
|
}
|
|
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
|
|
|
// Preserve active streams across tab switches. The panel is conditionally
|
|
// mounted per tab, so unmounting here should not cancel in-flight work.
|
|
useEffect(() => {
|
|
return () => {
|
|
// no-op: stream lifecycle is managed by explicit stop/delete actions
|
|
};
|
|
}, []);
|
|
|
|
// Agent discovery
|
|
const {
|
|
discoveredAgents,
|
|
isDiscovering,
|
|
rediscover,
|
|
enableAgent,
|
|
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
|
|
|
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],
|
|
);
|
|
|
|
// ── Export hook ──
|
|
const { handleExport } = useConversationExport(activeSession);
|
|
|
|
// Active provider info
|
|
const activeProvider = useMemo(
|
|
() => providers.find((p) => p.id === activeProviderId),
|
|
[providers, activeProviderId],
|
|
);
|
|
|
|
const providerDisplayName = activeProvider?.name ?? '';
|
|
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
|
|
|
// Agent model presets for the current external agent
|
|
const currentAgentConfig = useMemo(
|
|
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
|
[currentAgentId, externalAgents],
|
|
);
|
|
const isCopilotExternalAgent = useMemo(
|
|
() => isCopilotAgentConfig(currentAgentConfig),
|
|
[currentAgentConfig],
|
|
);
|
|
const isCodexManagedAgent = useMemo(
|
|
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
|
|
[currentAgentConfig],
|
|
);
|
|
const isClaudeManagedAgent = useMemo(
|
|
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'claude') : false,
|
|
[currentAgentConfig],
|
|
);
|
|
|
|
// For Codex, pick up the model declared in ~/.codex/config.toml (if any)
|
|
// so the picker can show just that model instead of the hardcoded ChatGPT
|
|
// preset list. Probing codex-acp for its full catalog returns the stock
|
|
// OpenAI models regardless of the active provider, which is misleading.
|
|
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
|
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
|
useEffect(() => {
|
|
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);
|
|
// Only flip "resolved" to true when the probe confirms this is a
|
|
// custom-config session; otherwise keep it false so we fall back to
|
|
// the static CODEX_MODEL_PRESETS.
|
|
setCodexCustomConfigResolved(hasCustom);
|
|
}).catch(() => {
|
|
if (!cancelled) {
|
|
setCodexConfigModel(null);
|
|
setCodexCustomConfigResolved(false);
|
|
}
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [isCodexManagedAgent, currentAgentId]);
|
|
|
|
const agentModelMapRef = useRef(agentModelMap);
|
|
agentModelMapRef.current = agentModelMap;
|
|
|
|
useEffect(() => {
|
|
if (!currentAgentConfig?.acpCommand) return;
|
|
// ACP agents can expose their runtime model catalog during session setup.
|
|
// Codex also exposes model/reasoning selectors through ACP config options,
|
|
// which keeps the picker aligned with the user's installed CLI version.
|
|
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
|
|
|
|
const bridge = getNetcattyBridge();
|
|
if (!bridge?.aiAcpListModels) return;
|
|
|
|
let cancelled = false;
|
|
void bridge.aiAcpListModels(
|
|
currentAgentConfig.acpCommand,
|
|
currentAgentConfig.acpArgs || [],
|
|
undefined,
|
|
undefined,
|
|
`models_${currentAgentId}`,
|
|
).then((result) => {
|
|
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
|
// If the probe came back empty, drop any stale cached catalog for this
|
|
// agent so `agentModelPresets` falls back to the hardcoded presets via
|
|
// the `?? getAgentModelPresets(...)` branch. Without this, a previously
|
|
// successful probe would keep surfacing models the backend no longer
|
|
// advertises.
|
|
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 ACP agent models:', err);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
|
|
|
|
// When Codex is backed by a ~/.codex/config.toml custom provider, the
|
|
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
|
|
// codexCustomConfigResolved (declared above alongside codexConfigModel)
|
|
// stays false until the integration probe confirms this session is
|
|
// custom-config, so we don't flash an empty picker while loading.
|
|
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
|
|
|
const agentModelPresets = useMemo(() => {
|
|
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
|
|
if (hasCodexCustomConfig) {
|
|
if (runtimePresets) {
|
|
return runtimePresets;
|
|
}
|
|
// Config.toml with a pinned model → show just that model.
|
|
if (codexConfigModel) {
|
|
return [{ id: codexConfigModel, name: codexConfigModel }];
|
|
}
|
|
// Config.toml custom provider without a pinned model → codex-acp
|
|
// uses its provider default. Don't surface the OpenAI presets; they
|
|
// wouldn't work. Empty list disables the picker.
|
|
return [];
|
|
}
|
|
return runtimePresets ?? getAgentModelPresets(currentAgentConfig?.command);
|
|
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
|
|
|
|
// Per-agent model: recall last selection or use first preset as default
|
|
const selectedAgentModel = useMemo(() => {
|
|
const stored = agentModelMap[currentAgentId];
|
|
if (stored && modelPresetsContainId(agentModelPresets, stored)) {
|
|
return stored;
|
|
}
|
|
// Default to first preset; for models with thinking levels, use the default level
|
|
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]);
|
|
|
|
// -------------------------------------------------------------------
|
|
// Handlers
|
|
// -------------------------------------------------------------------
|
|
|
|
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]);
|
|
|
|
// -------------------------------------------------------------------
|
|
// Shared helpers for handleSend sub-flows
|
|
// -------------------------------------------------------------------
|
|
|
|
/** 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]);
|
|
|
|
// -------------------------------------------------------------------
|
|
// Main send handler (thin orchestrator)
|
|
// -------------------------------------------------------------------
|
|
|
|
const handleSend = useCallback(async () => {
|
|
const draft = currentDraftRef.current;
|
|
const currentPanelView = panelViewRef.current;
|
|
const currentSessionView = activeSessionRef.current;
|
|
const trimmed = draft?.text.trim() ?? '';
|
|
const sendScopeKey = scopeKey;
|
|
// Double-submit protection currently relies on the draft being cleared
|
|
// immediately after the first send path starts; `isStreaming` alone does
|
|
// not protect the initial draft->session transition.
|
|
if (!trimmed || 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 attachments = (draft?.attachments ?? []).map((file) => ({
|
|
base64Data: file.base64Data,
|
|
mediaType: file.mediaType,
|
|
filename: file.filename,
|
|
filePath: file.filePath,
|
|
}));
|
|
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';
|
|
|
|
// No provider configured for built-in agent
|
|
if (!isExternalAgent && !activeProvider) {
|
|
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, 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;
|
|
}
|
|
|
|
// Add user message
|
|
addMessageToSession(sessionId, {
|
|
id: generateId(), role: 'user', content: trimmed,
|
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
timestamp: Date.now(),
|
|
});
|
|
clearScopeDraft();
|
|
showScopeSessionView(sessionId);
|
|
setActiveSessionId(sessionId);
|
|
setStreamingForScope(sessionId, true);
|
|
|
|
// Create assistant message placeholder with a tracked ID
|
|
const assistantMsgId = generateId();
|
|
addMessageToSession(sessionId, {
|
|
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
|
model: isExternalAgent
|
|
? (selectedAgentModel || agentConfig?.name || 'external')
|
|
: (activeModelId || activeProvider?.defaultModel || ''),
|
|
providerId: isExternalAgent ? undefined : activeProvider?.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, trimmed, agentConfig, abortController, attachments, {
|
|
existingSessionId: existingExternalSessionId,
|
|
updateExternalSessionId: updateSessionExternalSessionId,
|
|
historyMessages: buildAcpHistoryMessagesForBridge(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, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
|
activeProvider,
|
|
activeModelId,
|
|
scopeType,
|
|
scopeTargetId,
|
|
scopeLabel,
|
|
globalPermissionMode,
|
|
commandBlocklist,
|
|
terminalSessions,
|
|
webSearchConfig,
|
|
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
|
autoTitleSession,
|
|
selectedUserSkillSlugs: selectedSkillSlugs,
|
|
}, attachments.length > 0 ? attachments : undefined);
|
|
}
|
|
} finally {
|
|
if (isDraftMode) {
|
|
endDraftSend(draftSendInFlightRef);
|
|
}
|
|
}
|
|
}, [
|
|
isStreaming, activeProvider, 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 handleStop = useCallback(() => {
|
|
if (!activeSessionId) return;
|
|
const controller = abortControllersRef.current.get(activeSessionId);
|
|
controller?.abort();
|
|
abortControllersRef.current.delete(activeSessionId);
|
|
setStreamingForScope(activeSessionId, false);
|
|
// Clear statusText on the last message so stale status indicators disappear
|
|
updateLastMessage(activeSessionId, msg => ({
|
|
...msg,
|
|
statusText: '',
|
|
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
|
|
}));
|
|
// Clear pending approvals for this session (so tool execute functions don't hang)
|
|
clearAllPendingApprovals(activeSessionId);
|
|
// Cancel in-flight command executions (Catty Agent + ACP Agent)
|
|
const bridge = getNetcattyBridge();
|
|
bridge?.aiCattyCancelExec?.(activeSessionId);
|
|
bridge?.aiAcpCancel?.('', activeSessionId);
|
|
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
|
|
|
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();
|
|
deleteSession(sessionId, scopeKey);
|
|
// Active session clearing is handled by deleteSession with scopeKey
|
|
},
|
|
[deleteSession, scopeKey],
|
|
);
|
|
|
|
const handleAgentChange = useCallback((agentId: string) => {
|
|
showScopeDraftView();
|
|
ensureScopeDraft(agentId);
|
|
updateScopeDraft(agentId, (draft) => ({
|
|
...selectDraftForAgentSwitch(
|
|
draft,
|
|
agentId,
|
|
Boolean(activeSessionRef.current?.messages.length),
|
|
),
|
|
}));
|
|
setShowHistory(false);
|
|
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
|
|
|
// -------------------------------------------------------------------
|
|
// Render
|
|
// -------------------------------------------------------------------
|
|
|
|
if (!isVisible) return null;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
|
|
{/* ── Header ── */}
|
|
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
|
<AgentSelector
|
|
currentAgentId={currentAgentId}
|
|
externalAgents={externalAgents}
|
|
discoveredAgents={discoveredAgents}
|
|
isDiscovering={isDiscovering}
|
|
onSelectAgent={handleAgentChange}
|
|
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
|
onRediscover={rediscover}
|
|
onManageAgents={handleOpenSettings}
|
|
/>
|
|
<div className="flex items-center gap-0.5">
|
|
<ConversationExport
|
|
session={activeSession}
|
|
onExport={handleExport}
|
|
/>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
|
onClick={() => setShowHistory(!showHistory)}
|
|
>
|
|
<History size={14} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
|
onClick={handleNewChat}
|
|
>
|
|
<Plus size={15} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Main content ── */}
|
|
{showHistory ? (
|
|
<SessionHistoryDrawer
|
|
sessions={historySessions}
|
|
activeSessionId={activeSessionId}
|
|
onSelect={handleSelectSession}
|
|
onDelete={handleDeleteSession}
|
|
onClose={() => setShowHistory(false)}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* Chat messages */}
|
|
<ChatMessageList
|
|
messages={messages}
|
|
isStreaming={isStreaming}
|
|
activeSessionId={activeSessionId}
|
|
/>
|
|
|
|
{/* Recent sessions (Zed-style, shown when no messages) */}
|
|
{messages.length === 0 && historySessions.length > 0 && (
|
|
<div className="shrink-0 px-4 pb-1">
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
|
<button
|
|
onClick={() => setShowHistory(true)}
|
|
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
|
>
|
|
{t('ai.chat.viewAll')}
|
|
</button>
|
|
</div>
|
|
{historySessions.slice(0, 3).map((session) => (
|
|
<button
|
|
key={session.id}
|
|
onClick={() => handleSelectSession(session.id)}
|
|
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
|
>
|
|
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
|
{session.title || t('ai.chat.untitled')}
|
|
</span>
|
|
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
|
{formatRelativeTime(new Date(session.updatedAt), t)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Input area */}
|
|
<ChatInput
|
|
value={inputValue}
|
|
onChange={setInputValue}
|
|
onSend={handleSend}
|
|
onStop={handleStop}
|
|
isStreaming={isStreaming}
|
|
disabled={!canSendCurrentAgent}
|
|
providerName={providerDisplayName}
|
|
modelName={modelDisplayName}
|
|
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
|
modelPresets={agentModelPresets}
|
|
selectedModelId={selectedAgentModel}
|
|
onModelSelect={handleAgentModelSelect}
|
|
files={files}
|
|
onAddFiles={addFiles}
|
|
onRemoveFile={removeFile}
|
|
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
|
selectedUserSkills={selectedUserSkills}
|
|
userSkills={userSkillOptions}
|
|
onAddUserSkill={addSelectedUserSkill}
|
|
onRemoveUserSkill={removeSelectedUserSkill}
|
|
permissionMode={globalPermissionMode}
|
|
onPermissionModeChange={setGlobalPermissionMode}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// -------------------------------------------------------------------
|
|
// Session History Drawer
|
|
// -------------------------------------------------------------------
|
|
|
|
interface SessionHistoryDrawerProps {
|
|
sessions: AISession[];
|
|
activeSessionId: string | null;
|
|
onSelect: (sessionId: string) => void;
|
|
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
|
sessions,
|
|
activeSessionId,
|
|
onSelect,
|
|
onDelete,
|
|
onClose,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
return (
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
|
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
<ScrollArea className="flex-1">
|
|
<div className="px-3">
|
|
{sessions.length === 0 ? (
|
|
<div className="py-12 text-center">
|
|
<p className="text-[13px] text-muted-foreground/40">
|
|
{t('ai.chat.noSessions')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
sessions.map((session) => {
|
|
const isActive = session.id === activeSessionId;
|
|
const time = new Date(session.updatedAt);
|
|
const timeStr = formatRelativeTime(time, t);
|
|
|
|
return (
|
|
<div
|
|
key={session.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onSelect(session.id)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
|
|
className={cn(
|
|
SESSION_HISTORY_ROW_CLASSNAMES.row,
|
|
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
|
)}
|
|
>
|
|
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
|
{session.title || t('ai.chat.untitled')}
|
|
</span>
|
|
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
|
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
|
{timeStr}
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={(e) => onDelete(e, session.id)}
|
|
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('common.delete')}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// -------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------
|
|
|
|
function formatRelativeTime(date: Date, t: (key: string) => string): string {
|
|
const now = Date.now();
|
|
const diff = now - date.getTime();
|
|
const minutes = Math.floor(diff / 60_000);
|
|
const hours = Math.floor(diff / 3_600_000);
|
|
const days = Math.floor(diff / 86_400_000);
|
|
|
|
if (minutes < 1) return t('ai.chat.justNow');
|
|
if (minutes < 60) return t('ai.chat.minutesAgo').replace('{n}', String(minutes));
|
|
if (hours < 24) return t('ai.chat.hoursAgo').replace('{n}', String(hours));
|
|
if (days < 7) return t('ai.chat.daysAgo').replace('{n}', String(days));
|
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Export
|
|
// -------------------------------------------------------------------
|
|
|
|
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
|
|
AIChatSidePanel.displayName = 'AIChatSidePanel';
|
|
|
|
export default AIChatSidePanel;
|
|
export { AIChatSidePanel };
|
|
export type { AIChatSidePanelProps };
|