Skip to content

Commit 8baf19a

Browse files
kfaracikclaude
andcommitted
Merge branch 'main' into feat/chat-fade-edges
#227 (conversation forking) reworked the same three chat files, so the overlapping pieces were resolved by hand: - ChatBar: main also switched onHeightChange to report the baseline, and additionally sends 0 when there are no messages, which subsumes the hasMessages guard this branch kept on the consumer side. Took main's call and added theme.insets.bottom to the dependency list, which the inset re-capture needs. - ChatScreen: kept main's messagesLayer wrapper, userActionMenuOverlay and the LAYOUT_HEIGHT_CHANGE_THRESHOLD height handler (its 0.5dp filter is finer than the discarded hasMessages guard), renamed the state to chatBarHeight and dropped chatBarSpacer. The chat bar is an absolute overlay, so its zIndex/elevation of 2 moved from the removed spacer onto chatBarSticky. - Messages: unioned the new branching props with chatBarInset. Took main's bottomOffset doc, its pinActive guard, its Pressable scroll-to-bottom button and keyboardShouldPersistTaps="handled"; kept the memoised contentContainerStyle and the fade styles. tsc reports exactly the same error set as origin/main, so the merge adds no new type errors. 467 tests pass. Not yet re-checked on device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 8557712 + ec8fe09 commit 8baf19a

32 files changed

Lines changed: 1790 additions & 155 deletions

__tests__/DrawerMenu.test.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@ const mockChats = [
4646

4747
const mockRenameChat = jest.fn();
4848
const mockDeleteChat = jest.fn();
49+
let mockPhantomChat: { id: number } | null = null;
4950
jest.mock('../store/chatStore', () => ({
5051
useChatStore: jest.fn(() => ({
5152
chats: mockChats,
52-
phantomChat: null,
53+
phantomChat: mockPhantomChat,
54+
getChatById: (id: number) => mockChats.find((chat) => chat.id === id),
5355
renameChat: mockRenameChat,
5456
deleteChat: mockDeleteChat,
5557
})),
@@ -90,6 +92,7 @@ const renderMenu = (props: Partial<MenuProps> = {}) =>
9092
beforeEach(() => {
9193
jest.clearAllMocks();
9294
mockPathname = '/';
95+
mockPhantomChat = null;
9396
setPlatform('ios');
9497
});
9598

@@ -177,6 +180,28 @@ describe('DrawerMenu — collapsed', () => {
177180
expect(onNavigate).toHaveBeenCalled();
178181
});
179182

183+
it('only closes the drawer when already on the new chat screen', () => {
184+
mockPhantomChat = { id: 4 };
185+
mockPathname = '/chat/4';
186+
const onNavigate = jest.fn();
187+
renderMenu({ onNavigate });
188+
189+
fireEvent.press(screen.getByTestId('drawer-new-chat'));
190+
191+
expect(mockStartPhantomChat).not.toHaveBeenCalled();
192+
expect(onNavigate).toHaveBeenCalled();
193+
});
194+
195+
it('starts a phantom chat when a forked chat claimed the phantom id', () => {
196+
mockPhantomChat = { id: 3 };
197+
mockPathname = '/chat/3';
198+
renderMenu();
199+
200+
fireEvent.press(screen.getByTestId('drawer-new-chat'));
201+
202+
expect(mockStartPhantomChat).toHaveBeenCalledWith({}, 'replace');
203+
});
204+
180205
it('navigates to the chat when an item is pressed', () => {
181206
renderMenu();
182207

__tests__/MessageItem.test.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,32 @@ jest.mock('../components/chat-screen/AnimatedChatLoading', () => () => null);
4141
import MessageItem from '../components/chat-screen/MessageItem';
4242
import { useLLMStore } from '../store/llmStore';
4343

44-
const mockUseLLMStore = useLLMStore as jest.Mock;
44+
const mockUseLLMStore = useLLMStore as unknown as jest.Mock;
45+
46+
// The component reads the store via selectors; make the mock honor them.
47+
const mockLLMState = (state: {
48+
isGenerating?: boolean;
49+
isProcessingPrompt?: boolean;
50+
}) =>
51+
mockUseLLMStore.mockImplementation(
52+
(selector?: (s: typeof state) => unknown) =>
53+
selector ? selector(state) : state
54+
);
55+
56+
const baseMessage = {
57+
id: 1,
58+
role: 'assistant',
59+
content: 'Hello world',
60+
chatId: 1,
61+
timestamp: 0,
62+
} as React.ComponentProps<typeof MessageItem>['message'];
4563

4664
const renderItem = (
4765
props: Partial<React.ComponentProps<typeof MessageItem>> = {}
4866
) =>
4967
render(
5068
<MessageItem
69+
message={baseMessage}
5170
content="Hello world"
5271
role="assistant"
5372
isLastMessage={false}
@@ -56,7 +75,7 @@ const renderItem = (
5675
);
5776

5877
beforeEach(() => {
59-
mockUseLLMStore.mockReturnValue({ isGenerating: false });
78+
mockLLMState({ isGenerating: false });
6079
jest.spyOn(console, 'error').mockImplementation(() => {});
6180
});
6281

@@ -248,14 +267,14 @@ describe('thinking block parsing', () => {
248267
});
249268

250269
it('marks ThinkingBlock as inProgress when last message and isGenerating and thinking is incomplete', () => {
251-
mockUseLLMStore.mockReturnValue({ isGenerating: true });
270+
mockLLMState({ isGenerating: true });
252271
renderItem({ content: '<think>working...', isLastMessage: true });
253272
const block = screen.getByTestId('thinking-block');
254273
expect(block.props.accessibilityLabel).toContain('inProgress:true');
255274
});
256275

257276
it('does not mark ThinkingBlock as inProgress when not isLastMessage', () => {
258-
mockUseLLMStore.mockReturnValue({ isGenerating: true });
277+
mockLLMState({ isGenerating: true });
259278
renderItem({ content: '<think>working...', isLastMessage: false });
260279
const block = screen.getByTestId('thinking-block');
261280
expect(block.props.accessibilityLabel).toContain('inProgress:false');

__tests__/chatRepository.test.ts

Lines changed: 241 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { persistMessage } from '../database/chatRepository';
1+
import { forkChat, persistMessage } from '../database/chatRepository';
2+
import type { SQLiteDatabase } from 'expo-sqlite';
3+
4+
type TransactionCallback = Parameters<
5+
SQLiteDatabase['withTransactionAsync']
6+
>[0];
27

38
jest.mock('expo-sqlite', () => ({
49
useSQLiteContext: jest.fn(() => ({})),
@@ -10,7 +15,7 @@ jest.mock('@react-native-async-storage/async-storage', () => ({
1015
describe('persistMessage with imagePath', () => {
1116
it('includes imagePath in INSERT when provided', async () => {
1217
const runAsync = jest.fn().mockResolvedValue({ lastInsertRowId: 1 });
13-
const mockDb = { runAsync } as any;
18+
const mockDb = { runAsync } as SQLiteDatabase;
1419

1520
await persistMessage(mockDb, {
1621
role: 'user',
@@ -27,7 +32,7 @@ describe('persistMessage with imagePath', () => {
2732

2833
it('passes null imagePath when not provided', async () => {
2934
const runAsync = jest.fn().mockResolvedValue({ lastInsertRowId: 2 });
30-
const mockDb = { runAsync } as any;
35+
const mockDb = { runAsync } as SQLiteDatabase;
3136

3237
await persistMessage(mockDb, {
3338
role: 'user',
@@ -41,3 +46,236 @@ describe('persistMessage with imagePath', () => {
4146
);
4247
});
4348
});
49+
50+
describe('forkChat', () => {
51+
it('creates a branch chat and copies messages up to the target message', async () => {
52+
const runAsync = jest
53+
.fn()
54+
.mockResolvedValueOnce({ lastInsertRowId: 10 })
55+
.mockResolvedValueOnce({ lastInsertRowId: 101 })
56+
.mockResolvedValueOnce({ lastInsertRowId: 102 })
57+
.mockResolvedValue({ lastInsertRowId: 0 });
58+
const getFirstAsync = jest.fn().mockResolvedValue({
59+
id: 1,
60+
title: 'Original',
61+
modelId: 7,
62+
lastUsed: 1,
63+
});
64+
const getAllAsync = jest
65+
.fn()
66+
.mockResolvedValueOnce([
67+
{
68+
id: 1,
69+
chatId: 1,
70+
role: 'user',
71+
content: 'one',
72+
timestamp: 100,
73+
},
74+
{
75+
id: 2,
76+
chatId: 1,
77+
role: 'assistant',
78+
content: 'two',
79+
timestamp: 200,
80+
modelName: 'model',
81+
},
82+
{
83+
id: 3,
84+
chatId: 1,
85+
role: 'user',
86+
content: 'three',
87+
timestamp: 300,
88+
},
89+
])
90+
.mockResolvedValueOnce([]);
91+
const mockDb = {
92+
runAsync,
93+
getFirstAsync,
94+
getAllAsync,
95+
withTransactionAsync: async (callback: TransactionCallback) => callback(),
96+
} as SQLiteDatabase;
97+
98+
const newChatId = await forkChat(mockDb, 1, 2);
99+
100+
expect(newChatId).toBe(10);
101+
expect(runAsync).toHaveBeenCalledWith(
102+
`INSERT INTO chats (title, modelId, lastUsed) VALUES (?, ?, ?)`,
103+
['Original', 7, expect.any(Number)]
104+
);
105+
expect(runAsync).toHaveBeenCalledWith(
106+
expect.stringContaining('INSERT INTO messages'),
107+
[10, 'user', 'one', 100, '', 0, 0, null, null]
108+
);
109+
expect(runAsync).toHaveBeenCalledWith(
110+
expect.stringContaining('INSERT INTO messages'),
111+
[10, 'assistant', 'two', 200, 'model', 0, 0, null, null]
112+
);
113+
expect(runAsync).toHaveBeenCalledWith(
114+
expect.stringContaining('INSERT INTO chatBranches'),
115+
[10, 102, 1, 2, 'Original', 'two']
116+
);
117+
expect(runAsync).toHaveBeenCalledWith(
118+
expect.stringContaining('INSERT INTO chatSettings'),
119+
[10, 1]
120+
);
121+
expect(runAsync).toHaveBeenCalledWith(
122+
expect.stringContaining('INSERT INTO chatSources'),
123+
[10, 1]
124+
);
125+
});
126+
127+
it('throws when the target message is not in the original chat', async () => {
128+
const mockDb = {
129+
runAsync: jest.fn(),
130+
getFirstAsync: jest.fn().mockResolvedValue({
131+
id: 1,
132+
title: 'Original',
133+
modelId: 7,
134+
lastUsed: 1,
135+
}),
136+
getAllAsync: jest.fn().mockResolvedValueOnce([
137+
{
138+
id: 1,
139+
chatId: 1,
140+
role: 'user',
141+
content: 'one',
142+
timestamp: 100,
143+
},
144+
]),
145+
withTransactionAsync: async (callback: TransactionCallback) => callback(),
146+
} as SQLiteDatabase;
147+
148+
await expect(forkChat(mockDb, 1, 999)).rejects.toThrow(
149+
'Message 999 not found in chat 1'
150+
);
151+
expect(mockDb.runAsync).not.toHaveBeenCalled();
152+
});
153+
154+
it('preserves existing branch markers when forking from a branch', async () => {
155+
const runAsync = jest
156+
.fn()
157+
.mockResolvedValueOnce({ lastInsertRowId: 10 })
158+
.mockResolvedValueOnce({ lastInsertRowId: 101 })
159+
.mockResolvedValueOnce({ lastInsertRowId: 102 })
160+
.mockResolvedValueOnce({ lastInsertRowId: 103 })
161+
.mockResolvedValue({ lastInsertRowId: 0 });
162+
const mockDb = {
163+
runAsync,
164+
getFirstAsync: jest.fn().mockResolvedValue({
165+
id: 1,
166+
title: 'Fork 1',
167+
modelId: 7,
168+
lastUsed: 1,
169+
}),
170+
getAllAsync: jest
171+
.fn()
172+
.mockResolvedValueOnce([
173+
{
174+
id: 1,
175+
chatId: 1,
176+
role: 'user',
177+
content: 'one',
178+
timestamp: 100,
179+
},
180+
{
181+
id: 2,
182+
chatId: 1,
183+
role: 'assistant',
184+
content: 'two',
185+
timestamp: 200,
186+
},
187+
{
188+
id: 3,
189+
chatId: 1,
190+
role: 'assistant',
191+
content: 'three',
192+
timestamp: 300,
193+
},
194+
])
195+
.mockResolvedValueOnce([
196+
{
197+
id: 1,
198+
chatId: 1,
199+
afterMessageId: 2,
200+
sourceChatId: 8,
201+
sourceMessageId: 20,
202+
sourceChatTitle: 'Original',
203+
sourceMessagePreview: 'two',
204+
createdAt: 1,
205+
},
206+
]),
207+
withTransactionAsync: async (callback: TransactionCallback) => callback(),
208+
} as SQLiteDatabase;
209+
210+
await forkChat(mockDb, 1, 3);
211+
212+
expect(runAsync).toHaveBeenCalledWith(
213+
expect.stringContaining('INSERT INTO chatBranches'),
214+
[10, 102, 8, 20, 'Original', 'two']
215+
);
216+
expect(runAsync).toHaveBeenCalledWith(
217+
expect.stringContaining('INSERT INTO chatBranches'),
218+
[10, 103, 1, 3, 'Fork 1', 'three']
219+
);
220+
});
221+
222+
it('replaces an existing branch marker at the target message with the latest branch marker', async () => {
223+
const runAsync = jest
224+
.fn()
225+
.mockResolvedValueOnce({ lastInsertRowId: 10 })
226+
.mockResolvedValueOnce({ lastInsertRowId: 101 })
227+
.mockResolvedValueOnce({ lastInsertRowId: 102 })
228+
.mockResolvedValue({ lastInsertRowId: 0 });
229+
const mockDb = {
230+
runAsync,
231+
getFirstAsync: jest.fn().mockResolvedValue({
232+
id: 1,
233+
title: 'Fork 1',
234+
modelId: 7,
235+
lastUsed: 1,
236+
}),
237+
getAllAsync: jest
238+
.fn()
239+
.mockResolvedValueOnce([
240+
{
241+
id: 1,
242+
chatId: 1,
243+
role: 'user',
244+
content: 'one',
245+
timestamp: 100,
246+
},
247+
{
248+
id: 2,
249+
chatId: 1,
250+
role: 'assistant',
251+
content: 'two',
252+
timestamp: 200,
253+
},
254+
])
255+
.mockResolvedValueOnce([
256+
{
257+
id: 1,
258+
chatId: 1,
259+
afterMessageId: 2,
260+
sourceChatId: 8,
261+
sourceMessageId: 20,
262+
sourceChatTitle: 'Original',
263+
sourceMessagePreview: 'old marker',
264+
createdAt: 1,
265+
},
266+
]),
267+
withTransactionAsync: async (callback: TransactionCallback) => callback(),
268+
} as SQLiteDatabase;
269+
270+
await forkChat(mockDb, 1, 2);
271+
272+
expect(runAsync).not.toHaveBeenCalledWith(
273+
expect.stringContaining('INSERT INTO chatBranches'),
274+
[10, 102, 8, 20, 'Original', 'old marker']
275+
);
276+
expect(runAsync).toHaveBeenCalledWith(
277+
expect.stringContaining('INSERT INTO chatBranches'),
278+
[10, 102, 1, 2, 'Fork 1', 'two']
279+
);
280+
});
281+
});

0 commit comments

Comments
 (0)