Skip to content

Commit 31ee675

Browse files
authored
feat(drawer): revamp the sidebar — chat search, context menu & top bar (#243)
## Summary Redesign of the sidebar (closes #233): the drawer gets a proper top bar, in-drawer chat search, and a per-chat context menu, plus a round of Android interaction polish. Destructive actions now confirm through a bottom sheet instead of the native alert. ## What's included **New drawer** - Top bar with title + search entry point; search overlay expands from drawer width to full screen and filters chats live. - Per-chat context menu on long-press (rename / export / delete / cancel). - Nav section, empty state, and date-grouped chat sections. **Destructive-action confirmations** - New `useConfirm` hook — native `Alert` on iOS, `WarningSheet` bottom sheet on Android. - Adopted for chat delete and Model Hub delete / clear-all. **Android polish & fixes** - Search keyboard now opens reliably (focus on `onShow`, not `autoFocus`) and overlay touches work (`GestureHandlerRootView` inside the modal). - Animated, opaque search backdrop — no bleed-through of the screen behind on expand, smooth reveal on collapse. - Search title icon stays put during expand (no left/right jump). - Rename-chat dialog keeps the keyboard up and stops jumping (keyboard-controller). - Opening a chat from search no longer flashes the previous screen. **Refactor** - Extracted chat rename/export/delete into a shared `useChatActions` hook, reused by the chat title menu and the drawer context menu. Closes #233
1 parent 7a1c8fa commit 31ee675

29 files changed

Lines changed: 2758 additions & 231 deletions

__tests__/DrawerMenu.test.tsx

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react-native';
3+
import { Platform, ActionSheetIOS } from 'react-native';
4+
5+
jest.mock('../context/ThemeContext', () => ({
6+
useTheme: () => ({ theme: require('./helpers/renderWithTheme').testTheme }),
7+
}));
8+
9+
jest.mock('react-native-gesture-handler', () => {
10+
const RN = require('react-native');
11+
return { ScrollView: RN.ScrollView, Pressable: RN.Pressable };
12+
});
13+
14+
jest.mock('expo-sqlite', () => ({
15+
useSQLiteContext: jest.fn(() => ({})),
16+
}));
17+
18+
const mockReplace = jest.fn();
19+
const mockPush = jest.fn();
20+
let mockPathname = '/';
21+
22+
jest.mock('expo-router', () => ({
23+
router: { replace: jest.fn(), push: jest.fn(), back: jest.fn() },
24+
useRouter: () => ({ replace: mockReplace, push: mockPush, back: jest.fn() }),
25+
usePathname: () => mockPathname,
26+
}));
27+
28+
const mockStartPhantomChat = jest.fn();
29+
jest.mock('../utils/startPhantomChat', () => ({
30+
startPhantomChat: (...args: unknown[]) => mockStartPhantomChat(...args),
31+
}));
32+
33+
const mockInterrupt = jest.fn();
34+
jest.mock('../store/llmStore', () => ({
35+
useLLMStore: jest.fn(() => ({ interrupt: mockInterrupt })),
36+
}));
37+
38+
const HOUR = 60 * 60 * 1000;
39+
const DAY = 24 * HOUR;
40+
const mockNow = new Date('2026-03-10T12:00:00Z').getTime();
41+
const mockChats = [
42+
{ id: 1, modelId: 1, title: 'Pizza recipe', lastUsed: mockNow - HOUR },
43+
{ id: 2, modelId: 1, title: 'Meeting notes', lastUsed: mockNow - 2 * HOUR },
44+
{ id: 3, modelId: 1, title: 'Trip to Rome', lastUsed: mockNow - DAY },
45+
];
46+
47+
const mockRenameChat = jest.fn();
48+
const mockDeleteChat = jest.fn();
49+
jest.mock('../store/chatStore', () => ({
50+
useChatStore: jest.fn(() => ({
51+
chats: mockChats,
52+
phantomChat: null,
53+
renameChat: mockRenameChat,
54+
deleteChat: mockDeleteChat,
55+
})),
56+
}));
57+
58+
jest.mock('../context/VectorStoreContext', () => ({
59+
useVectorStore: jest.fn(() => ({ vectorStore: null })),
60+
}));
61+
62+
jest.mock('../database/exportImportRepository', () => ({
63+
exportChatRoom: jest.fn(),
64+
}));
65+
66+
import DrawerMenu from '../components/drawer/DrawerMenu';
67+
68+
const setPlatform = (os: string) => {
69+
Object.defineProperty(Platform, 'OS', { get: () => os, configurable: true });
70+
};
71+
72+
const ORIGINAL_OS = Platform.OS;
73+
74+
const defaultProps = {
75+
searching: false,
76+
search: '',
77+
now: mockNow,
78+
navHeight: 151,
79+
onNavMeasured: jest.fn(),
80+
onChangeSearch: jest.fn(),
81+
onOpenSearch: jest.fn(),
82+
onCloseSearch: jest.fn(),
83+
};
84+
85+
type MenuProps = React.ComponentProps<typeof DrawerMenu>;
86+
87+
const renderMenu = (props: Partial<MenuProps> = {}) =>
88+
render(<DrawerMenu {...defaultProps} {...props} />);
89+
90+
beforeEach(() => {
91+
jest.clearAllMocks();
92+
mockPathname = '/';
93+
setPlatform('ios');
94+
});
95+
96+
afterEach(() => setPlatform(ORIGINAL_OS));
97+
98+
describe('DrawerMenu — collapsed', () => {
99+
it('keeps New chat, Models and App Info at the top', () => {
100+
renderMenu();
101+
102+
expect(screen.getByText('New chat')).toBeTruthy();
103+
expect(screen.getByText('Models')).toBeTruthy();
104+
expect(screen.getByText('App Info')).toBeTruthy();
105+
});
106+
107+
it('renders the app name and a search button instead of a search field', () => {
108+
renderMenu();
109+
110+
expect(screen.getByText('Private Mind')).toBeTruthy();
111+
expect(screen.getByTestId('drawer-search-open')).toBeTruthy();
112+
expect(screen.queryByTestId('drawer-search-input')).toBeNull();
113+
});
114+
115+
it('requests the search state when the search icon is pressed', () => {
116+
const onOpenSearch = jest.fn();
117+
renderMenu({ onOpenSearch });
118+
119+
fireEvent.press(screen.getByTestId('drawer-search-open'));
120+
121+
expect(onOpenSearch).toHaveBeenCalled();
122+
});
123+
124+
it('renders chats grouped by relative date', () => {
125+
renderMenu();
126+
127+
expect(screen.getByText('Today')).toBeTruthy();
128+
expect(screen.getByText('Yesterday')).toBeTruthy();
129+
expect(screen.getByText('Pizza recipe')).toBeTruthy();
130+
});
131+
132+
it('groups chats against the given now instead of the wall clock', () => {
133+
const { rerender } = render(<DrawerMenu {...defaultProps} />);
134+
expect(screen.getByText('Today')).toBeTruthy();
135+
136+
rerender(<DrawerMenu {...defaultProps} now={mockNow + 30 * HOUR} />);
137+
138+
expect(screen.queryByText('Today')).toBeNull();
139+
expect(screen.getByText('Yesterday')).toBeTruthy();
140+
expect(screen.getByText('2 days ago')).toBeTruthy();
141+
});
142+
143+
it('records the scroll offset it was scrolled to', () => {
144+
const scrollOffsetRef = { current: 0 };
145+
renderMenu({ scrollOffsetRef });
146+
147+
fireEvent.scroll(screen.getByTestId('drawer-scroll'), {
148+
nativeEvent: { contentOffset: { x: 0, y: 120 } },
149+
});
150+
151+
expect(scrollOffsetRef.current).toBe(120);
152+
});
153+
154+
it('keeps the sections identical between the collapsed and searching states', () => {
155+
const sectionsOf = (element: React.ReactElement) => {
156+
const view = render(element);
157+
const titles = ['Today', 'Yesterday', '2 days ago'].filter(
158+
(title) => view.queryByText(title) !== null
159+
);
160+
view.unmount();
161+
return titles;
162+
};
163+
164+
expect(sectionsOf(<DrawerMenu {...defaultProps} searching />)).toEqual(
165+
sectionsOf(<DrawerMenu {...defaultProps} />)
166+
);
167+
});
168+
169+
it('starts a phantom chat when New chat is pressed', () => {
170+
mockPathname = '/model-hub';
171+
const onNavigate = jest.fn();
172+
renderMenu({ onNavigate });
173+
174+
fireEvent.press(screen.getByTestId('drawer-new-chat'));
175+
176+
expect(mockStartPhantomChat).toHaveBeenCalledWith({}, 'replace');
177+
expect(onNavigate).toHaveBeenCalled();
178+
});
179+
180+
it('navigates to the chat when an item is pressed', () => {
181+
renderMenu();
182+
183+
fireEvent.press(screen.getByTestId('drawer-chat-1'));
184+
185+
expect(mockReplace).toHaveBeenCalledWith('/chat/1');
186+
});
187+
});
188+
189+
describe('DrawerMenu — searching', () => {
190+
it('shows the search field and back button instead of the search icon', () => {
191+
renderMenu({ searching: true });
192+
193+
expect(screen.getByTestId('drawer-search-back')).toBeTruthy();
194+
expect(screen.getByTestId('drawer-search-input')).toBeTruthy();
195+
expect(screen.queryByTestId('drawer-search-open')).toBeNull();
196+
});
197+
198+
it('opens the list at the offset the drawer was scrolled to', () => {
199+
const scrollOffsetRef = { current: 120 };
200+
renderMenu({ searching: true, scrollOffsetRef });
201+
202+
expect(screen.getByTestId('drawer-scroll').props.contentOffset).toEqual({
203+
x: 0,
204+
y: 120,
205+
});
206+
});
207+
208+
it('does not record the offset of a filtered list', () => {
209+
const scrollOffsetRef = { current: 120 };
210+
renderMenu({
211+
searching: true,
212+
search: 'meeting',
213+
scrollOffsetRef,
214+
});
215+
216+
fireEvent.scroll(screen.getByTestId('drawer-scroll'), {
217+
nativeEvent: { contentOffset: { x: 0, y: 0 } },
218+
});
219+
220+
expect(scrollOffsetRef.current).toBe(120);
221+
});
222+
223+
it('keeps the navigation items while the query is still empty', () => {
224+
renderMenu({ searching: true });
225+
226+
expect(screen.getByText('New chat')).toBeTruthy();
227+
expect(screen.getByText('Models')).toBeTruthy();
228+
expect(screen.getByText('App Info')).toBeTruthy();
229+
});
230+
231+
it('hides the navigation items once a query is typed, leaving only results', () => {
232+
renderMenu({ searching: true, search: 'pizza' });
233+
234+
expect(screen.queryByText('New chat')).toBeNull();
235+
expect(screen.queryByText('Models')).toBeNull();
236+
expect(screen.queryByText('App Info')).toBeNull();
237+
expect(screen.getByText('Pizza recipe')).toBeTruthy();
238+
});
239+
240+
it('brings the navigation back when the query is cleared', () => {
241+
const { rerender } = renderMenu({ searching: true, search: 'pizza' });
242+
expect(screen.queryByText('Models')).toBeNull();
243+
244+
rerender(
245+
<DrawerMenu
246+
{...defaultProps}
247+
searching
248+
search=""
249+
onNavigate={jest.fn()}
250+
/>
251+
);
252+
253+
expect(screen.getByText('Models')).toBeTruthy();
254+
});
255+
256+
it('filters the chat list by the query', () => {
257+
renderMenu({ searching: true, search: 'rome' });
258+
259+
expect(screen.getByText('Trip to Rome')).toBeTruthy();
260+
expect(screen.queryByText('Pizza recipe')).toBeNull();
261+
expect(screen.queryByText('Today')).toBeNull();
262+
});
263+
264+
it('matches case-insensitively and ignores surrounding whitespace', () => {
265+
renderMenu({ searching: true, search: ' PIZZA ' });
266+
267+
expect(screen.getByText('Pizza recipe')).toBeTruthy();
268+
expect(screen.queryByText('Trip to Rome')).toBeNull();
269+
});
270+
271+
it('shows an empty state when nothing matches', () => {
272+
renderMenu({ searching: true, search: 'nonexistent' });
273+
274+
expect(screen.getByText('No chats found')).toBeTruthy();
275+
expect(screen.getByText('Start new chat')).toBeTruthy();
276+
});
277+
278+
it('starts a new chat from the empty state', () => {
279+
const onNavigate = jest.fn();
280+
renderMenu({
281+
searching: true,
282+
search: 'nonexistent',
283+
onNavigate,
284+
});
285+
286+
fireEvent.press(screen.getByText('Start new chat'));
287+
288+
expect(mockStartPhantomChat).toHaveBeenCalledWith({}, 'replace');
289+
expect(onNavigate).toHaveBeenCalled();
290+
});
291+
292+
it('closes the search state from the back button', () => {
293+
const onCloseSearch = jest.fn();
294+
renderMenu({ searching: true, onCloseSearch });
295+
296+
fireEvent.press(screen.getByTestId('drawer-search-back'));
297+
298+
expect(onCloseSearch).toHaveBeenCalled();
299+
});
300+
});
301+
302+
describe('DrawerMenu — context menu', () => {
303+
it('opens the context menu on long press', () => {
304+
const spy = jest
305+
.spyOn(ActionSheetIOS, 'showActionSheetWithOptions')
306+
.mockImplementation(() => {});
307+
308+
renderMenu();
309+
310+
fireEvent(screen.getByTestId('drawer-chat-1'), 'longPress');
311+
312+
expect(spy).toHaveBeenCalledWith(
313+
expect.objectContaining({
314+
options: ['Rename', 'Export Chat', 'Delete Chat', 'Cancel'],
315+
destructiveButtonIndex: 2,
316+
cancelButtonIndex: 3,
317+
}),
318+
expect.any(Function)
319+
);
320+
});
321+
322+
it('does not open the context menu on a plain press', () => {
323+
const spy = jest
324+
.spyOn(ActionSheetIOS, 'showActionSheetWithOptions')
325+
.mockImplementation(() => {});
326+
327+
renderMenu();
328+
329+
fireEvent.press(screen.getByTestId('drawer-chat-1'));
330+
331+
expect(spy).not.toHaveBeenCalled();
332+
});
333+
334+
it('reports the menu as active so the search state is not torn down', () => {
335+
jest
336+
.spyOn(ActionSheetIOS, 'showActionSheetWithOptions')
337+
.mockImplementation(() => {});
338+
const onMenuActiveChange = jest.fn();
339+
340+
renderMenu({ searching: true, onMenuActiveChange });
341+
342+
fireEvent(screen.getByTestId('drawer-chat-1'), 'longPress');
343+
344+
expect(onMenuActiveChange).toHaveBeenCalledWith(true);
345+
});
346+
});

__tests__/chatLabel.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { chatLabel } from '../utils/chatLabel';
2+
3+
describe('chatLabel', () => {
4+
it('returns the title when the chat has one', () => {
5+
expect(chatLabel({ id: 3, title: 'Trip to Rome' })).toBe('Trip to Rome');
6+
});
7+
8+
it('falls back to "Chat <id>" for an empty title', () => {
9+
expect(chatLabel({ id: 9, title: '' })).toBe('Chat 9');
10+
});
11+
});

0 commit comments

Comments
 (0)