mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
2029b21e0b
* 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>
302 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|