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
121 changes: 119 additions & 2 deletions src/frontend/components/layout/AppLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, screen, waitFor } from '@testing-library/react';
Expand All @@ -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 { getAllThreadsByUserId, getThreadState } 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 All @@ -84,6 +89,20 @@ vi.mock('../Sidebar', () => ({
),
}));

const clientCreatedTestIds = new Set<string>();

beforeEach(() => {
vi.mocked(getAllThreadsByUserId).mockReset();
vi.mocked(getThreadState).mockReset();
unmarkChatAsClientCreated('c1');
});

afterEach(() => {
unmarkChatAsClientCreated('c1');
for (const id of clientCreatedTestIds) unmarkChatAsClientCreated(id);
clientCreatedTestIds.clear();
});

function seedStore() {
const store = createTestStore();
store.dispatch(
Expand Down Expand Up @@ -136,6 +155,104 @@ 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;
clientCreatedTestIds.add(chatId);

// 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.each([false, true])('warms a backend thread instead of a new local chat (marker cleared: %s)', async (markerCleared) => {
let resolveHistory!: (history: Array<{ id: string; updatedAt: string }>) => void;
vi.mocked(getAllThreadsByUserId).mockReturnValueOnce(
new Promise((resolve) => { resolveHistory = resolve; }),
);
const store = createTestStore();
renderLayout(store);

markChatAsClientCreated('c1');
store.dispatch(addChat({
id: 'c1',
title: 'New Chat',
timestamp: '2026-11-11T00:00:00.000Z',
preview: '',
messages: [],
historicalActivities: {},
feedback: {},
}));
if (markerCleared) unmarkChatAsClientCreated('c1');
resolveHistory([{ id: 'backend-chat', updatedAt: '2026-01-01T00:00:00.000Z' }]);

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

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
19 changes: 12 additions & 7 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 @@ -225,7 +228,9 @@ export function AppLayout({ children }: AppLayoutProps) {
}

// Pre-fetch most recent thread's messages (warm the cache)
const topThread = reconciled.find((c) => c.messages.length === 0);
const topThread = reconciled.find(
(c) => c.messages.length === 0 && backendIds.has(c.id) && !isClientCreatedChat(c.id),
);
if (topThread) {
getThreadState(topThread.id).then((msgs) => {
if (msgs.length > 0) {
Expand Down Expand Up @@ -254,7 +259,7 @@ export function AppLayout({ children }: AppLayoutProps) {
}

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

useEffect(() => {
if (chats.length > 0) {
Expand Down
49 changes: 48 additions & 1 deletion src/frontend/lib/streaming/StreamingManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StreamingManager } from './StreamingManager';
import type { StreamCallback } from './StreamingManager';
import { markChatAsClientCreated, isClientCreatedChat } from '@/services/newChatTracker';
import { markChatAsClientCreated, isClientCreatedChat, unmarkChatAsClientCreated } from '@/services/newChatTracker';

// ── Helpers ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -64,6 +64,7 @@ describe('StreamingManager', () => {
});

afterEach(() => {
unmarkChatAsClientCreated(BASE_REQUEST.threadId);
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -271,6 +272,52 @@ describe('StreamingManager', () => {
expect(isClientCreatedChat('thread-1')).toBe(false);
});

it.each(['HTTP error', 'network failure'])(
'allows server hydration after a first POST ends in %s',
async (failure) => {
markChatAsClientCreated(BASE_REQUEST.threadId);
if (failure === 'HTTP error') {
vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 500 }));
} else {
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Failed to fetch'));
}

const callbacks = makeCallbacks({
onError: vi.fn(() => {
expect(isClientCreatedChat(BASE_REQUEST.threadId)).toBe(false);
}),
});
await new StreamingManager().stream(BASE_REQUEST, callbacks);

expect(callbacks.onError).toHaveBeenCalledOnce();
expect(isClientCreatedChat(BASE_REQUEST.threadId)).toBe(false);
},
);

it('unmarks a client-created chat when the first POST is cancelled', async () => {
markChatAsClientCreated(BASE_REQUEST.threadId);
vi.mocked(fetch).mockImplementationOnce(
(_url, opts) => new Promise<Response>((_resolve, reject) => {
opts?.signal?.addEventListener('abort', () => {
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
});
}),
);

const manager = new StreamingManager();
const callbacks = makeCallbacks();
const streamPromise = manager.stream(BASE_REQUEST, callbacks);
expect(isClientCreatedChat(BASE_REQUEST.threadId)).toBe(true);
manager.cancel();
await streamPromise;

expect(isClientCreatedChat(BASE_REQUEST.threadId)).toBe(false);
expect(callbacks.onStatusChange).toHaveBeenCalledWith('cancelled');
expect(callbacks.onError).not.toHaveBeenCalled();
});

it('forwards message_id from token SSE to onToken callback', async () => {
const sseBody = makeTokenSSE('Hello', 0, 'server-msg-uuid') + 'data: [DONE]\n\n';
vi.mocked(fetch).mockResolvedValueOnce(makeStreamResponse(sseBody));
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/lib/streaming/StreamingManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ export class StreamingManager {
callbacks.onDone();
}
} catch (error: unknown) {
unmarkChatAsClientCreated(request.threadId);
Comment thread
Anish701 marked this conversation as resolved.
Resolved

let err: Error;
if (error instanceof Error) {
err = error;
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