Skip to content

Commit 1f364c3

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/rag-hybrid
# Conflicts: # components/chat-screen/Messages.tsx # ios/Podfile.lock
2 parents 5855e45 + 700363f commit 1f364c3

23 files changed

Lines changed: 617 additions & 106 deletions

__mocks__/react-native-executorch.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export const LFM2_5_VL_1_6B_QUANTIZED = makeModelConstants(
6262
export const LFM2_5_VL_450M_QUANTIZED = makeModelConstants(
6363
'lfm2.5-vl-450m-quantized'
6464
);
65+
export const BIELIK_V3_0_1_5B_QUANTIZED = makeModelConstants(
66+
'bielik-v3.0-1.5b-quantized'
67+
);
6568
export const GEMMA4_E2B = makeModelConstants('gemma4-e2b');
6669
export const GEMMA4_E2B_MM = makeModelConstants('gemma4-e2b-mm');
6770
export const WHISPER_TINY_EN = 'whisper-tiny-en';

__tests__/defaultModels.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { getStartingModels } from '../constants/default-models';
1+
import { BIELIK_V3_0_1_5B_QUANTIZED } from 'react-native-executorch';
2+
import { DEFAULT_MODELS, getStartingModels } from '../constants/default-models';
23

34
describe('getStartingModels', () => {
45
it('returns low-end model suggestions below 4 GB RAM', () => {
@@ -36,3 +37,29 @@ describe('getStartingModels', () => {
3637
expect(getStartingModels(-1)).toEqual(lowEnd);
3738
});
3839
});
40+
41+
describe('DEFAULT_MODELS paths', () => {
42+
it('sources Bielik paths from the react-native-executorch constant', () => {
43+
const bielik = DEFAULT_MODELS.find((m) => m.modelName === 'Bielik - v3.0');
44+
expect(bielik).toBeDefined();
45+
expect(bielik!.modelPath).toBe(BIELIK_V3_0_1_5B_QUANTIZED.modelSource);
46+
expect(bielik!.tokenizerPath).toBe(
47+
BIELIK_V3_0_1_5B_QUANTIZED.tokenizerSource
48+
);
49+
expect(bielik!.tokenizerConfigPath).toBe(
50+
BIELIK_V3_0_1_5B_QUANTIZED.tokenizerConfigSource
51+
);
52+
});
53+
54+
it('never points a default model at the mutable HF main branch', () => {
55+
for (const model of DEFAULT_MODELS) {
56+
for (const path of [
57+
model.modelPath,
58+
model.tokenizerPath,
59+
model.tokenizerConfigPath,
60+
]) {
61+
expect(path).not.toContain('/resolve/main/');
62+
}
63+
}
64+
});
65+
});

__tests__/modelPathRefresh.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { SQLiteDatabase } from 'expo-sqlite';
2+
3+
jest.mock('../store/chatStore', () => ({
4+
useChatStore: { getState: () => ({}) },
5+
}));
6+
jest.mock('../store/llmStore', () => ({
7+
useLLMStore: { getState: () => ({}) },
8+
}));
9+
jest.mock('../store/modelStore', () => ({
10+
useModelStore: { getState: () => ({}) },
11+
}));
12+
jest.mock('../store/sourceStore', () => ({
13+
useSourceStore: { getState: () => ({}) },
14+
}));
15+
jest.mock('../database/modelRepository', () => ({ addModel: jest.fn() }));
16+
jest.mock('../constants/default-models', () => ({
17+
DEFAULT_MODELS: [
18+
{
19+
modelName: 'Bielik - v3.0',
20+
family: 'Bielik',
21+
modelPath: 'https://hf.example/resolve/v0.9.0/xnnpack/bielik.pte',
22+
tokenizerPath: 'https://hf.example/resolve/v0.9.0/tokenizer.json',
23+
tokenizerConfigPath:
24+
'https://hf.example/resolve/v0.9.0/tokenizer_config.json',
25+
source: 'remote',
26+
modelSize: 0.86,
27+
featured: true,
28+
},
29+
],
30+
}));
31+
32+
import { runMigrations } from '../database/db';
33+
34+
type Call = { sql: string; params: unknown[] };
35+
36+
const makeFakeDb = () => {
37+
const calls: Call[] = [];
38+
const db = {
39+
getAllAsync: async () => [],
40+
execAsync: async () => {},
41+
getFirstAsync: async () => null,
42+
runAsync: async (sql: string, ...params: unknown[]) => {
43+
calls.push({ sql, params: params.flat() });
44+
return {};
45+
},
46+
withTransactionAsync: async (fn: () => Promise<void>) => fn(),
47+
};
48+
return { db: db as unknown as SQLiteDatabase, calls };
49+
};
50+
51+
describe('runMigrations built-in model path refresh', () => {
52+
it('rewrites stale download paths for undownloaded built-in models', async () => {
53+
const { db, calls } = makeFakeDb();
54+
55+
await runMigrations(db);
56+
57+
const refresh = calls.find((c) => c.sql.includes('SET modelPath'));
58+
expect(refresh).toBeDefined();
59+
expect(refresh!.sql).toContain(`source = 'built-in'`);
60+
expect(refresh!.sql).toContain('isDownloaded = 0');
61+
expect(refresh!.params).toEqual([
62+
'https://hf.example/resolve/v0.9.0/xnnpack/bielik.pte',
63+
'https://hf.example/resolve/v0.9.0/tokenizer.json',
64+
'https://hf.example/resolve/v0.9.0/tokenizer_config.json',
65+
0.86,
66+
'Bielik - v3.0',
67+
]);
68+
});
69+
});

__tests__/modelRepository.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// __tests__/modelRepository.test.ts
22
import { type SQLiteDatabase } from 'expo-sqlite';
3-
import { getAllModels, getModelsByNames } from '../database/modelRepository';
3+
import {
4+
getAllModels,
5+
getModelsByNames,
6+
syncBuiltInModelPaths,
7+
} from '../database/modelRepository';
8+
import { DEFAULT_MODELS } from '../constants/default-models';
49

510
jest.mock('expo-sqlite', () => {
611
const stableDb = {};
@@ -167,3 +172,33 @@ describe('getModelsByNames', () => {
167172
]);
168173
});
169174
});
175+
176+
describe('syncBuiltInModelPaths', () => {
177+
it('rewrites a built-in row with paths from DEFAULT_MODELS', async () => {
178+
const bielik = DEFAULT_MODELS.find((m) => m.modelName === 'Bielik - v3.0')!;
179+
const runAsync = jest.fn().mockResolvedValue({});
180+
const mockDb = { runAsync } as unknown as SQLiteDatabase;
181+
182+
await syncBuiltInModelPaths(mockDb, 7, 'Bielik - v3.0');
183+
184+
expect(runAsync).toHaveBeenCalledTimes(1);
185+
const [sql, params] = runAsync.mock.calls[0];
186+
expect(sql).toContain(`source = 'built-in'`);
187+
expect(params).toEqual([
188+
bielik.modelPath,
189+
bielik.tokenizerPath,
190+
bielik.tokenizerConfigPath,
191+
bielik.modelSize,
192+
7,
193+
]);
194+
});
195+
196+
it('does nothing for a model name outside DEFAULT_MODELS', async () => {
197+
const runAsync = jest.fn();
198+
const mockDb = { runAsync } as unknown as SQLiteDatabase;
199+
200+
await syncBuiltInModelPaths(mockDb, 3, 'My Local Model');
201+
202+
expect(runAsync).not.toHaveBeenCalled();
203+
});
204+
});

__tests__/useKeyboardLift.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { renderHook } from '@testing-library/react-native';
2+
import { useKeyboardLift } from '../components/chat-screen/useKeyboardLift';
3+
4+
let mockInsetsBottom = 0;
5+
const mockHeight = { value: 0 };
6+
const mockProgress = { value: 0 };
7+
8+
jest.mock('../context/ThemeContext', () => ({
9+
useTheme: () => ({
10+
theme: {
11+
insets: { top: 0, bottom: mockInsetsBottom, left: 0, right: 0 },
12+
},
13+
}),
14+
}));
15+
16+
jest.mock('react-native-keyboard-controller', () => ({
17+
useReanimatedKeyboardAnimation: () => ({
18+
height: mockHeight,
19+
progress: mockProgress,
20+
}),
21+
}));
22+
23+
describe('useKeyboardLift', () => {
24+
beforeEach(() => {
25+
mockHeight.value = 0;
26+
mockProgress.value = 0;
27+
mockInsetsBottom = 0;
28+
});
29+
30+
it('returns 0 when the keyboard is closed', () => {
31+
const { result } = renderHook(() => useKeyboardLift());
32+
33+
expect(result.current.value).toBe(0);
34+
});
35+
36+
it('gives back the bottom inset the open keyboard swallows', () => {
37+
mockInsetsBottom = 34;
38+
mockHeight.value = -346;
39+
mockProgress.value = 1;
40+
41+
const { result } = renderHook(() => useKeyboardLift());
42+
43+
expect(result.current.value).toBe(-312);
44+
});
45+
46+
it('scales the inset compensation with keyboard progress', () => {
47+
mockInsetsBottom = 34;
48+
mockHeight.value = -173;
49+
mockProgress.value = 0.5;
50+
51+
const { result } = renderHook(() => useKeyboardLift());
52+
53+
expect(result.current.value).toBe(-156);
54+
});
55+
56+
it('equals the raw keyboard height on a device without a bottom inset', () => {
57+
mockHeight.value = -300;
58+
mockProgress.value = 1;
59+
60+
const { result } = renderHook(() => useKeyboardLift());
61+
62+
expect(result.current.value).toBe(-300);
63+
});
64+
65+
it('recomputes after the keyboard values change', () => {
66+
mockInsetsBottom = 34;
67+
const { result, rerender } = renderHook(() => useKeyboardLift());
68+
69+
expect(result.current.value).toBe(0);
70+
71+
mockHeight.value = -346;
72+
mockProgress.value = 1;
73+
rerender({});
74+
75+
expect(result.current.value).toBe(-312);
76+
});
77+
});

app/(drawer)/_layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ const DrawerLayout = () => {
5555
title: 'Benchmark',
5656
}}
5757
/>
58-
<Drawer.Screen name="chat/[id]" />
58+
<Drawer.Screen
59+
name="chat/[id]"
60+
options={{
61+
headerTransparent: true,
62+
headerStyle: { backgroundColor: 'transparent' },
63+
}}
64+
/>
5965
</Drawer>
6066
);
6167
};

app/(drawer)/chat/[id].tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,13 @@ function ChatScreenInner() {
6363
const isEmpty = !isLoading && activeChatMessages.length === 0;
6464
const shouldExitOnBack = isPhantom && isEmpty;
6565
const openModelSheetRef = useRef<(() => void) | null>(null);
66+
const openModelSheet = useCallback(() => openModelSheetRef.current?.(), []);
6667

67-
const { MenuElements } = useChatHeader({
68+
const { MenuElements, titleBottom } = useChatHeader({
6869
chatId: chatId,
6970
chatModel: model,
7071
isEmpty,
71-
onSelectModelFromTitle: isPhantom
72-
? () => openModelSheetRef.current?.()
73-
: undefined,
72+
onSelectModelFromTitle: isPhantom ? openModelSheet : undefined,
7473
});
7574

7675
useFocusEffect(
@@ -150,6 +149,7 @@ function ChatScreenInner() {
150149
selectModel={handleSetModel}
151150
openModelSheetRef={openModelSheetRef}
152151
revealFromTop={shouldPlayBranchEntryAnimation}
152+
headerTitleBottom={titleBottom}
153153
/>
154154
{MenuElements}
155155
</>

babel.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,12 @@ module.exports = function (api) {
1818
},
1919
],
2020
],
21+
env: {
22+
// Strip every console.* call (error included — there is no crash
23+
// reporting to feed) from release bundles; dev and test keep them.
24+
production: {
25+
plugins: ['transform-remove-console'],
26+
},
27+
},
2128
};
2229
};

components/chat-screen/ChatBar.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ const ChatBar = ({
108108

109109
const defaultBarHeight = useRef(0);
110110
const prevBarHeight = useRef(0);
111+
112+
// Inset the baseline was captured with. Checked in the layout handler, not
113+
// an effect: onLayout fires first, so an effect-driven reset would lose the
114+
// pass carrying the new height.
115+
const baselineInset = useRef<number | null>(null);
111116
const textInputRef = useRef<RNTextInput>(null);
112117
// iOS-only: bump the TextInput key to force a remount when a prompt
113118
// suggestion is set programmatically. iOS doesn't re-fire onLayout
@@ -132,12 +137,19 @@ const ChatBar = ({
132137
const handleBarLayoutForPadding = useCallback(
133138
(e: { nativeEvent: { layout: { height: number } } }) => {
134139
const height = e.nativeEvent.layout.height;
140+
const inset = theme.insets.bottom;
135141
// Only capture the default height once we're in the "with messages"
136142
// layout — otherwise the empty-state extras (WhatsNewCard, prompt
137143
// suggestions) would bake into the baseline and squeeze the scroll
138-
// view once they disappear.
139-
if (defaultBarHeight.current === 0 && hasMessages) {
144+
// view once they disappear. Re-capture on inset changes (Android
145+
// navigation mode, rotation), or the stale baseline reads the difference
146+
// as "the bar grew".
147+
if (
148+
hasMessages &&
149+
(defaultBarHeight.current === 0 || baselineInset.current !== inset)
150+
) {
140151
defaultBarHeight.current = height;
152+
baselineInset.current = inset;
141153
}
142154
const baseline = defaultBarHeight.current || height;
143155
const delta = height - baseline;
@@ -147,14 +159,22 @@ const ChatBar = ({
147159
easing: BAR_GROW_EASING,
148160
})
149161
);
162+
// Baseline, not live height — consumers must not follow the bar as it
163+
// grows with typed lines; that is what extraContentPadding is for.
150164
onHeightChange?.(hasMessages ? baseline : 0);
151165
const grew = height > prevBarHeight.current;
152166
prevBarHeight.current = height;
153167
if (delta > 0 && grew) {
154168
onBarGrow?.();
155169
}
156170
},
157-
[extraContentPadding, onBarGrow, onHeightChange, hasMessages]
171+
[
172+
extraContentPadding,
173+
onBarGrow,
174+
onHeightChange,
175+
hasMessages,
176+
theme.insets.bottom,
177+
]
158178
);
159179

160180
const {

0 commit comments

Comments
 (0)