Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
bda61c7
fix: prevent first PDF send foreign key race
kfaracik Jun 30, 2026
1734d68
feat(rag): download embedding model on demand instead of bundling
kfaracik Jul 6, 2026
1f69c2f
feat(rag): hybrid retrieval with keyword (FTS5/BM25) + vector fusion
kfaracik Jul 6, 2026
244df35
feat(rag): persist per-message citations and migrate legacy sources
kfaracik Jul 6, 2026
f4cdb92
feat(rag): wire retrieval into send and persist cited sources
kfaracik Jul 6, 2026
822bf9b
feat(attachments): require embeddings for docs and normalize file text
kfaracik Jul 7, 2026
a83b97f
feat(rag): expand retrieved chunks with neighboring context
kfaracik Jul 7, 2026
697291d
feat(citations): show cited sources with highlighted passages
kfaracik Jul 7, 2026
bc142b8
chore(deps): refresh locks and tidy retrieval test setup
kfaracik Jul 7, 2026
a21ea23
feat(rag): tighten final chunk selection
kfaracik Jul 8, 2026
3219505
feat(rag): dedup overlap when stitching passages
kfaracik Jul 8, 2026
ee71db0
fix(rag): pack prompt to budget without dropping the answer
kfaracik Jul 8, 2026
0927606
fix(chat): show legacy-document notice as a reactive toast
kfaracik Jul 8, 2026
d956ad3
perf(chat): render the sent message instantly, defer retrieval
kfaracik Jul 9, 2026
27374b4
feat(citations): attribute replies to the sources they actually used
kfaracik Jul 9, 2026
a7f307b
feat(attachments): show import progress and flag scanned PDFs
kfaracik Jul 9, 2026
e3115b8
feat(citations): add citation constants and refine source attribution
kfaracik Jul 9, 2026
35ed96e
refactor(sources-sheet): extract subcomponents and hoist constants
kfaracik Jul 13, 2026
de344c6
feat(citations): widen no-answer detection for PL/EN refusals
kfaracik Jul 13, 2026
a11468c
fix(attachments): abort embedding on cancel and cap oversized document
kfaracik Jul 14, 2026
eeb6e36
refactor(rag): add HybridRetriever wrapper as app↔lib boundary
kfaracik Jul 16, 2026
16f41c7
fix(rag): make vector store teardown idempotent and abort init on unm…
kfaracik Jul 16, 2026
08f66da
style: format embedding model status union for prettier 3.9.4
kfaracik Jul 16, 2026
53106dd
test(llm): capture token callback from the correct fromModelName arg
kfaracik Jul 16, 2026
6b142ec
test(db): cover runMigrations from a real pre-RAG schema
kfaracik Jul 16, 2026
61d940d
test(embeddings): cover ensureReady error, retry and single-flight paths
kfaracik Jul 16, 2026
661e289
test(rag): add buildMessageSources pipeline integration test
kfaracik Jul 16, 2026
cb47694
refactor(rag): reduce retrieval to a vanilla vector baseline
kfaracik Jul 16, 2026
f0e39ae
fix(rag): recalibrate semantic gate and harden source ingestion
kfaracik Jul 16, 2026
aeff49a
Merge remote-tracking branch 'origin/main' into feat/221-rag-sources
kfaracik Jul 20, 2026
4119c3b
refactor(chat): animate AttachmentThumbnail progress with Reanimated
kfaracik Jul 20, 2026
e21ab45
Merge branch 'main' into feat/221-rag-sources
kfaracik Jul 20, 2026
811e338
fix(rag): wipe dimension-incompatible legacy vectors when adopting a …
kfaracik Jul 21, 2026
26eefa8
perf(chat): host a single SourcesSheet at the screen level
kfaracik Jul 21, 2026
243e735
Merge branch 'main' into feat/221-rag-sources
kfaracik Jul 21, 2026
92e3c98
fix(context): give shipped model families a real context window
kfaracik Jul 21, 2026
a383da7
fix(rag): keep the embedding-model key unset when the wipe is partial
kfaracik Jul 21, 2026
9519d4d
fix(citations): stop citing sources for facts the answer negates
kfaracik Jul 21, 2026
ff23ebc
fix(rag): emit expanded chunks in document order
kfaracik Jul 21, 2026
de42e2d
fix(attachments): sweep sources abandoned without sending
kfaracik Jul 21, 2026
c113556
style: restore the prettier 3.9.4 formatting of the status union
kfaracik Jul 21, 2026
4a43216
fix(citations): strip every think block, not just the first
kfaracik Jul 21, 2026
2a9ae99
chore: drop dead estimateTokens and the unused ChatBar clear handle
kfaracik Jul 21, 2026
ba68d59
style: drop the explanatory comments added during review
kfaracik Jul 21, 2026
5823d8f
Merge branch 'main' into feat/221-rag-sources
kfaracik Jul 22, 2026
dadfaf5
test(llmStore): deterministic clock in the benchmark first-token test
kfaracik Jul 23, 2026
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
59 changes: 49 additions & 10 deletions __mocks__/@gorhom/bottom-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,53 @@
import React from 'react';
import { View, FlatList } from 'react-native';
import React, { forwardRef, type PropsWithChildren } from 'react';
import {
View,
FlatList,
type FlatListProps,
type ScrollViewProps,
type ViewProps,
} from 'react-native';

export const BottomSheetModal = React.forwardRef(
({ children }: any, _ref: any) => <>{children}</>
);
export const BottomSheetView = ({ children, style }: any) => (
export interface BottomSheetModalRef {
present: () => void;
dismiss: () => void;
}

export type SheetAnimationConfig = Record<string, number | boolean>;

export const BottomSheetModal = forwardRef<
BottomSheetModalRef,
PropsWithChildren
>(({ children }, _ref) => <>{children}</>);
BottomSheetModal.displayName = 'BottomSheetModal';

export const BottomSheetView = ({ children, style }: ViewProps) => (
<View style={style}>{children}</View>
);
export const BottomSheetFlatList = (props: any) => <FlatList {...props} />;

export const BottomSheetScrollView = ({
children,
contentContainerStyle,
testID,
}: ScrollViewProps) => (
<View style={contentContainerStyle} testID={testID}>
{children}
</View>
);

export const BottomSheetFlatList = <ItemT,>(props: FlatListProps<ItemT>) => (
<FlatList {...props} />
);

export const BottomSheetBackdrop = () => null;
export const BottomSheetModalProvider = ({ children }: any) => <>{children}</>;
export const useBottomSheetTimingConfigs = (_config: any) => ({});
export const useBottomSheetSpringConfigs = (_config: any) => ({});

export const BottomSheetModalProvider = ({ children }: PropsWithChildren) => (
<>{children}</>
);

export const useBottomSheet = () => ({ close: () => {} });

export const useBottomSheetTimingConfigs = (config: SheetAnimationConfig) =>
config;

export const useBottomSheetSpringConfigs = (config: SheetAnimationConfig) =>
config;
2 changes: 2 additions & 0 deletions __mocks__/react-native-executorch-expo-resource-fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const ExpoResourceFetcher = {
cancelFetching: jest.fn(),
deleteResources: jest.fn(),
listDownloadedFiles: jest.fn(async () => [] as string[]),
getFilesTotalSize: jest.fn(async () => 0),
};
1 change: 1 addition & 0 deletions __mocks__/react-native-reanimated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ module.exports = {
inOut: (fn: any) => fn,
out: (fn: any) => fn,
in: (fn: any) => fn,
bezier: () => (t: any) => t,
},
interpolate: (val: any, inputRange: any, outputRange: any) => {
if (val <= inputRange[0]) return outputRange[0];
Expand Down
127 changes: 88 additions & 39 deletions __tests__/ChatBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react-native';
import type { LLMStore } from '../store/llmStore';
import type { Attachment } from '../hooks/useAttachment';
import type { PermissionStatus } from 'react-native-audio-api';

// ── mocks ─────────────────────────────────────────────────────────────────────

Expand All @@ -13,17 +16,20 @@ jest.mock('../context/ThemeContext', () => ({
}));

jest.mock('../store/llmStore', () => ({
useLLMStore: jest.fn(() => ({
isGenerating: false,
isProcessingPrompt: false,
interrupt: jest.fn(),
loadModel: jest.fn(),
model: null,
})),
useLLMStore: jest.fn((selector?: (state: Partial<LLMStore>) => unknown) => {
const state = {
isGenerating: false,
isProcessingPrompt: false,
interrupt: jest.fn(),
loadModel: jest.fn(),
model: null,
};
return selector ? selector(state) : state;
}),
}));

const mockUseAttachment = {
attachments: [] as any[],
attachments: [] as Attachment[],
sheetRef: { current: null },
pickFromLibrary: jest.fn(),
pickFromCamera: jest.fn(),
Expand All @@ -45,7 +51,12 @@ jest.mock('../components/bottomSheets/AttachmentSheet', () => {
onPickFromCamera,
onPickDocument,
isVisionModel,
}: any) => (
}: {
onPickFromLibrary: () => void;
onPickFromCamera: () => void;
onPickDocument: () => void;
isVisionModel: boolean;
}) => (
<View testID="attachment-sheet">
<Text>{`vision:${isVisionModel}`}</Text>
<TouchableOpacity testID="pick-library-btn" onPress={onPickFromLibrary}>
Expand All @@ -63,7 +74,13 @@ jest.mock('../components/bottomSheets/AttachmentSheet', () => {

jest.mock('../components/chat-screen/AttachmentThumbnail', () => {
const { View, TouchableOpacity, Text } = require('react-native');
return ({ attachment, onRemove }: any) => (
return ({
attachment,
onRemove,
}: {
attachment: Attachment;
onRemove: () => void;
}) => (
<View testID={`attachment-thumb-${attachment.id}`}>
<Text>{attachment.name || attachment.uri}</Text>
<TouchableOpacity
Expand All @@ -77,8 +94,14 @@ jest.mock('../components/chat-screen/AttachmentThumbnail', () => {
});

jest.mock('../components/chat-screen/ChatSpeechInput', () => {
const { View, TouchableOpacity, Text } = require('react-native');
return ({ onSubmit, onCancel }: any) => (
const { View, TouchableOpacity } = require('react-native');
return ({
onSubmit,
onCancel,
}: {
onSubmit: (transcript: string) => void;
onCancel: () => void;
}) => (
<View testID="speech-input">
<TouchableOpacity
testID="speech-submit"
Expand All @@ -91,7 +114,7 @@ jest.mock('../components/chat-screen/ChatSpeechInput', () => {

jest.mock('../components/chat-screen/PromptSuggestions', () => {
const { TouchableOpacity, Text } = require('react-native');
return ({ onSelectPrompt }: any) => (
return ({ onSelectPrompt }: { onSelectPrompt: (prompt: string) => void }) => (
<TouchableOpacity
testID="prompt-suggestion"
onPress={() => onSelectPrompt('Suggested prompt')}
Expand All @@ -114,7 +137,18 @@ jest.mock('../components/chat-screen/ChatBarActions', () => {
onThinkingToggle,
thinkingEnabled,
onAttach,
}: any) => (
}: {
userInput: string;
hasAttachments: boolean;
onSend: () => void;
isGenerating: boolean;
isProcessingPrompt: boolean;
onInterrupt: () => void;
onSpeechInput: () => void;
onThinkingToggle: () => void;
thinkingEnabled: boolean;
onAttach: () => void;
}) => (
<View testID="chat-bar-actions">
<TouchableOpacity testID="attach-btn" onPress={onAttach}>
<Text>+</Text>
Expand Down Expand Up @@ -175,7 +209,7 @@ const defaultProps = {
onSelectModel: jest.fn(),
onSelectPrompt: jest.fn(),
model: downloadedModel,
scrollRef: { current: null } as any,
scrollRef: { current: null },
isAtBottom: true,
isVisionModel: false,
thinkingEnabled: false,
Expand All @@ -187,13 +221,18 @@ const renderBar = (props: Partial<typeof defaultProps> = {}) =>
render(<ChatBar {...defaultProps} {...props} />);

beforeEach(() => {
mockUseLLMStore.mockReturnValue({
isGenerating: false,
isProcessingPrompt: false,
interrupt: jest.fn(),
loadModel: jest.fn(),
model: null,
});
mockUseLLMStore.mockImplementation(
(selector?: (state: Partial<LLMStore>) => unknown) => {
const state = {
isGenerating: false,
isProcessingPrompt: false,
interrupt: jest.fn(),
loadModel: jest.fn(),
model: null,
};
return selector ? selector(state) : state;
}
);
mockUseAttachment.attachments = [];
mockUseAttachment.openSheet.mockClear();
mockUseAttachment.clearAll.mockClear();
Expand All @@ -203,7 +242,7 @@ beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
// Default: permission granted
mockAudioManager.requestRecordingPermissions.mockResolvedValue(
'Granted' as any
'Granted' as PermissionStatus
);
});

Expand Down Expand Up @@ -293,26 +332,36 @@ describe('downloaded model — text input', () => {

describe('generating state', () => {
it('shows interrupt button when isGenerating', () => {
mockUseLLMStore.mockReturnValue({
isGenerating: true,
isProcessingPrompt: false,
interrupt: jest.fn(),
loadModel: jest.fn(),
model: null,
});
mockUseLLMStore.mockImplementation(
(selector?: (state: Partial<LLMStore>) => unknown) => {
const state = {
isGenerating: true,
isProcessingPrompt: false,
interrupt: jest.fn(),
loadModel: jest.fn(),
model: null,
};
return selector ? selector(state) : state;
}
);
renderBar();
expect(screen.getByTestId('interrupt-btn')).toBeTruthy();
});

it('calls interrupt when interrupt button is pressed', () => {
const interrupt = jest.fn();
mockUseLLMStore.mockReturnValue({
isGenerating: true,
isProcessingPrompt: false,
interrupt,
loadModel: jest.fn(),
model: null,
});
mockUseLLMStore.mockImplementation(
(selector?: (state: Partial<LLMStore>) => unknown) => {
const state = {
isGenerating: true,
isProcessingPrompt: false,
interrupt,
loadModel: jest.fn(),
model: null,
};
return selector ? selector(state) : state;
}
);
renderBar();
fireEvent.press(screen.getByTestId('interrupt-btn'));
expect(interrupt).toHaveBeenCalled();
Expand All @@ -332,7 +381,7 @@ describe('speech input', () => {

it('shows toast and stays in text mode when microphone permission is denied', async () => {
mockAudioManager.requestRecordingPermissions.mockResolvedValue(
'Denied' as any
'Denied' as PermissionStatus
);
renderBar();
await act(async () => {
Expand Down Expand Up @@ -405,7 +454,7 @@ describe('speech input', () => {
// Override speech mock to submit empty string
jest.mock('../components/chat-screen/ChatSpeechInput', () => {
const { View, TouchableOpacity } = require('react-native');
return ({ onSubmit }: any) => (
return ({ onSubmit }: { onSubmit: (transcript: string) => void }) => (
<View testID="speech-input">
<TouchableOpacity
testID="speech-submit"
Expand Down
Loading
Loading