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