From a3a0f2f4ad9e603a6ac613a0f976dc16a198ee31 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:56:05 -0700 Subject: [PATCH] feat: added conversation history feature --- src/agent/core/AgentCore.test.ts | 29 ++ src/agent/core/AgentCore.ts | 24 ++ src/agent/core/AgentState.test.ts | 36 ++ src/agent/core/AgentState.ts | 19 + src/components/ChatBox.css | 137 +++++++- src/components/ChatBox.test.tsx | 192 +++++++++- src/components/ChatBox.tsx | 405 ++++++++++++++++++++-- src/components/chat/AssistantMessage.tsx | 4 +- src/constants/coreConstants.ts | 2 + src/core/io/KGConversationStorage.test.ts | 144 ++++++++ src/core/io/KGConversationStorage.ts | 155 +++++++++ src/core/io/KGProjectStorage.test.ts | 81 ++++- src/core/io/KGProjectStorage.ts | 75 ++-- src/i18n/messages/en_us.ts | 7 + src/i18n/messages/fr_fr.ts | 7 + src/i18n/messages/zh_cn.ts | 7 + src/i18n/messages/zh_hk.ts | 7 + src/types/conversationTypes.ts | 28 ++ src/util/chatUtil.ts | 2 +- 19 files changed, 1271 insertions(+), 90 deletions(-) create mode 100644 src/core/io/KGConversationStorage.test.ts create mode 100644 src/core/io/KGConversationStorage.ts create mode 100644 src/types/conversationTypes.ts diff --git a/src/agent/core/AgentCore.test.ts b/src/agent/core/AgentCore.test.ts index ef0c0b7..cf0a27f 100644 --- a/src/agent/core/AgentCore.test.ts +++ b/src/agent/core/AgentCore.test.ts @@ -182,4 +182,33 @@ describe('AgentCore todo integration', () => { expect(provider.calls).toHaveLength(1); expect(AgentCore.instance().getAgentState().getMessages().at(-1)?.role).toBe('tool'); }); + + it('restores a saved conversation document into the agent state', () => { + AgentCore.instance().restoreConversation({ + version: 1, + conversationId: 'conv_saved', + continuationState: { + messages: [ + { id: 'm2', role: 'assistant', content: 'summary', timestamp: 2 }, + ], + todos: [ + { id: 'todo-1', text: 'Continue work', status: 'in_progress', updatedAt: 3 }, + ], + }, + fullHistory: { + messages: [ + { id: 'm1', role: 'user', content: 'prompt', timestamp: 1 }, + { id: 'm2', role: 'assistant', content: 'summary', timestamp: 2 }, + ], + }, + displayTranscript: [], + }); + + expect(AgentCore.instance().getAgentState().getConversationId()).toBe('conv_saved'); + expect(AgentCore.instance().getAgentState().getMessages()).toHaveLength(1); + expect(AgentCore.instance().getAgentState().getFullMessages()).toHaveLength(2); + expect(AgentCore.instance().getAgentState().getTodos()).toEqual([ + { id: 'todo-1', text: 'Continue work', status: 'in_progress', updatedAt: 3 }, + ]); + }); }); diff --git a/src/agent/core/AgentCore.ts b/src/agent/core/AgentCore.ts index f4d3a19..26e1ba9 100644 --- a/src/agent/core/AgentCore.ts +++ b/src/agent/core/AgentCore.ts @@ -9,6 +9,7 @@ import type { OpenAIToolDefinition } from '../tools/BaseTool'; import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor'; import { ConfigManager } from '../../core/config/ConfigManager'; import { buildTodoContext } from './todo'; +import type { SavedConversationDocument } from '../../types/conversationTypes'; export interface CompactConversationOptions { trigger: 'manual' | 'auto'; @@ -261,6 +262,29 @@ export class AgentCore { this.currentTurnLikelyMultiStep = false; } + startNewConversation(): void { + this.agentState.resetConversation(); + this.todoToolCyclesSinceUpdate = 0; + this.remindAboutTodosOnNextLoop = false; + this.currentTurnLikelyMultiStep = false; + this.currentUserMessageId = null; + this.currentAssistantMessageId = null; + } + + restoreConversation(document: SavedConversationDocument): void { + this.agentState.replaceConversationState({ + conversationId: document.conversationId, + messages: document.continuationState.messages, + fullMessages: document.fullHistory.messages, + todos: document.continuationState.todos, + }); + this.todoToolCyclesSinceUpdate = 0; + this.remindAboutTodosOnNextLoop = false; + this.currentTurnLikelyMultiStep = false; + this.currentUserMessageId = null; + this.currentAssistantMessageId = null; + } + async shouldCompactBeforeNextTurn(userInput: string): Promise { if (!this.llmProvider?.estimateHistoryTokens || !this.llmProvider.getContextWindow) { return false; diff --git a/src/agent/core/AgentState.test.ts b/src/agent/core/AgentState.test.ts index 1503682..b4d506c 100644 --- a/src/agent/core/AgentState.test.ts +++ b/src/agent/core/AgentState.test.ts @@ -109,4 +109,40 @@ describe('AgentState compaction helpers', () => { expect(state.getTodos()).toEqual([]); }); + + it('restores full conversation state including full history and todos', () => { + const state = new AgentState('conv_original'); + + state.replaceConversationState({ + conversationId: 'conv_restored', + messages: [ + { id: 'm2', role: 'assistant', content: 'summary', timestamp: 2, is_compacted_summary: true }, + ], + fullMessages: [ + { id: 'm1', role: 'user', content: 'prompt', timestamp: 1 }, + { id: 'm2', role: 'assistant', content: 'summary', timestamp: 2, is_compacted_summary: true }, + ], + todos: [ + { id: 'todo-1', text: 'Resume work', status: 'in_progress', updatedAt: 3 }, + ], + }); + + expect(state.getConversationId()).toBe('conv_restored'); + expect(state.getMessages()).toHaveLength(1); + expect(state.getFullMessages()).toHaveLength(2); + expect(state.getTodos()).toEqual([ + { id: 'todo-1', text: 'Resume work', status: 'in_progress', updatedAt: 3 }, + ]); + }); + + it('starts a fresh conversation with a new id when reset', () => { + const state = new AgentState('conv_test'); + state.addMessage('user', 'first'); + + state.resetConversation('conv_new'); + + expect(state.getConversationId()).toBe('conv_new'); + expect(state.getMessages()).toEqual([]); + expect(state.getFullMessages()).toEqual([]); + }); }); diff --git a/src/agent/core/AgentState.ts b/src/agent/core/AgentState.ts index 2e9400a..f4935f0 100644 --- a/src/agent/core/AgentState.ts +++ b/src/agent/core/AgentState.ts @@ -153,6 +153,25 @@ export class AgentState { this.messages = [...messages]; } + replaceConversationState(state: { + conversationId: string; + messages: Message[]; + fullMessages: Message[]; + todos: TodoItem[]; + }): void { + this.conversationId = state.conversationId; + this.messages = state.messages.map(message => ({ ...message })); + this.fullMessages = state.fullMessages.map(message => ({ ...message })); + this.setTodos(state.todos); + } + + resetConversation(conversationId?: string): void { + this.conversationId = conversationId || this.generateConversationId(); + this.messages = []; + this.fullMessages = []; + this.clearTodos(); + } + /** * Get the last N messages */ diff --git a/src/components/ChatBox.css b/src/components/ChatBox.css index 12fd0a9..2f4342b 100644 --- a/src/components/ChatBox.css +++ b/src/components/ChatBox.css @@ -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; } diff --git a/src/components/ChatBox.test.tsx b/src/components/ChatBox.test.tsx index 2e3c563..50466ae 100644 --- a/src/components/ChatBox.test.tsx +++ b/src/components/ChatBox.test.tsx @@ -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', () => { , ); - expect(clearChatHistoryAndUIMock).toHaveBeenCalled(); + await waitFor(() => { + expect(clearChatHistoryAndUIMock).toHaveBeenCalled(); + }); + + rerender( + undefined, + t: (key, params) => translate(key, params, 'en_us'), + }} + > + + , + ); + 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(); + }); + }); }); diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index efa9687..6399f92 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -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 = ({ 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(null); @@ -93,10 +171,15 @@ const ChatBox: React.FC = ({ 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([]); const exportOptions = [ 'Export conversation as JSON', 'Export conversation as Markdown' ]; + const lastPersistedConversationKeyRef = useRef(null); + const messagesRef = useRef([]); const handleExportOptionSelect = (option: string) => { if (option === 'Export conversation as JSON') { @@ -160,7 +243,11 @@ const ChatBox: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ isVisible }) => { const welcomeMessage = await addWelcomeMessage(); if (welcomeMessage) { + messagesRef.current = [welcomeMessage]; setMessages([welcomeMessage]); } })(); @@ -262,6 +494,10 @@ const ChatBox: React.FC = ({ isVisible }) => { }; }, [clearChatUI]); + useEffect(() => { + lastPersistedConversationKeyRef.current = null; + }, [projectName]); + const handleAbort = () => { const controller = streamProcessor.abortController; if (controller) { @@ -270,17 +506,16 @@ const ChatBox: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ isVisible }) => { } await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage); + await persistConversationDocument(messagesRef.current); } }; @@ -457,6 +705,22 @@ const ChatBox: React.FC = ({ isVisible }) => {

{t('assistant.displayName')}

+ @@ -538,31 +803,95 @@ const ChatBox: React.FC = ({ isVisible }) => {
)} -
- {messages.map((message) => ( - message.role === 'user' ? ( - - ) : ( - - ) - ))} -
-
+ {showHistoryPanel ? ( +
+
+

{t('chatbox.history.heading')}

+ +
+
+ {isLoadingHistory && ( +
{t('chatbox.history.loading')}
+ )} + {!isLoadingHistory && conversationHistory.length === 0 && ( +
{t('chatbox.history.empty')}
+ )} + {!isLoadingHistory && conversationHistory.map((conversation) => ( +
+
+ + +
+ +
+ ))} +
+
+ ) : ( +
+ {messages.map((message) => ( + message.role === 'user' ? ( + + ) : ( + + ) + ))} +
+
+ )} {!isProcessing && !isCompacting && (
diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx index d626e7d..13bd3ba 100644 --- a/src/components/chat/AssistantMessage.tsx +++ b/src/components/chat/AssistantMessage.tsx @@ -279,14 +279,14 @@ const AssistantMessage: React.FC = ({