Skip to content

Commit d2cce73

Browse files
authored
Feat/save empty conversations (#21)
* fix: resolve TypeScript error in theme-provider test - Fix 'children' prop missing error by passing undefined instead of empty - All 11 tests still passing - Resolves TypeScript compilation error * test: add tests for empty conversation saving (TDD Red) TDD Red Phase - Failing tests: - conversation-title: Edit button should show for new conversations - page: Should allow editing title without messages - page: Should save empty conversation when title is changed These tests expect: 1. Edit button visible on new/empty conversations 2. Title editable before sending first message 3. Conversation saved when title is edited (even with no messages) Tests currently failing - implementation to follow * feat: allow saving and editing empty conversations (TDD Green) TDD Green Phase - Implementation: - Remove edit button conditional in conversation-title.tsx - Create conversation ID when title changes on empty conversation - Save empty conversations to localStorage - Update auto-save to support conversations without messages Features: - Edit button now visible on new conversations - Title can be changed before sending first message - Conversations persist even with no messages/files Testing: - Updated conversation-title.test.tsx selector to be more specific - Updated page.test.tsx to check localStorage directly - All 508 tests passing * fix: ensure conversation title updates in header when changed Issue: Title was saving to localStorage but not updating in the header Root cause: Auto-save useEffect was calling createConversation without passing the conversationTitle parameter, causing it to overwrite the user's title with an auto-generated one. Solution: Pass conversationTitle to createConversation when updating an existing conversation, ensuring the user's custom title is preserved in both storage and the UI. All tests passing. * fix: remove conversationTitle from auto-save dependencies Issue: Title would revert to 'New Conversation' after being changed, even though it saved correctly in localStorage/history. Root Cause: conversationTitle was in the auto-save useEffect dependency array, causing it to re-run every time the title changed. This triggered the auto-save logic which would sometimes overwrite the user's custom title with an auto-generated one. Solution: Remove conversationTitle from dependencies since title updates are already handled by handleTitleChange and updateConversationTitle. The auto-save should only trigger on content changes (messages, files, aiTheme), not title changes. All tests passing. * fix: use ref to track conversation title and prevent stale closure Issue: Title would update in localStorage but revert to 'New Conversation' in the header after being changed. Root Cause: The auto-save useEffect was using a stale conversationTitle value from closure. Since conversationTitle was removed from dependencies to prevent infinite loops, the effect closure captured the old value and kept using 'New Conversation' instead of the updated title. Solution: Introduce conversationTitleRef to track the current title value. This ref is updated immediately when the title changes and is always current when the auto-save effect runs. The ref bypasses React's batching and closure staleness issues. Changes: - Added conversationTitleRef to track current title - Update ref in all places where setConversationTitle is called - Auto-save effect now uses conversationTitleRef.current for latest value - handleTitleChange also saves full conversation to ensure sync All tests passing (17 passed, 13 skipped). * fix: update title ref before setting conversation ID to prevent race condition Issue: When loading a conversation from history, the title would not update in the header (e.g., loading 'Test 5' would still show 'New Conversation'). Root Cause: In handleLoadConversation, conversationTitleRef was being updated AFTER setCurrentConversationId. This caused the auto-save effect (triggered by the ID change) to run with the old/stale ref value before the ref was updated with the new title. Solution: Update the title and ref BEFORE setting the conversation ID. This ensures conversationTitleRef.current has the correct value when the auto-save effect runs. Order of operations now: 1. Set title state and ref (with new title) 2. Set conversation ID (triggers auto-save) 3. Auto-save uses the already-updated ref value All tests passing. * refactor: auto-sync conversationTitle ref with state using useEffect Simplification: Instead of manually updating conversationTitleRef.current in multiple places, use a useEffect to automatically keep the ref in sync with the conversationTitle state. This ensures the ref is always current and reduces code duplication. Benefits: - Single source of truth for title updates - Eliminates manual ref assignments scattered throughout the code - Prevents missing ref updates when title state changes - Cleaner and more maintainable code The useEffect runs whenever conversationTitle changes, ensuring conversationTitleRef.current is always up-to-date when the auto-save effect needs it. All tests passing (17 passed, 13 skipped). * docs: add issue documentation for conversation title UI update bug Issue: Title updates in localStorage and sidebar but not in header until page refresh. Documented attempted fixes, suspected root causes, and possible solutions for future debugging. Status: Open - requires further investigation
1 parent f77a050 commit d2cce73

File tree

5 files changed

+265
-37
lines changed

5 files changed

+265
-37
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Issue: Conversation Title Not Updating in UI
2+
3+
**Status:** Open
4+
**Priority:** High
5+
**Date Reported:** November 2, 2025
6+
**Branch:** feat/save-empty-conversations
7+
8+
## Problem Description
9+
10+
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.
11+
12+
## Steps to Reproduce
13+
14+
### Scenario 1: New Conversation Title Update
15+
1. Click "New Conversation" button
16+
2. Click "Edit conversation title" button
17+
3. Change title to "Research 1"
18+
4. Click save (checkmark button)
19+
5. **Expected:** Header shows "Research 1"
20+
6. **Actual:** Header still shows "New Conversation"
21+
7. Title shows correctly in sidebar history
22+
8. Full page refresh (Ctrl+R) shows correct title
23+
24+
### Scenario 2: Load Existing Conversation
25+
1. Have existing conversation titled "Test 5"
26+
2. Click on "Test 5" in conversation history sidebar
27+
3. **Expected:** Header shows "Test 5"
28+
4. **Actual:** Header still shows "New Conversation" (or previous title)
29+
5. Title shows correctly in sidebar history
30+
6. Full page refresh (Ctrl+R) shows correct title
31+
32+
## Current Implementation
33+
34+
### State Management
35+
- `conversationTitle` state is used for the title
36+
- `conversationTitleRef` ref is used to prevent stale closures in auto-save effect
37+
- `useEffect` syncs ref with state: `conversationTitleRef.current = conversationTitle`
38+
39+
### Title Display
40+
```tsx
41+
<ChatHeader
42+
conversationTitle={conversationTitle}
43+
onTitleChange={handleTitleChange}
44+
isNewConversation={!currentConversationId || messages.length === 0}
45+
/>
46+
```
47+
48+
### What Works
49+
- Title saves correctly to localStorage ✅
50+
- Title displays correctly in conversation history sidebar ✅
51+
- Title displays correctly after full page refresh ✅
52+
- All tests pass (17 passed, 13 skipped) ✅
53+
54+
### What Doesn't Work
55+
- Title does not update in header immediately after change ❌
56+
- Title does not update in header when loading conversation ❌
57+
58+
## Technical Details
59+
60+
### Attempted Fixes
61+
1. **Removed conversationTitle from auto-save dependencies** - Prevented infinite loops but created stale closure issue
62+
2. **Added conversationTitleRef** - Helped with auto-save but didn't fix UI update
63+
3. **Fixed order of state updates** - Set title before conversation ID to prevent race conditions
64+
4. **Auto-sync ref with useEffect** - Simplified code but didn't fix UI update
65+
5. **Save full conversation in handleTitleChange** - Ensured localStorage consistency
66+
67+
### Suspected Root Causes
68+
1. **React State Batching** - Multiple state updates may be batched, causing UI to not reflect latest value
69+
2. **Component Re-render Issue** - ChatHeader component may not be re-rendering when conversationTitle changes
70+
3. **State Update Timing** - Auto-save effect may be running and overwriting title after it's set
71+
4. **Closure Capture** - Some effect or callback may have captured old title value
72+
73+
## Files Involved
74+
- `src/app/page.tsx` - Main page component with title state management
75+
- `src/components/chat-header.tsx` - Header component that displays title
76+
- `src/components/conversation-title.tsx` - Title editing component
77+
- `src/lib/storage.ts` - localStorage functions for saving conversations
78+
79+
## Debugging Suggestions
80+
81+
1. **Add console logs** to track state changes:
82+
```typescript
83+
useEffect(() => {
84+
console.log('Title state changed:', conversationTitle);
85+
}, [conversationTitle]);
86+
```
87+
88+
2. **Check ChatHeader component** - Ensure it's not memoized incorrectly or has stale props
89+
90+
3. **Verify ConversationTitle component** - Check if it's updating its internal state correctly
91+
92+
4. **Use React DevTools** - Inspect component tree to see if conversationTitle prop is updating
93+
94+
5. **Check for stale closures** - Look for any callbacks or effects that might capture old title
95+
96+
## Possible Solutions to Try
97+
98+
### Solution 1: Force Re-render
99+
```typescript
100+
const [, forceUpdate] = useReducer(x => x + 1, 0);
101+
102+
const handleTitleChange = (newTitle: string) => {
103+
setConversationTitle(newTitle);
104+
forceUpdate(); // Force component re-render
105+
// ... rest of logic
106+
};
107+
```
108+
109+
### Solution 2: Use Callback Ref Pattern
110+
```typescript
111+
const titleRef = useCallback((node) => {
112+
if (node) {
113+
node.textContent = conversationTitle;
114+
}
115+
}, [conversationTitle]);
116+
```
117+
118+
### Solution 3: Direct DOM Manipulation (Not Recommended)
119+
```typescript
120+
useEffect(() => {
121+
const titleElement = document.querySelector('[data-conversation-title]');
122+
if (titleElement) {
123+
titleElement.textContent = conversationTitle;
124+
}
125+
}, [conversationTitle]);
126+
```
127+
128+
### Solution 4: Check Component Memoization
129+
- Ensure ChatHeader is not wrapped in `React.memo()` without proper dependencies
130+
- Check if ConversationTitle has `useMemo` or `useCallback` issues
131+
132+
### Solution 5: Separate Title State from Auto-Save
133+
- Completely decouple title updates from auto-save logic
134+
- Have title updates go through a different path than message/file updates
135+
136+
## Related Code Commits
137+
138+
1. `d36b0b9` - test: add tests for empty conversation saving (TDD Red)
139+
2. `4452df1` - feat: allow saving and editing empty conversations (TDD Green)
140+
3. `1c518ae` - fix: ensure conversation title updates in header when changed
141+
4. `bfedde7` - fix: remove conversationTitle from auto-save dependencies
142+
5. `1c32e14` - fix: use ref to track conversation title and prevent stale closure
143+
6. `c3bf9d9` - fix: update title ref before setting conversation ID to prevent race condition
144+
7. `4719410` - refactor: auto-sync conversationTitle ref with state using useEffect
145+
146+
## Testing Notes
147+
148+
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.
149+
150+
## Next Steps
151+
152+
1. Add detailed console logging to track state changes
153+
2. Inspect ChatHeader and ConversationTitle components for rendering issues
154+
3. Check React DevTools to see if props are updating
155+
4. Consider if this is a React 19 or Next.js 15 specific issue
156+
5. Try creating a minimal reproduction case
157+
158+
## Workaround for Users
159+
160+
**Current Workaround:** Refresh the page (F5 or Ctrl+R) after changing title or loading conversation.

src/__tests__/app/page.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,4 +400,46 @@ describe('Main Page Component', () => {
400400
expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument();
401401
});
402402
});
403+
404+
describe('Empty Conversation Saving', () => {
405+
it('should allow editing title on new conversation without messages', async () => {
406+
const user = userEvent.setup();
407+
renderPage();
408+
409+
// Find and click the edit title button
410+
const editButton = screen.getByRole('button', { name: /edit conversation title/i });
411+
expect(editButton).toBeInTheDocument();
412+
413+
await user.click(editButton);
414+
415+
// Input should appear - use getByDisplayValue to be more specific
416+
const titleInput = screen.getByDisplayValue('New Conversation');
417+
expect(titleInput).toBeInTheDocument();
418+
});
419+
420+
it('should save empty conversation when title is changed', async () => {
421+
const user = userEvent.setup();
422+
renderPage();
423+
424+
// Edit title on empty conversation
425+
const editButton = screen.getByRole('button', { name: /edit conversation title/i });
426+
await user.click(editButton);
427+
428+
const titleInput = screen.getByDisplayValue('New Conversation');
429+
await user.clear(titleInput);
430+
await user.type(titleInput, 'My Empty Conversation');
431+
432+
// Save the title
433+
const saveButton = screen.getByRole('button', { name: /save title/i });
434+
await user.click(saveButton);
435+
436+
// Conversation should be saved even without messages
437+
await waitFor(() => {
438+
const conversations = JSON.parse(localStorage.getItem('notechat-conversations') || '[]');
439+
expect(conversations.length).toBeGreaterThan(0);
440+
expect(conversations[0].title).toBe('My Empty Conversation');
441+
expect(conversations[0].messages).toEqual([]);
442+
});
443+
});
444+
});
403445
});

src/__tests__/components/conversation-title.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ describe('ConversationTitle', () => {
3030
expect(editButton).toBeInTheDocument();
3131
});
3232

33-
it('hides edit button for new conversations', () => {
33+
it('shows edit button for new conversations (allows early title editing)', () => {
3434
render(<ConversationTitle {...defaultProps} isNewConversation={true} />);
35-
expect(screen.queryByRole('button', { name: /edit conversation title/i })).not.toBeInTheDocument();
35+
const editButton = screen.getByRole('button', { name: /edit conversation title/i });
36+
expect(editButton).toBeInTheDocument();
3637
});
3738

3839
it('updates displayed title when prop changes', () => {

src/app/page.tsx

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,16 @@ export default function Home() {
5252
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
5353
const [editedContent, setEditedContent] = useState<string>('');
5454
const searchInputRef = useRef<HTMLInputElement>(null);
55+
const conversationTitleRef = useRef<string>('New Conversation');
5556
const { toast } = useToast();
5657
const { setTheme } = useTheme();
5758
const { streamingText, isStreaming, streamResponse, reset } = useStreamingResponse();
5859

60+
// Keep ref in sync with state
61+
useEffect(() => {
62+
conversationTitleRef.current = conversationTitle;
63+
}, [conversationTitle]);
64+
5965
// Load persisted data on mount
6066
useEffect(() => {
6167
const savedMessages = loadMessages();
@@ -113,23 +119,27 @@ export default function Home() {
113119

114120
// Auto-save conversation when messages or sources change
115121
useEffect(() => {
116-
if (messages.length > 0 && isLoaded) {
117-
const conversation = createConversation(messages, files, aiTheme || undefined);
118-
119-
// If we don't have a current conversation ID, set it
120-
if (!currentConversationId) {
121-
setCurrentConversationIdState(conversation.id);
122-
setCurrentConversationId(conversation.id);
123-
setConversationTitle(conversation.title); // Set auto-generated title
124-
} else {
125-
// Update existing conversation with current ID and title
126-
conversation.id = currentConversationId;
127-
conversation.title = conversationTitle; // Preserve user's title
122+
if (isLoaded) {
123+
// Save if we have messages OR if we have a conversation ID (empty conversation with title)
124+
if (messages.length > 0 || currentConversationId) {
125+
// If we don't have a current conversation ID, create new one with auto-generated title
126+
if (!currentConversationId) {
127+
const conversation = createConversation(messages, files, aiTheme || undefined);
128+
setCurrentConversationIdState(conversation.id);
129+
setCurrentConversationId(conversation.id);
130+
setConversationTitle(conversation.title); // Set auto-generated title
131+
saveConversation(conversation);
132+
} else {
133+
// Update existing conversation with current ID and title from ref
134+
const conversation = createConversation(messages, files, aiTheme || undefined, conversationTitleRef.current);
135+
conversation.id = currentConversationId;
136+
saveConversation(conversation);
137+
}
128138
}
129-
130-
saveConversation(conversation);
131139
}
132-
}, [messages, files, aiTheme, currentConversationId, conversationTitle, isLoaded]);
140+
// Note: conversationTitle is NOT in dependencies because title updates are handled by handleTitleChange
141+
// eslint-disable-next-line react-hooks/exhaustive-deps
142+
}, [messages, files, aiTheme, currentConversationId, isLoaded]);
133143

134144
const handleNewConversation = () => {
135145
// Save current conversation before creating new one
@@ -174,9 +184,10 @@ export default function Home() {
174184
setMessages(conversation.messages);
175185
setFiles(conversation.sources);
176186
setAiTheme(conversation.aiTheme || null);
187+
// Update title BEFORE setting conversation ID to avoid stale ref in auto-save effect
188+
setConversationTitle(conversation.title);
177189
setCurrentConversationIdState(conversation.id);
178190
setCurrentConversationId(conversation.id);
179-
setConversationTitle(conversation.title);
180191

181192
toast({
182193
title: 'Conversation Loaded',
@@ -187,10 +198,26 @@ export default function Home() {
187198
const handleTitleChange = (newTitle: string) => {
188199
setConversationTitle(newTitle);
189200

190-
// Update in storage if we have a conversation ID
191-
if (currentConversationId) {
201+
// If we don't have a conversation ID yet, create one and save the empty conversation
202+
if (!currentConversationId) {
203+
const conversation = createConversation(messages, files, aiTheme || undefined, newTitle);
204+
setCurrentConversationIdState(conversation.id);
205+
setCurrentConversationId(conversation.id);
206+
saveConversation(conversation);
207+
208+
toast({
209+
title: 'Conversation Created',
210+
description: `Created "${newTitle}"`,
211+
});
212+
} else {
213+
// Update existing conversation title in storage
192214
updateConversationTitle(currentConversationId, newTitle);
193215

216+
// Also update the full conversation to ensure title is synced
217+
const conversation = createConversation(messages, files, aiTheme || undefined, newTitle);
218+
conversation.id = currentConversationId;
219+
saveConversation(conversation);
220+
194221
toast({
195222
title: 'Title Updated',
196223
description: `Conversation renamed to "${newTitle}"`,

src/components/conversation-title.tsx

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -98,23 +98,21 @@ export function ConversationTitle({ title, onTitleChange, isNewConversation }: C
9898
<h2 className="text-sm font-medium truncate" title={displayTitle}>
9999
{displayTitle}
100100
</h2>
101-
{!isNewConversation && (
102-
<Tooltip>
103-
<TooltipTrigger asChild>
104-
<button
105-
type="button"
106-
onClick={() => setIsEditing(true)}
107-
className="h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-md opacity-0 group-hover:opacity-100 hover:bg-accent hover:text-accent-foreground transition-opacity"
108-
aria-label="Edit conversation title"
109-
>
110-
<Pencil className="h-3 w-3" />
111-
</button>
112-
</TooltipTrigger>
113-
<TooltipContent side="right">
114-
<p>Edit conversation title</p>
115-
</TooltipContent>
116-
</Tooltip>
117-
)}
101+
<Tooltip>
102+
<TooltipTrigger asChild>
103+
<button
104+
type="button"
105+
onClick={() => setIsEditing(true)}
106+
className="h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-md opacity-0 group-hover:opacity-100 hover:bg-accent hover:text-accent-foreground transition-opacity"
107+
aria-label="Edit conversation title"
108+
>
109+
<Pencil className="h-3 w-3" />
110+
</button>
111+
</TooltipTrigger>
112+
<TooltipContent side="right">
113+
<p>Edit conversation title</p>
114+
</TooltipContent>
115+
</Tooltip>
118116
</div>
119117
</TooltipProvider>
120118
);

0 commit comments

Comments
 (0)