Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
74 changes: 74 additions & 0 deletions src/frontend/components/layout/AppLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { createTestStore } from '../../test-utils/createTestStore';
import { addChat } from '../../redux/slices/chats';
import { setProjects } from '../../redux/slices/projects';
import { AppLayout } from './AppLayout';
import { HomePage } from '../../pages/HomePage';
import { chatStorage } from '../../services/chatStorage';
import { getAllThreadsByUserId } from '../../services/agent-rest';
import { getProjects, getThreadsByProject } from '../../services/projects-api';
import { markChatAsClientCreated, unmarkChatAsClientCreated } from '../../services/newChatTracker';

const mocks = vi.hoisted(() => ({
deleteProject: vi.fn(),
Expand Down Expand Up @@ -67,13 +69,16 @@ vi.mock('../../lib/streaming/streamingManagerRegistry', () => ({

vi.mock('../Sidebar', () => ({
Sidebar: ({
onNewChat,
onDeleteProject,
onDeleteChat,
}: {
onNewChat: () => void;
onDeleteProject: (id: string, options?: { keepThreads?: boolean }) => Promise<boolean>;
onDeleteChat: (id: string) => void;
}) => (
<>
<button type="button" onClick={() => onNewChat()}>new-chat</button>
<button type="button" onClick={() => { void onDeleteProject('p1'); }}>
delete-project
</button>
Expand Down Expand Up @@ -136,6 +141,75 @@ describe('AppLayout — project delete and chat count', () => {
mocks.getThreadsByProject.mockResolvedValue([]);
});

it.each([
['quick prompt', false],
['quick prompt', true],
['typed prompt', true],
['sidebar', true],
] as const)('preserves a %s chat through delayed startup history (stream connected: %s)', async (entry, streamConnected) => {
let resolveHistory!: (history: []) => void;
vi.mocked(getAllThreadsByUserId).mockReturnValueOnce(
new Promise((resolve) => { resolveHistory = resolve; }),
);
const store = createTestStore();
render(
<Provider store={store}>
<MemoryRouter>
<AppLayout><HomePage /></AppLayout>
</MemoryRouter>
</Provider>,
);

const user = userEvent.setup();
if (entry === 'sidebar') {
await user.click(screen.getByRole('button', { name: 'new-chat' }));
} else if (entry === 'typed prompt') {
await user.type(screen.getByRole('textbox'), 'Hello');
await user.click(screen.getByRole('button', { name: 'Send message' }));
} else {
await user.click(screen.getByRole('button', { name: 'Start chat: What can you do for me?' }));
}
const chatId = store.getState().chats.chats[0].id;

// A successful stream response clears this marker before history settles.
if (streamConnected) unmarkChatAsClientCreated(chatId);
resolveHistory([]);

await waitFor(() => {
expect(store.getState().chats.threadsListHydrated).toBe(true);
});
expect(store.getState().chats.chats.map((chat) => chat.id)).toContain(chatId);
});

it('preserves a chat already connecting at mount when its marker clears before history returns', async () => {
let resolveHistory!: (history: []) => void;
vi.mocked(getAllThreadsByUserId).mockReturnValueOnce(
new Promise((resolve) => { resolveHistory = resolve; }),
);
const store = seedStore();
markChatAsClientCreated('c1');
renderLayout(store);

unmarkChatAsClientCreated('c1');
resolveHistory([]);

await waitFor(() => {
expect(store.getState().chats.threadsListHydrated).toBe(true);
});
expect(store.getState().chats.chats.map((chat) => chat.id)).toContain('c1');
});

it('still removes pre-existing local chats absent from startup history', async () => {
vi.mocked(getAllThreadsByUserId).mockResolvedValueOnce([]);
const store = seedStore();
renderLayout(store);

await waitFor(() => {
expect(store.getState().chats.threadsListHydrated).toBe(true);
});
expect(store.getState().chats.chats).toEqual([]);
});

it('does not wipe local chats when the project is already gone', async () => {
const user = userEvent.setup();
mocks.deleteProject.mockResolvedValue({
Expand Down
15 changes: 9 additions & 6 deletions src/frontend/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useStore } from 'react-redux';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
import {
Expand Down Expand Up @@ -115,8 +116,7 @@ export function AppLayout({ children }: AppLayoutProps) {
const navigate = useNavigate();
const location = useLocation();

const chatsRef = useRef(chats);
useEffect(() => { chatsRef.current = chats; }, [chats]);
const store = useStore<RootState>();

useEffect(() => {
setAuthExpiredCallback(() => {
Expand Down Expand Up @@ -153,17 +153,20 @@ export function AppLayout({ children }: AppLayoutProps) {

useEffect(() => {
async function loadUserHistory() {
const initialChatIds = new Set(store.getState().chats.chats.map((c) => c.id));
const initialClientCreatedIds = new Set([...initialChatIds].filter(isClientCreatedChat));
try {
dispatch(setLoadingThreads(true));
const history = await withProjectThreads(
await getAllThreadsByUserId(window.USER_DATA.preferred_username || window.USER_DATA.sub),
);

const backendIds = new Set(history.map((t) => t.id));
const local = chatsRef.current;
const local = store.getState().chats.chats;

const surviving = local.filter(
(c) => backendIds.has(c.id) || isClientCreatedChat(c.id),
(c) => backendIds.has(c.id) || isClientCreatedChat(c.id) ||
initialClientCreatedIds.has(c.id) || !initialChatIds.has(c.id),
);

const backendTitleMap = new Map(
Expand Down Expand Up @@ -254,7 +257,7 @@ export function AppLayout({ children }: AppLayoutProps) {
}

loadUserHistory();
}, [dispatch]);
}, [dispatch, store]);

useEffect(() => {
if (chats.length > 0) {
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
import { Send } from 'lucide-react';
import { useAppDispatch } from '../redux/hooks';
import { addChat, ChatItem } from '../redux/slices/chats';
import { markChatAsClientCreated } from '../services/newChatTracker';

function builtinQuickPrompts(): string[] {
return [
Expand Down Expand Up @@ -35,6 +36,7 @@ export function HomePage() {
const startChat = useCallback(
(initialPrompt?: string) => {
const newChatId = uuidv4();
markChatAsClientCreated(newChatId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const newChat: ChatItem = {
id: newChatId,
title: initialPrompt?.substring(0, 40) || 'New Chat',
Expand Down
Loading