feat: added conversation history feature

This commit is contained in:
Xiaohan-Tian
2026-06-03 22:56:05 -07:00
parent 5c6e1d69c5
commit a3a0f2f4ad
19 changed files with 1271 additions and 90 deletions
+119 -18
View File
@@ -176,6 +176,125 @@
gap: 12px;
}
.chatbox-history-panel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.chatbox-history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 8px;
border-bottom: 1px solid #3a3a3a;
}
.chatbox-history-header h4 {
margin: 0;
font-size: 12px;
color: #e0e0e0;
}
.chatbox-history-cancel {
background: transparent;
border: none;
color: #9fc7ea;
cursor: pointer;
font-size: 11px;
}
.chatbox-history-list {
flex: 1;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chatbox-history-item {
width: 100%;
background: #262626;
border: 1px solid #3a3a3a;
border-radius: 8px;
padding: 10px 12px;
color: #e0e0e0;
}
.chatbox-history-item:hover {
background: #2d2d2d;
border-color: #4a4a4a;
}
.chatbox-history-item-toprow {
display: flex;
align-items: center;
gap: 8px;
}
.chatbox-history-open-btn {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: inherit;
text-align: left;
padding: 0;
cursor: pointer;
}
.chatbox-history-open-btn-body {
display: block;
margin-top: 4px;
}
.chatbox-history-item-title {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chatbox-history-item-meta {
font-size: 10px;
color: #9a9a9a;
margin-top: 4px;
}
.chatbox-history-delete-btn {
background: transparent;
border: none;
color: #b7b7b7;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
flex: 0 0 auto;
}
.chatbox-history-delete-btn:hover {
color: #e17070;
}
.chatbox-history-item-preview {
font-size: 11px;
color: #c8c8c8;
margin-top: 6px;
line-height: 1.4;
}
.chatbox-history-empty {
color: #999;
font-size: 11px;
padding: 16px 6px;
}
.chatbox-todo-card {
background: linear-gradient(180deg, #252525 0%, #202020 100%);
border: 1px solid #3a3a3a;
@@ -441,24 +560,6 @@
min-height: 32px;
}
.message-tool-confirmation-btn-always.dialog-btn-primary {
background-color: #5aa36a;
}
.message-tool-confirmation-btn-always.dialog-btn-primary:hover {
background-color: #4a935a;
box-shadow: 0 4px 12px rgba(90, 163, 106, 0.3);
}
.message-tool-confirmation-btn-deny.dialog-btn-primary {
background-color: #c96a6a;
}
.message-tool-confirmation-btn-deny.dialog-btn-primary:hover {
background-color: #b85b5b;
box-shadow: 0 4px 12px rgba(201, 106, 106, 0.3);
}
.message-tool-summary-content > :first-child {
margin-top: 0;
}
+190 -2
View File
@@ -14,6 +14,8 @@ const {
streamProcessorCallbacks,
clearChatHistoryAndUIMock,
projectStoreState,
conversationStorageMock,
showConfirmMock,
} = vi.hoisted(() => ({
agentCoreMock: {
setLLMProvider: vi.fn(),
@@ -21,9 +23,12 @@ const {
abortCurrentRequest: vi.fn(),
getAgentState: vi.fn(() => ({
getMessages: vi.fn(() => []),
getFullMessages: vi.fn(() => []),
getConversationId: vi.fn(() => 'conv_test'),
getTodos: vi.fn(() => []),
subscribeTodoChanges: vi.fn(() => () => undefined),
})),
restoreConversation: vi.fn(),
compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })),
shouldCompactBeforeNextTurn: vi.fn(async () => false),
},
@@ -31,11 +36,20 @@ const {
processStreamMock: vi.fn(async () => ''),
clearChatHistoryAndUIMock: vi.fn(),
projectStoreState: {
projectName: 'Test Project',
toolFastForwardEnabled: false,
setStatus: vi.fn(),
setToolFastForwardEnabled: vi.fn(),
toggleToolFastForwardEnabled: vi.fn(),
},
conversationStorageMock: {
initialize: vi.fn(async () => undefined),
saveConversation: vi.fn(async () => undefined),
loadConversation: vi.fn(async () => null),
listConversations: vi.fn(async () => []),
deleteConversation: vi.fn(async () => undefined),
},
showConfirmMock: vi.fn(async () => true),
streamProcessorCallbacks: {
onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined,
onMessageUpdate: undefined as ((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void) | undefined,
@@ -181,6 +195,20 @@ vi.mock('../util/localLLMConfig', () => ({
LOCAL_LLM_PROVIDER_KEY: 'local_browser',
}));
vi.mock('../core/io/KGConversationStorage', () => ({
KGConversationStorage: {
getInstance: () => conversationStorageMock,
},
}));
vi.mock('../util/dialogUtil', async () => {
const actual = await vi.importActual('../util/dialogUtil');
return {
...actual,
showConfirm: showConfirmMock,
};
});
vi.mock('./common/KGDropdown', () => ({
default: () => null,
}));
@@ -209,16 +237,32 @@ describe('ChatBox', () => {
processUserMessageMock.mockReset();
processStreamMock.mockClear();
clearChatHistoryAndUIMock.mockClear();
conversationStorageMock.initialize.mockClear();
conversationStorageMock.saveConversation.mockClear();
conversationStorageMock.loadConversation.mockClear();
conversationStorageMock.listConversations.mockClear();
conversationStorageMock.deleteConversation.mockClear();
showConfirmMock.mockClear();
showConfirmMock.mockResolvedValue(true);
agentCoreMock.compactConversation.mockClear();
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
agentCoreMock.restoreConversation.mockClear();
streamProcessorCallbacks.onMessageAdd = undefined;
streamProcessorCallbacks.onMessageUpdate = undefined;
streamProcessorCallbacks.onMessageRemove = undefined;
streamProcessorCallbacks.onProcessingChange = undefined;
projectStoreState.projectName = 'Test Project';
projectStoreState.toolFastForwardEnabled = false;
projectStoreState.setStatus.mockClear();
projectStoreState.setToolFastForwardEnabled.mockClear();
projectStoreState.toggleToolFastForwardEnabled.mockClear();
agentCoreMock.getAgentState.mockReturnValue({
getMessages: vi.fn(() => []),
getFullMessages: vi.fn(() => []),
getConversationId: vi.fn(() => 'conv_test'),
getTodos: vi.fn(() => []),
subscribeTodoChanges: vi.fn(() => () => undefined),
});
});
it('renders the English assistant title under en_us', () => {
@@ -436,7 +480,7 @@ describe('ChatBox', () => {
expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'true');
});
it('resets fast-forward through the shared new chat clear path', () => {
it('resets fast-forward through the shared new chat clear path', async () => {
projectStoreState.toolFastForwardEnabled = true;
clearChatHistoryAndUIMock.mockImplementation(() => {
projectStoreState.setToolFastForwardEnabled(false);
@@ -458,7 +502,151 @@ describe('ChatBox', () => {
</I18nContext.Provider>,
);
expect(clearChatHistoryAndUIMock).toHaveBeenCalled();
await waitFor(() => {
expect(clearChatHistoryAndUIMock).toHaveBeenCalled();
});
rerender(
<I18nContext.Provider
value={{
languageSetting: 'en_us',
resolvedLocale: 'en_us',
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, 'en_us'),
}}
>
<ChatBox isVisible={true} />
</I18nContext.Provider>,
);
expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'false');
});
it('autosaves a completed conversation after sending', async () => {
agentCoreMock.getAgentState.mockReturnValue({
getMessages: vi.fn(() => [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }]),
getFullMessages: vi.fn(() => [
{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 },
{ id: 'm2', role: 'assistant', content: 'world', timestamp: 2 },
]),
getConversationId: vi.fn(() => 'conv_saved'),
getTodos: vi.fn(() => []),
subscribeTodoChanges: vi.fn(() => () => undefined),
});
processUserMessageMock.mockResolvedValue({
displayUserMessage: true,
sendToLLM: true,
finalMessageForLLM: 'hello',
pseudoAssistantResponse: null,
metadata: null,
});
processStreamMock.mockImplementation(async () => {
streamProcessorCallbacks.onMessageAdd?.({ id: 'assistant-1', role: 'assistant', content: 'world' });
return 'world';
});
renderWithLocale('en_us');
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
fireEvent.change(input, { target: { value: 'hello' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
await waitFor(() => {
expect(conversationStorageMock.saveConversation).toHaveBeenCalledTimes(1);
});
const persistedDocument = ((conversationStorageMock.saveConversation.mock.calls[0] as unknown) as [string, { displayTranscript: ChatMessage[] }])[1];
expect(persistedDocument.displayTranscript).toEqual([
expect.objectContaining({ role: 'user', content: 'hello' }),
expect.objectContaining({ role: 'assistant', content: 'world' }),
]);
});
it('loads and restores a selected saved conversation from history', async () => {
conversationStorageMock.listConversations.mockResolvedValue([
{
conversationId: 'conv_old',
title: 'Earlier conversation',
createdAt: 1,
updatedAt: 2,
lastTurnAt: 2,
messageCount: 2,
preview: 'Preview',
},
] as never);
conversationStorageMock.loadConversation.mockResolvedValue({
meta: {
conversationId: 'conv_old',
title: 'Earlier conversation',
createdAt: 1,
updatedAt: 2,
lastTurnAt: 2,
messageCount: 2,
preview: 'Preview',
},
document: {
version: 1,
conversationId: 'conv_old',
continuationState: {
messages: [{ id: 'a', role: 'user', content: 'prompt', timestamp: 1 }],
todos: [],
},
fullHistory: {
messages: [
{ id: 'a', role: 'user', content: 'prompt', timestamp: 1 },
{ id: 'b', role: 'assistant', content: 'reply', timestamp: 2 },
],
},
displayTranscript: [
{ id: 'display-a', role: 'user', content: 'prompt' },
{ id: 'display-b', role: 'assistant', content: 'reply' },
],
},
} as never);
renderWithLocale('en_us');
fireEvent.click(screen.getByTitle('Conversation history'));
await waitFor(() => {
expect(screen.getByText('Earlier conversation')).toBeTruthy();
});
fireEvent.click(screen.getByText('Earlier conversation'));
await waitFor(() => {
expect(agentCoreMock.restoreConversation).toHaveBeenCalledTimes(1);
expect(screen.getByText('prompt')).toBeTruthy();
expect(screen.getByText('reply')).toBeTruthy();
});
});
it('deletes a saved conversation after confirmation', async () => {
conversationStorageMock.listConversations.mockResolvedValue([
{
conversationId: 'conv_old',
title: 'Earlier conversation',
createdAt: 1,
updatedAt: 2,
lastTurnAt: 2,
messageCount: 2,
preview: 'Preview',
},
] as never);
renderWithLocale('en_us');
fireEvent.click(screen.getByTitle('Conversation history'));
await waitFor(() => {
expect(screen.getByText('Earlier conversation')).toBeTruthy();
});
fireEvent.click(screen.getByLabelText('Delete'));
await waitFor(() => {
expect(showConfirmMock).toHaveBeenCalledTimes(1);
expect(conversationStorageMock.deleteConversation).toHaveBeenCalledWith('Test Project', 'conv_old');
expect(screen.queryByText('Earlier conversation')).toBeNull();
});
});
});
+367 -38
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
import './ChatBox.css';
import { FaPlus, FaDownload, FaForward } from 'react-icons/fa';
import { FaPlus, FaDownload, FaForward, FaHistory, FaTrash } from 'react-icons/fa';
import { UserMessage, AssistantMessage } from './chat';
import { AgentCore } from '../agent/core/AgentCore';
import { summarizeTodoCounts } from '../agent/core/todo';
@@ -19,12 +19,18 @@ import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMM
import { LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY } from '../util/localLLMConfig';
import KGDropdown from './common/KGDropdown';
import { useI18n } from '../i18n/useI18n';
import { KGConversationStorage } from '../core/io/KGConversationStorage';
import { SAVED_CONVERSATION_VERSION, type SavedConversationDocument, type SavedConversationMeta } from '../types/conversationTypes';
import type { Message } from '../agent/core/AgentState';
import { showConfirm } from '../util/dialogUtil';
import type { ChatMessage } from '../types/projectTypes';
// Module-level guard to avoid duplicate welcome in React StrictMode dev remounts
let hasShownWelcomeOnceInRuntime = false;
const TODO_TOOL_NAME = 'update_todo_list';
const HISTORY_TITLE_MAX_LENGTH = 48;
const HISTORY_PREVIEW_MAX_LENGTH = 96;
const isCompletedTodoSnapshotMessage = (message: ChatMessage): boolean => {
if (message.toolName !== TODO_TOOL_NAME || !Array.isArray(message.todoSnapshot)) {
@@ -35,6 +41,77 @@ const isCompletedTodoSnapshotMessage = (message: ChatMessage): boolean => {
return counts.total > 0 && counts.completed === counts.total;
};
const truncateWithEllipsis = (value: string, maxLength: number): string => {
const normalized = value.trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
};
const stripDisplayText = (value: string | null | undefined): string => {
if (!value) {
return '';
}
return value
.replace(/<[^>]+>/g, ' ')
.replace(/[`*_>#-]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
};
const extractFirstLine = (value: string | null | undefined): string => {
if (!value) {
return '';
}
const [firstLine] = value.split(/\r?\n/, 1);
return stripDisplayText(firstLine);
};
const toDurableChatMessage = (message: ChatMessage): ChatMessage | null => {
if (message.isStreaming || message.toolConfirmation) {
return null;
}
const { onToolConfirmationDecision, ...persisted } = message;
return persisted;
};
const buildConversationMeta = (
conversationId: string,
fullHistoryMessages: Message[],
displayTranscript: ChatMessage[],
): SavedConversationMeta => {
const firstDisplayUserMessage = displayTranscript.find((message) => (
message.role === 'user' && stripDisplayText(message.content)
));
const firstUserMessage = firstDisplayUserMessage
?? fullHistoryMessages.find((message) => message.role === 'user' && stripDisplayText(message.content));
const previewSource = [...displayTranscript]
.reverse()
.map((message) => stripDisplayText(message.content))
.find(Boolean)
|| [...fullHistoryMessages]
.reverse()
.map((message) => stripDisplayText(message.content))
.find(Boolean)
|| '';
const createdAt = fullHistoryMessages[0]?.timestamp ?? Date.now();
const lastTurnAt = fullHistoryMessages[fullHistoryMessages.length - 1]?.timestamp ?? createdAt;
return {
conversationId,
title: truncateWithEllipsis(extractFirstLine(firstUserMessage?.content) || 'Untitled conversation', HISTORY_TITLE_MAX_LENGTH),
createdAt,
updatedAt: Date.now(),
lastTurnAt,
messageCount: fullHistoryMessages.length,
preview: truncateWithEllipsis(previewSource, HISTORY_PREVIEW_MAX_LENGTH),
};
};
/**
* Create the LLM provider from current configuration
*/
@@ -78,6 +155,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const { t } = useI18n();
const toolFastForwardEnabled = useProjectStore((state) => state.toolFastForwardEnabled);
const toggleToolFastForwardEnabled = useProjectStore((state) => state.toggleToolFastForwardEnabled);
const projectName = useProjectStore((state) => state.projectName);
const [inputValue, setInputValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -93,10 +171,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
// Export dropdown state and options
const [showExportDropdown, setShowExportDropdown] = useState(false);
const [showHistoryPanel, setShowHistoryPanel] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [conversationHistory, setConversationHistory] = useState<SavedConversationMeta[]>([]);
const exportOptions = [
'Export conversation as JSON',
'Export conversation as Markdown'
];
const lastPersistedConversationKeyRef = useRef<string | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
const handleExportOptionSelect = (option: string) => {
if (option === 'Export conversation as JSON') {
@@ -160,7 +243,11 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
// Message update callbacks for stream processor
const handleMessageUpdate = useCallback((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
setMessages(prev => prev.map(msg => msg.id === messageId ? updater(msg) : msg));
setMessages(prev => {
const next = prev.map(msg => msg.id === messageId ? updater(msg) : msg);
messagesRef.current = next;
return next;
});
}, []);
const handleMessageAdd = useCallback((message: ChatMessage) => {
@@ -171,15 +258,23 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|| !Array.isArray(existingMessage.todoSnapshot)
|| isCompletedTodoSnapshotMessage(existingMessage)
));
return [...preservedMessages, message];
const next = [...preservedMessages, message];
messagesRef.current = next;
return next;
}
return [...prev, message];
const next = [...prev, message];
messagesRef.current = next;
return next;
});
}, []);
const handleMessageRemove = useCallback((messageId: string) => {
setMessages(prev => prev.filter(msg => msg.id !== messageId));
setMessages(prev => {
const next = prev.filter(msg => msg.id !== messageId);
messagesRef.current = next;
return next;
});
}, []);
const handleProcessingChange = useCallback((processing: boolean) => {
@@ -195,15 +290,151 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
});
const clearChatUI = useCallback(async () => {
messagesRef.current = [];
setMessages([]);
setIsFirstMessage(true);
setShowHistoryPanel(false);
const welcomeMessage = await addWelcomeMessage();
if (welcomeMessage) {
messagesRef.current = [welcomeMessage];
setMessages([welcomeMessage]);
}
}, []);
const buildSavedConversationDocument = useCallback((displayMessages: ChatMessage[]): SavedConversationDocument | null => {
const agentState = AgentCore.instance().getAgentState();
const fullHistoryMessages = agentState.getFullMessages();
if (fullHistoryMessages.length === 0) {
return null;
}
const durableTranscript = displayMessages
.map(toDurableChatMessage)
.filter((message): message is ChatMessage => message !== null);
const transcriptToPersist = durableTranscript.length > 0
? durableTranscript
: fullHistoryMessages
.filter((message): message is Message & { role: 'user' | 'assistant' } => (
message.role === 'user' || message.role === 'assistant'
))
.map((message) => ({
id: message.id,
role: message.role,
content: message.content ?? '',
}));
return {
version: SAVED_CONVERSATION_VERSION,
conversationId: agentState.getConversationId(),
continuationState: {
messages: agentState.getMessages(),
todos: agentState.getTodos(),
},
fullHistory: {
messages: fullHistoryMessages,
},
displayTranscript: transcriptToPersist,
};
}, []);
const persistConversationDocument = useCallback(async (displayMessages: ChatMessage[]) => {
if (!projectName) {
return;
}
const document = buildSavedConversationDocument(displayMessages);
if (!document) {
lastPersistedConversationKeyRef.current = null;
return;
}
const fingerprint = JSON.stringify(document);
if (lastPersistedConversationKeyRef.current === fingerprint) {
return;
}
const meta = buildConversationMeta(
document.conversationId,
document.fullHistory.messages,
document.displayTranscript,
);
const storage = KGConversationStorage.getInstance();
await storage.initialize();
await storage.saveConversation(projectName, document, meta);
lastPersistedConversationKeyRef.current = fingerprint;
}, [buildSavedConversationDocument, projectName]);
const loadConversationHistory = useCallback(async () => {
if (!projectName) {
setConversationHistory([]);
return;
}
setIsLoadingHistory(true);
try {
const storage = KGConversationStorage.getInstance();
await storage.initialize();
setConversationHistory(await storage.listConversations(projectName));
} finally {
setIsLoadingHistory(false);
}
}, [projectName]);
const handleOpenHistoryPanel = useCallback(async () => {
await loadConversationHistory();
setShowHistoryPanel(true);
}, [loadConversationHistory]);
const handleRestoreConversation = useCallback(async (conversationId: string) => {
await persistConversationDocument(messagesRef.current);
if (!projectName) {
return;
}
const storage = KGConversationStorage.getInstance();
await storage.initialize();
const savedConversation = await storage.loadConversation(projectName, conversationId);
if (!savedConversation) {
return;
}
AgentCore.instance().restoreConversation(savedConversation.document);
messagesRef.current = savedConversation.document.displayTranscript;
setMessages(savedConversation.document.displayTranscript);
setInputValue('');
setLastUserMessage('');
setIsFirstMessage(savedConversation.document.fullHistory.messages.length === 0);
setShowHistoryPanel(false);
lastPersistedConversationKeyRef.current = JSON.stringify(savedConversation.document);
}, [persistConversationDocument, projectName]);
const handleDeleteConversation = useCallback(async (conversationId: string, title: string) => {
const confirmed = await showConfirm(
t('chatbox.history.deleteConfirm', { title }),
{
confirmLabel: t('chatbox.history.delete'),
cancelLabel: t('chatbox.history.cancel'),
},
);
if (!confirmed || !projectName) {
return;
}
const storage = KGConversationStorage.getInstance();
await storage.initialize();
await storage.deleteConversation(projectName, conversationId);
setConversationHistory((prev) => prev.filter((conversation) => conversation.conversationId !== conversationId));
}, [projectName, t]);
const handleStartNewChat = useCallback(async () => {
await persistConversationDocument(messagesRef.current);
const { setStatus } = useProjectStore.getState();
clearChatHistoryAndUI(setStatus);
lastPersistedConversationKeyRef.current = null;
}, [persistConversationDocument]);
// Initialize AgentCore with configured provider and register clear UI callback
useEffect(() => {
const initializeProvider = async () => {
@@ -250,6 +481,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const welcomeMessage = await addWelcomeMessage();
if (welcomeMessage) {
messagesRef.current = [welcomeMessage];
setMessages([welcomeMessage]);
}
})();
@@ -262,6 +494,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
};
}, [clearChatUI]);
useEffect(() => {
lastPersistedConversationKeyRef.current = null;
}, [projectName]);
const handleAbort = () => {
const controller = streamProcessor.abortController;
if (controller) {
@@ -270,17 +506,16 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const agentCore = AgentCore.instance();
const userMessageContent = agentCore.abortCurrentRequest();
setMessages(prev => prev.slice(0, -2));
setMessages(prev => {
const next = prev.slice(0, -2);
messagesRef.current = next;
return next;
});
setInputValue(userMessageContent || lastUserMessage);
setIsProcessing(false);
}
};
const handleClearCommand = () => {
const { setStatus } = useProjectStore.getState();
clearChatHistoryAndUI(setStatus);
};
const runCompactionWithStatus = useCallback(async (
trigger: 'manual' | 'auto',
focus?: string,
@@ -313,6 +548,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
content: result.changed ? 'Conversation Compacted' : 'Nothing to Compact Yet',
}));
handleMessageRemove(progressMessage.id);
if (result.changed) {
const nextMessages = messages
.filter((message) => message.id !== progressMessage.id)
.map((message) => message.id === statusMessage.id
? { ...message, content: 'Conversation Compacted' }
: message);
await persistConversationDocument(nextMessages);
}
return result.changed;
} catch (error) {
console.error('Conversation compaction failed:', error);
@@ -325,7 +568,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} finally {
setIsCompacting(false);
}
}, [handleMessageAdd, handleMessageRemove, handleMessageUpdate]);
}, [handleMessageAdd, handleMessageRemove, handleMessageUpdate, persistConversationDocument]);
const sendWithCompactionRecovery = useCallback(async (
llmInput: string,
@@ -357,6 +600,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
setLastUserMessage(userMessage);
setInputValue('');
if (/^\/clear(\s|$)/i.test(userMessage)) {
await persistConversationDocument(messagesRef.current);
}
const filterResult = await processUserMessage(userMessage);
if (filterResult.displayUserMessage) {
@@ -404,6 +651,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
}
await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage);
await persistConversationDocument(messagesRef.current);
}
};
@@ -457,6 +705,22 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<div className="chatbox-header">
<h3>{t('assistant.displayName')}</h3>
<div className="chatbox-actions">
<button
type="button"
title={t('chatbox.history.title')}
aria-pressed={showHistoryPanel}
onClick={() => {
if (showHistoryPanel) {
setShowHistoryPanel(false);
} else {
void handleOpenHistoryPanel();
}
}}
className={`chatbox-action-btn chatbox-toggle-btn ${showHistoryPanel ? 'is-active' : ''}`}
disabled={isProcessing || isCompacting}
>
<FaHistory />
</button>
<button
type="button"
title={t('chatbox.fastForward.title')}
@@ -491,8 +755,9 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<button
type="button"
title="New Chat"
onClick={handleClearCommand}
onClick={() => { void handleStartNewChat(); }}
className="chatbox-action-btn"
disabled={isProcessing || isCompacting}
>
<FaPlus />
</button>
@@ -538,31 +803,95 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
</div>
)}
<div className="chatbox-messages">
{messages.map((message) => (
message.role === 'user' ? (
<UserMessage key={message.id} content={message.content} />
) : (
<AssistantMessage
key={message.id}
content={message.content}
isStreaming={message.isStreaming}
performanceInfo={message.performanceInfo}
toolName={message.toolName}
toolSuccess={message.toolSuccess}
toolRawResult={message.toolRawResult}
toolResultDisplayContent={message.toolResultDisplayContent}
toolConfirmation={message.toolConfirmation}
toolDenied={message.toolDenied}
onToolConfirmationDecision={message.onToolConfirmationDecision}
todoSnapshot={message.todoSnapshot}
isToolCallMessage={message.isToolCallMessage}
onAbort={message.isStreaming ? handleAbort : undefined}
/>
)
))}
<div ref={messagesEndRef} />
</div>
{showHistoryPanel ? (
<div className="chatbox-history-panel">
<div className="chatbox-history-header">
<h4>{t('chatbox.history.heading')}</h4>
<button
type="button"
className="chatbox-history-cancel"
onClick={() => setShowHistoryPanel(false)}
>
{t('chatbox.history.cancel')}
</button>
</div>
<div className="chatbox-history-list">
{isLoadingHistory && (
<div className="chatbox-history-empty">{t('chatbox.history.loading')}</div>
)}
{!isLoadingHistory && conversationHistory.length === 0 && (
<div className="chatbox-history-empty">{t('chatbox.history.empty')}</div>
)}
{!isLoadingHistory && conversationHistory.map((conversation) => (
<div
key={conversation.conversationId}
className="chatbox-history-item"
>
<div className="chatbox-history-item-toprow">
<button
type="button"
className="chatbox-history-open-btn"
onClick={() => { void handleRestoreConversation(conversation.conversationId); }}
>
<div className="chatbox-history-item-title" title={conversation.title}>{conversation.title}</div>
</button>
<button
type="button"
className="chatbox-history-delete-btn"
title={t('chatbox.history.delete')}
aria-label={t('chatbox.history.delete')}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDeleteConversation(conversation.conversationId, conversation.title);
}}
>
<FaTrash />
</button>
</div>
<button
type="button"
className="chatbox-history-open-btn chatbox-history-open-btn-body"
onClick={() => { void handleRestoreConversation(conversation.conversationId); }}
>
<div className="chatbox-history-item-meta">
{formatLocalDateTime(new Date(conversation.lastTurnAt))}
</div>
{conversation.preview && (
<div className="chatbox-history-item-preview">{conversation.preview}</div>
)}
</button>
</div>
))}
</div>
</div>
) : (
<div className="chatbox-messages">
{messages.map((message) => (
message.role === 'user' ? (
<UserMessage key={message.id} content={message.content} />
) : (
<AssistantMessage
key={message.id}
content={message.content}
isStreaming={message.isStreaming}
performanceInfo={message.performanceInfo}
toolName={message.toolName}
toolSuccess={message.toolSuccess}
toolRawResult={message.toolRawResult}
toolResultDisplayContent={message.toolResultDisplayContent}
toolConfirmation={message.toolConfirmation}
toolDenied={message.toolDenied}
onToolConfirmationDecision={message.onToolConfirmationDecision}
todoSnapshot={message.todoSnapshot}
isToolCallMessage={message.isToolCallMessage}
onAbort={message.isStreaming ? handleAbort : undefined}
/>
)
))}
<div ref={messagesEndRef} />
</div>
)}
{!isProcessing && !isCompacting && (
<div className="chatbox-input-area">
+2 -2
View File
@@ -279,14 +279,14 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
</button>
<button
type="button"
className="message-tool-confirmation-btn message-tool-confirmation-btn-always dialog-btn dialog-btn-primary kgone-btn-generate"
className="message-tool-confirmation-btn dialog-btn dialog-btn-primary kgone-btn-generate"
onClick={() => onToolConfirmationDecision('always_allow')}
>
{t('chatbox.tool.confirmation.alwaysAllow')}
</button>
<button
type="button"
className="message-tool-confirmation-btn message-tool-confirmation-btn-deny dialog-btn dialog-btn-primary kgone-btn-generate"
className="message-tool-confirmation-btn dialog-btn dialog-btn-cancel kgone-btn-generate"
onClick={() => onToolConfirmationDecision('deny')}
>
{t('chatbox.tool.confirmation.deny')}