diff --git a/docs/04-development/issues/conversation-title-not-updating-in-ui.md b/docs/04-development/issues/conversation-title-not-updating-in-ui.md new file mode 100644 index 0000000..03a5f65 --- /dev/null +++ b/docs/04-development/issues/conversation-title-not-updating-in-ui.md @@ -0,0 +1,160 @@ +# Issue: Conversation Title Not Updating in UI + +**Status:** Open +**Priority:** High +**Date Reported:** November 2, 2025 +**Branch:** feat/save-empty-conversations + +## Problem Description + +When updating a conversation title or loading a conversation from history, the title displays correctly in the sidebar conversation history but does NOT update in the main header until a full page refresh. + +## Steps to Reproduce + +### Scenario 1: New Conversation Title Update +1. Click "New Conversation" button +2. Click "Edit conversation title" button +3. Change title to "Research 1" +4. Click save (checkmark button) +5. **Expected:** Header shows "Research 1" +6. **Actual:** Header still shows "New Conversation" +7. Title shows correctly in sidebar history +8. Full page refresh (Ctrl+R) shows correct title + +### Scenario 2: Load Existing Conversation +1. Have existing conversation titled "Test 5" +2. Click on "Test 5" in conversation history sidebar +3. **Expected:** Header shows "Test 5" +4. **Actual:** Header still shows "New Conversation" (or previous title) +5. Title shows correctly in sidebar history +6. Full page refresh (Ctrl+R) shows correct title + +## Current Implementation + +### State Management +- `conversationTitle` state is used for the title +- `conversationTitleRef` ref is used to prevent stale closures in auto-save effect +- `useEffect` syncs ref with state: `conversationTitleRef.current = conversationTitle` + +### Title Display +```tsx + +``` + +### What Works +- Title saves correctly to localStorage ✅ +- Title displays correctly in conversation history sidebar ✅ +- Title displays correctly after full page refresh ✅ +- All tests pass (17 passed, 13 skipped) ✅ + +### What Doesn't Work +- Title does not update in header immediately after change ❌ +- Title does not update in header when loading conversation ❌ + +## Technical Details + +### Attempted Fixes +1. **Removed conversationTitle from auto-save dependencies** - Prevented infinite loops but created stale closure issue +2. **Added conversationTitleRef** - Helped with auto-save but didn't fix UI update +3. **Fixed order of state updates** - Set title before conversation ID to prevent race conditions +4. **Auto-sync ref with useEffect** - Simplified code but didn't fix UI update +5. **Save full conversation in handleTitleChange** - Ensured localStorage consistency + +### Suspected Root Causes +1. **React State Batching** - Multiple state updates may be batched, causing UI to not reflect latest value +2. **Component Re-render Issue** - ChatHeader component may not be re-rendering when conversationTitle changes +3. **State Update Timing** - Auto-save effect may be running and overwriting title after it's set +4. **Closure Capture** - Some effect or callback may have captured old title value + +## Files Involved +- `src/app/page.tsx` - Main page component with title state management +- `src/components/chat-header.tsx` - Header component that displays title +- `src/components/conversation-title.tsx` - Title editing component +- `src/lib/storage.ts` - localStorage functions for saving conversations + +## Debugging Suggestions + +1. **Add console logs** to track state changes: + ```typescript + useEffect(() => { + console.log('Title state changed:', conversationTitle); + }, [conversationTitle]); + ``` + +2. **Check ChatHeader component** - Ensure it's not memoized incorrectly or has stale props + +3. **Verify ConversationTitle component** - Check if it's updating its internal state correctly + +4. **Use React DevTools** - Inspect component tree to see if conversationTitle prop is updating + +5. **Check for stale closures** - Look for any callbacks or effects that might capture old title + +## Possible Solutions to Try + +### Solution 1: Force Re-render +```typescript +const [, forceUpdate] = useReducer(x => x + 1, 0); + +const handleTitleChange = (newTitle: string) => { + setConversationTitle(newTitle); + forceUpdate(); // Force component re-render + // ... rest of logic +}; +``` + +### Solution 2: Use Callback Ref Pattern +```typescript +const titleRef = useCallback((node) => { + if (node) { + node.textContent = conversationTitle; + } +}, [conversationTitle]); +``` + +### Solution 3: Direct DOM Manipulation (Not Recommended) +```typescript +useEffect(() => { + const titleElement = document.querySelector('[data-conversation-title]'); + if (titleElement) { + titleElement.textContent = conversationTitle; + } +}, [conversationTitle]); +``` + +### Solution 4: Check Component Memoization +- Ensure ChatHeader is not wrapped in `React.memo()` without proper dependencies +- Check if ConversationTitle has `useMemo` or `useCallback` issues + +### Solution 5: Separate Title State from Auto-Save +- Completely decouple title updates from auto-save logic +- Have title updates go through a different path than message/file updates + +## Related Code Commits + +1. `d36b0b9` - test: add tests for empty conversation saving (TDD Red) +2. `4452df1` - feat: allow saving and editing empty conversations (TDD Green) +3. `1c518ae` - fix: ensure conversation title updates in header when changed +4. `bfedde7` - fix: remove conversationTitle from auto-save dependencies +5. `1c32e14` - fix: use ref to track conversation title and prevent stale closure +6. `c3bf9d9` - fix: update title ref before setting conversation ID to prevent race condition +7. `4719410` - refactor: auto-sync conversationTitle ref with state using useEffect + +## Testing Notes + +All unit tests pass, but the visual UI update is not happening. This suggests the issue is related to React's rendering cycle rather than the underlying logic. + +## Next Steps + +1. Add detailed console logging to track state changes +2. Inspect ChatHeader and ConversationTitle components for rendering issues +3. Check React DevTools to see if props are updating +4. Consider if this is a React 19 or Next.js 15 specific issue +5. Try creating a minimal reproduction case + +## Workaround for Users + +**Current Workaround:** Refresh the page (F5 or Ctrl+R) after changing title or loading conversation. diff --git a/src/__tests__/app/page.test.tsx b/src/__tests__/app/page.test.tsx index 0072e86..f5a3b48 100644 --- a/src/__tests__/app/page.test.tsx +++ b/src/__tests__/app/page.test.tsx @@ -400,4 +400,46 @@ describe('Main Page Component', () => { expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument(); }); }); + + describe('Empty Conversation Saving', () => { + it('should allow editing title on new conversation without messages', async () => { + const user = userEvent.setup(); + renderPage(); + + // Find and click the edit title button + const editButton = screen.getByRole('button', { name: /edit conversation title/i }); + expect(editButton).toBeInTheDocument(); + + await user.click(editButton); + + // Input should appear - use getByDisplayValue to be more specific + const titleInput = screen.getByDisplayValue('New Conversation'); + expect(titleInput).toBeInTheDocument(); + }); + + it('should save empty conversation when title is changed', async () => { + const user = userEvent.setup(); + renderPage(); + + // Edit title on empty conversation + const editButton = screen.getByRole('button', { name: /edit conversation title/i }); + await user.click(editButton); + + const titleInput = screen.getByDisplayValue('New Conversation'); + await user.clear(titleInput); + await user.type(titleInput, 'My Empty Conversation'); + + // Save the title + const saveButton = screen.getByRole('button', { name: /save title/i }); + await user.click(saveButton); + + // Conversation should be saved even without messages + await waitFor(() => { + const conversations = JSON.parse(localStorage.getItem('notechat-conversations') || '[]'); + expect(conversations.length).toBeGreaterThan(0); + expect(conversations[0].title).toBe('My Empty Conversation'); + expect(conversations[0].messages).toEqual([]); + }); + }); + }); }); diff --git a/src/__tests__/components/conversation-title.test.tsx b/src/__tests__/components/conversation-title.test.tsx index 192875e..65f230c 100644 --- a/src/__tests__/components/conversation-title.test.tsx +++ b/src/__tests__/components/conversation-title.test.tsx @@ -30,9 +30,10 @@ describe('ConversationTitle', () => { expect(editButton).toBeInTheDocument(); }); - it('hides edit button for new conversations', () => { + it('shows edit button for new conversations (allows early title editing)', () => { render(); - expect(screen.queryByRole('button', { name: /edit conversation title/i })).not.toBeInTheDocument(); + const editButton = screen.getByRole('button', { name: /edit conversation title/i }); + expect(editButton).toBeInTheDocument(); }); it('updates displayed title when prop changes', () => { diff --git a/src/app/page.tsx b/src/app/page.tsx index 4e0a61a..02ca114 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -52,10 +52,16 @@ export default function Home() { const [editingMessageId, setEditingMessageId] = useState(null); const [editedContent, setEditedContent] = useState(''); const searchInputRef = useRef(null); + const conversationTitleRef = useRef('New Conversation'); const { toast } = useToast(); const { setTheme } = useTheme(); const { streamingText, isStreaming, streamResponse, reset } = useStreamingResponse(); + // Keep ref in sync with state + useEffect(() => { + conversationTitleRef.current = conversationTitle; + }, [conversationTitle]); + // Load persisted data on mount useEffect(() => { const savedMessages = loadMessages(); @@ -113,23 +119,27 @@ export default function Home() { // Auto-save conversation when messages or sources change useEffect(() => { - if (messages.length > 0 && isLoaded) { - const conversation = createConversation(messages, files, aiTheme || undefined); - - // If we don't have a current conversation ID, set it - if (!currentConversationId) { - setCurrentConversationIdState(conversation.id); - setCurrentConversationId(conversation.id); - setConversationTitle(conversation.title); // Set auto-generated title - } else { - // Update existing conversation with current ID and title - conversation.id = currentConversationId; - conversation.title = conversationTitle; // Preserve user's title + if (isLoaded) { + // Save if we have messages OR if we have a conversation ID (empty conversation with title) + if (messages.length > 0 || currentConversationId) { + // If we don't have a current conversation ID, create new one with auto-generated title + if (!currentConversationId) { + const conversation = createConversation(messages, files, aiTheme || undefined); + setCurrentConversationIdState(conversation.id); + setCurrentConversationId(conversation.id); + setConversationTitle(conversation.title); // Set auto-generated title + saveConversation(conversation); + } else { + // Update existing conversation with current ID and title from ref + const conversation = createConversation(messages, files, aiTheme || undefined, conversationTitleRef.current); + conversation.id = currentConversationId; + saveConversation(conversation); + } } - - saveConversation(conversation); } - }, [messages, files, aiTheme, currentConversationId, conversationTitle, isLoaded]); + // Note: conversationTitle is NOT in dependencies because title updates are handled by handleTitleChange + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages, files, aiTheme, currentConversationId, isLoaded]); const handleNewConversation = () => { // Save current conversation before creating new one @@ -174,9 +184,10 @@ export default function Home() { setMessages(conversation.messages); setFiles(conversation.sources); setAiTheme(conversation.aiTheme || null); + // Update title BEFORE setting conversation ID to avoid stale ref in auto-save effect + setConversationTitle(conversation.title); setCurrentConversationIdState(conversation.id); setCurrentConversationId(conversation.id); - setConversationTitle(conversation.title); toast({ title: 'Conversation Loaded', @@ -187,10 +198,26 @@ export default function Home() { const handleTitleChange = (newTitle: string) => { setConversationTitle(newTitle); - // Update in storage if we have a conversation ID - if (currentConversationId) { + // If we don't have a conversation ID yet, create one and save the empty conversation + if (!currentConversationId) { + const conversation = createConversation(messages, files, aiTheme || undefined, newTitle); + setCurrentConversationIdState(conversation.id); + setCurrentConversationId(conversation.id); + saveConversation(conversation); + + toast({ + title: 'Conversation Created', + description: `Created "${newTitle}"`, + }); + } else { + // Update existing conversation title in storage updateConversationTitle(currentConversationId, newTitle); + // Also update the full conversation to ensure title is synced + const conversation = createConversation(messages, files, aiTheme || undefined, newTitle); + conversation.id = currentConversationId; + saveConversation(conversation); + toast({ title: 'Title Updated', description: `Conversation renamed to "${newTitle}"`, diff --git a/src/components/conversation-title.tsx b/src/components/conversation-title.tsx index 70c835e..872c238 100644 --- a/src/components/conversation-title.tsx +++ b/src/components/conversation-title.tsx @@ -98,23 +98,21 @@ export function ConversationTitle({ title, onTitleChange, isNewConversation }: C

{displayTitle}

- {!isNewConversation && ( - - - - - -

Edit conversation title

-
-
- )} + + + + + +

Edit conversation title

+
+
);