Files
Netcatty/components/AIChatPanelContent.tsx
T
陈大猫 2029b21e0b feat(ai): improve Catty context budget, step guards, and compaction UX
* Improve Catty context budget, step guards, and compaction reliability.

Anchors session state across compactions and surfaces compaction to the UI so long tool-heavy turns stay within context limits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Codex review: gate reinjection and match sftp_read_file.

Only reinject session context after actual compaction adjustments, and recognize the generated sftp_read_file tool name in stale read pruning.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Scope stale pruning by session for SFTP reads and terminals.

Include sessionId in read fingerprints and keep the last two terminal outputs per session instead of globally across the conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap output reserve for small context windows in budget math.

Prevent default 4096-token output reserve from collapsing compaction thresholds on 4k/8k models into near-zero values.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Gate terminal stale pruning on budget pressure and clear old assistant status.

Only omit older terminal_execute output when compaction budget is tight, and clear step-compaction statusText from the prior assistant when a new assistant message starts after tool results.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Gate SFTP read supersession on budget pressure like terminal pruning.

Preserve earlier file reads during read-edit-reread compare flows until context compaction is actually needed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use compaction summary budget for pre-summarize pruning.

Reserve the 1600-token summary output when fitting messages for LLM compaction, and skip superseding successful reads when a later read failed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Prune oldest messages first when fitting compaction summarize input.

Keep the portion of pre-tail history closest to the current task instead of dropping recent summarize candidates from the end.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Detect JSON tool errors before superseding stale reads.

Treat structured error payloads as failed reads so a later SFTP failure does not replace the last successful file contents.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Show compaction as a transient inline shimmer instead of a banner.

Emit compaction_start/compaction events and render a single ephemeral status line in the message list that clears when compaction finishes.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 14:35:44 +08:00

302 lines
11 KiB
TypeScript

import React, { type Dispatch, type SetStateAction } from 'react';
import { History, Plus } from 'lucide-react';
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
import type { Host, VaultNote } from '../types';
import type { UserSkillOption } from './ai/userSkillsState';
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
import { Button } from './ui/button';
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 { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
import {
getAIPanelDiagnosticHiddenParts,
getAIPanelProfilerProps,
isAIPanelDiagnosticPartHidden,
} from './ai/aiPanelDiagnostics';
type Translate = (key: string) => string;
type ExportFormat = 'md' | 'json' | 'txt';
type TerminalSessionSummary = {
sessionId: string;
hostname: string;
label: string;
connected: boolean;
};
interface AIChatPanelContentProps {
t: Translate;
currentAgentId: string;
externalAgents: ExternalAgentConfig[];
discoveredAgents: DiscoveredAgent[];
isDiscovering: boolean;
handleAgentChange: (agentId: string) => void;
handleEnableDiscoveredAgent: (agent: DiscoveredAgent) => void;
rediscover: () => void;
handleOpenSettings: () => void;
activeSession: AISession | null;
handleExport: (format: ExportFormat) => void;
showHistory: boolean;
setShowHistory: Dispatch<SetStateAction<boolean>>;
handleNewChat: () => void;
historySessions: AISession[];
activeSessionId: string | null;
handleSelectSession: (sessionId: string) => void;
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
messages: ChatMessage[];
isStreaming: boolean;
activeCompaction?: import('./ai/hooks/useAgentCompactionUi').ActiveCompactionUi | null;
inputValue: string;
setInputValue: (value: string) => void;
handleSend: () => void;
handleStop: () => void;
canSendCurrentAgent: boolean;
providerDisplayName?: string;
modelDisplayName?: string;
agentModelPresets: AgentModelPreset[];
selectedAgentModel: string;
handleAgentModelSelect: (modelId: string) => void;
cattyConfiguredProviders: ProviderConfig[];
effectiveActiveProvider?: ProviderConfig;
effectiveActiveModelId?: string;
handleAgentProviderModelSelect: (providerId: string, modelId: string) => void;
files: UploadedFile[];
addFiles: (inputFiles: File[]) => Promise<void>;
removeFile: (fileId: string) => void;
terminalSessions: TerminalSessionSummary[];
selectedUserSkills: UserSkillOption[];
userSkillOptions: UserSkillOption[];
quickMessages: AIQuickMessage[];
addSelectedUserSkill: (slug: string) => void;
removeSelectedUserSkill: (slug: string) => void;
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
notes?: VaultNote[];
hosts?: Host[];
onOpenVaultNote?: (noteId: string) => void;
onOpenVaultHost?: (hostId: string) => void;
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
}
export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
t,
currentAgentId,
externalAgents,
discoveredAgents,
isDiscovering,
handleAgentChange,
handleEnableDiscoveredAgent,
rediscover,
handleOpenSettings,
activeSession,
handleExport,
showHistory,
setShowHistory,
handleNewChat,
historySessions,
activeSessionId,
handleSelectSession,
handleDeleteSession,
messages,
isStreaming,
activeCompaction = null,
inputValue,
setInputValue,
handleSend,
handleStop,
canSendCurrentAgent,
providerDisplayName,
modelDisplayName,
agentModelPresets,
selectedAgentModel,
handleAgentModelSelect,
cattyConfiguredProviders,
effectiveActiveProvider,
effectiveActiveModelId,
handleAgentProviderModelSelect,
files,
addFiles,
removeFile,
terminalSessions,
selectedUserSkills,
userSkillOptions,
quickMessages,
addSelectedUserSkill,
removeSelectedUserSkill,
globalPermissionMode,
setGlobalPermissionMode,
notes = [],
hosts = [],
onOpenVaultNote,
onOpenVaultHost,
onOpenVaultSection,
}) => {
const hiddenParts = getAIPanelDiagnosticHiddenParts();
const hideHeader = isAIPanelDiagnosticPartHidden('header', hiddenParts);
const hideHistory = isAIPanelDiagnosticPartHidden('history', hiddenParts);
const hideMessages = isAIPanelDiagnosticPartHidden('messages', hiddenParts);
const hideRecent = isAIPanelDiagnosticPartHidden('recent', hiddenParts);
const hideInput = isAIPanelDiagnosticPartHidden('input', hiddenParts);
return (
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
{/* ── Header ── */}
{!hideHeader && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.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>
</React.Profiler>
)}
{/* ── Main content ── */}
{showHistory && !hideHistory ? (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.History')}>
<SessionHistoryDrawer
sessions={historySessions}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onClose={() => setShowHistory(false)}
/>
</React.Profiler>
) : (
<>
{/* Chat messages */}
{!hideMessages && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Messages')}>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
activeSessionId={activeSessionId}
activeCompaction={activeCompaction}
notes={notes}
hosts={hosts}
onOpenVaultNote={onOpenVaultNote}
onOpenVaultHost={onOpenVaultHost}
onOpenVaultSection={onOpenVaultSection}
/>
</React.Profiler>
)}
{/* Recent sessions (Zed-style, shown when no messages) */}
{messages.length === 0 && historySessions.length > 0 && !hideRecent && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Recent')}>
<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>
</React.Profiler>
)}
{/* Input area */}
{!hideInput && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
<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}
providerSwitcher={
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
? {
providers: cattyConfiguredProviders,
selectedProviderId: effectiveActiveProvider?.id,
selectedModelId: effectiveActiveModelId || undefined,
onSelect: handleAgentProviderModelSelect,
}
: undefined
}
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}
quickMessages={quickMessages}
onAddUserSkill={addSelectedUserSkill}
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>
</React.Profiler>
)}
</>
)}
</div>
);
};