Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions docs/04-development/issues/conversation-title-not-updating-in-ui.md
Original file line number Diff line number Diff line change
@@ -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
<ChatHeader
conversationTitle={conversationTitle}
onTitleChange={handleTitleChange}
isNewConversation={!currentConversationId || messages.length === 0}
/>
```

### 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.
42 changes: 42 additions & 0 deletions src/__tests__/app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
});
});
5 changes: 3 additions & 2 deletions src/__tests__/components/conversation-title.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ConversationTitle {...defaultProps} isNewConversation={true} />);
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', () => {
Expand Down
63 changes: 45 additions & 18 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ export default function Home() {
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editedContent, setEditedContent] = useState<string>('');
const searchInputRef = useRef<HTMLInputElement>(null);
const conversationTitleRef = useRef<string>('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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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}"`,
Expand Down
32 changes: 15 additions & 17 deletions src/components/conversation-title.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,23 +98,21 @@ export function ConversationTitle({ title, onTitleChange, isNewConversation }: C
<h2 className="text-sm font-medium truncate" title={displayTitle}>
{displayTitle}
</h2>
{!isNewConversation && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setIsEditing(true)}
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"
aria-label="Edit conversation title"
>
<Pencil className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Edit conversation title</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setIsEditing(true)}
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"
aria-label="Edit conversation title"
>
<Pencil className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Edit conversation title</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
Expand Down