+ {isEditing ? (
+ // Truly wide, centered, in-place edit box replacing the bubble
+
+
+
+
- {/* Render images if any */}
- {imagePaths.length > 0 && (
-
- {imagePaths.map((imagePath, index) => (
-
- ))}
-
- )}
+ {/* Render images if any */}
+ {imagePaths.length > 0 && (
+
+ {imagePaths.map((imagePath, index) => (
+
+ ))}
+
+ )}
-
-
- {timestamp}
-
-
-
+
+
+ {timestamp}
+
+
+
+
+
+
+
-
+ )}
+
+ {/* Edited indicator */}
+ {hasBeenEdited && !isEditing && (
+
+ Edited
+
+ )}
{/* TODO(alexhancock): Re-enable link previews once styled well again */}
{/* eslint-disable-next-line no-constant-binary-expression */}
diff --git a/ui/desktop/src/hooks/useChatEngine.test.ts b/ui/desktop/src/hooks/useChatEngine.test.ts
new file mode 100644
index 0000000000..83e87a3360
--- /dev/null
+++ b/ui/desktop/src/hooks/useChatEngine.test.ts
@@ -0,0 +1,155 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useChatEngine } from './useChatEngine';
+import { Message, getTextContent } from '../types/message';
+import { ChatType } from '../types/chat';
+import type { Mock } from 'vitest';
+
+// Mock the useMessageStream hook which is a dependency of useChatEngine
+vi.mock('./useMessageStream', () => ({
+ useMessageStream: vi.fn(),
+}));
+
+// Mock the sessions API which is another dependency
+vi.mock('../sessions', () => ({
+ fetchSessionDetails: vi.fn().mockResolvedValue({ metadata: {} }),
+}));
+
+describe('useChatEngine', () => {
+ let mockUseMessageStream: Mock;
+
+ beforeEach(async () => {
+ // Mock the global window object more completely for the React testing environment
+ const mockWindow = {
+ appConfig: {
+ get: vi.fn((key: string) => {
+ if (key === 'GOOSE_API_HOST') return 'http://localhost';
+ if (key === 'GOOSE_PORT') return '8000';
+ return null;
+ }),
+ },
+ electron: {
+ logInfo: vi.fn(),
+ },
+ setTimeout: vi.fn((fn: () => void) => {
+ fn(); // Execute immediately for tests
+ return 123;
+ }),
+ clearTimeout: vi.fn(),
+ dispatchEvent: vi.fn(),
+ CustomEvent: vi.fn(),
+ // Add basic browser objects required by React Testing Library
+ HTMLElement: class MockHTMLElement {},
+ Event: class MockEvent {},
+ };
+ vi.stubGlobal('window', mockWindow);
+
+ // Dynamically import the hook so we can get a reference to the mock
+ const { useMessageStream } = await import('./useMessageStream');
+ mockUseMessageStream = useMessageStream as Mock;
+
+ // Reset all mocks before each test to ensure a clean state
+ vi.clearAllMocks();
+
+ // Provide a complete, default mock implementation for useMessageStream
+ mockUseMessageStream.mockReturnValue({
+ messages: [],
+ append: vi.fn(),
+ stop: vi.fn(),
+ chatState: 'idle',
+ error: undefined,
+ setMessages: vi.fn(),
+ input: '',
+ setInput: vi.fn(),
+ handleInputChange: vi.fn(),
+ handleSubmit: vi.fn(),
+ updateMessageStreamBody: vi.fn(),
+ notifications: [],
+ sessionMetadata: undefined,
+ setError: vi.fn(),
+ });
+ });
+
+ describe('onMessageUpdate', () => {
+ it('should truncate history and append the updated message when a message is edited', () => {
+ // --- 1. ARRANGE ---
+ const initialMessages: Message[] = [
+ { id: '1', role: 'user', content: [{ type: 'text', text: 'First message' }], created: 0 },
+ {
+ id: '2',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'First response' }],
+ created: 1,
+ },
+ {
+ id: '3',
+ role: 'user',
+ content: [{ type: 'text', text: 'Message to be edited' }],
+ created: 2,
+ },
+ {
+ id: '4',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'Response to be deleted' }],
+ created: 3,
+ },
+ ];
+
+ const mockSetMessages = vi.fn();
+ const mockAppend = vi.fn();
+
+ // Configure the mock to return specific values for this test case
+ mockUseMessageStream.mockReturnValue({
+ messages: initialMessages,
+ append: mockAppend,
+ setMessages: mockSetMessages,
+ notifications: [],
+ stop: vi.fn(),
+ chatState: 'idle',
+ error: undefined,
+ input: '',
+ setInput: vi.fn(),
+ handleInputChange: vi.fn(),
+ handleSubmit: vi.fn(),
+ updateMessageStreamBody: vi.fn(),
+ sessionMetadata: undefined,
+ setError: vi.fn(),
+ });
+
+ const mockChat: ChatType = {
+ id: 'test-chat',
+ messages: initialMessages,
+ title: 'Test Chat',
+ messageHistoryIndex: 0,
+ };
+
+ // Render the hook with our test setup
+ const { result } = renderHook(() =>
+ useChatEngine({
+ chat: mockChat,
+ setChat: vi.fn(),
+ })
+ );
+
+ const messageIdToUpdate = '3';
+ const newContent = 'This is the edited message.';
+
+ // --- 2. ACT ---
+ // Call the function we want to test
+ act(() => {
+ result.current.onMessageUpdate(messageIdToUpdate, newContent);
+ });
+
+ // --- 3. ASSERT ---
+ // Verify that setMessages was called with the correctly truncated history
+ const expectedTruncatedHistory = initialMessages.slice(0, 2);
+ expect(mockSetMessages).toHaveBeenCalledWith(expectedTruncatedHistory);
+
+ // Verify that append was called with the new message
+ expect(mockAppend).toHaveBeenCalledTimes(1);
+ const appendedMessage = mockAppend.mock.calls[0][0];
+ expect(getTextContent(appendedMessage)).toBe(newContent);
+ expect(appendedMessage.role).toBe('user');
+ });
+ });
+});
diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts
index 8d29f98666..93b1a85166 100644
--- a/ui/desktop/src/hooks/useChatEngine.ts
+++ b/ui/desktop/src/hooks/useChatEngine.ts
@@ -51,6 +51,9 @@ export const useChatEngine = ({
const [localOutputTokens, setLocalOutputTokens] = useState
(0);
const [powerSaveTimeoutId, setPowerSaveTimeoutId] = useState(null);
+ // Track pending edited message
+ const [pendingEdit, setPendingEdit] = useState<{ id: string; content: string } | null>(null);
+
// Store message in global history when it's added (if enabled)
const storeMessageInHistory = useCallback(
(message: Message) => {
@@ -408,6 +411,34 @@ export const useChatEngine = ({
}, new Map());
}, [notifications]);
+ // Handle message updates from the UI
+ const onMessageUpdate = useCallback(
+ (messageId: string, newContent: string) => {
+ const messageIndex = messages.findIndex((msg) => msg.id === messageId);
+
+ if (messageIndex !== -1) {
+ // Truncate the history to the point *before* the edited message.
+ const history = messages.slice(0, messageIndex);
+
+ // Set the truncated history.
+ setMessages(history);
+
+ // Instead of setTimeout, set pendingEdit which will be handled in useEffect
+ setPendingEdit({ id: messageId, content: newContent });
+ }
+ },
+ [messages, setMessages, setPendingEdit]
+ );
+
+ // Listen for pending edit and append message after messages updated
+ useEffect(() => {
+ if (pendingEdit) {
+ const updatedMessage = createUserMessage(pendingEdit.content);
+ append(updatedMessage);
+ setPendingEdit(null); // Reset after processing
+ }
+ }, [pendingEdit, append]);
+
return {
// Core message data
messages,
@@ -451,5 +482,8 @@ export const useChatEngine = ({
// Error management
clearError: () => setError(undefined),
+
+ // New functions for message editing
+ onMessageUpdate,
};
};
diff --git a/ui/desktop/src/test/setup.ts b/ui/desktop/src/test/setup.ts
index d03e2a87c8..4716c7ab2c 100644
--- a/ui/desktop/src/test/setup.ts
+++ b/ui/desktop/src/test/setup.ts
@@ -1,5 +1,12 @@
import '@testing-library/jest-dom';
-import { vi } from 'vitest';
+import { vi, afterEach } from 'vitest';
+import { cleanup } from '@testing-library/react';
+
+// This is the standard setup to ensure that React Testing Library's
+// automatic cleanup runs after each test.
+afterEach(() => {
+ cleanup();
+});
// Mock console methods to avoid noise in tests
// eslint-disable-next-line no-undef