Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
fcd97ee
chore(deps): add react-native-webview and expo-web-browser
kfaracik Jul 22, 2026
659a530
feat(sources): carry web provenance on SourceDocument
kfaracik Jul 22, 2026
eecb4b7
feat(prompt): describe the context block by what actually fed it
kfaracik Jul 22, 2026
d042077
feat(prompt): allow clearly-marked general knowledge after a groundin…
kfaracik Jul 22, 2026
3493e8c
fix(prompt): harden prompt assembly against malformed and oversized i…
kfaracik Jul 22, 2026
fe5bbe4
feat(web): add the web-search core — types, tunables, context converg…
kfaracik Jul 22, 2026
ba326e9
feat(web): chat with a URL — index a pasted page like a document
kfaracik Jul 22, 2026
09a4fc7
feat(web): scrape search results from an on-device WebView
kfaracik Jul 22, 2026
0d96a54
feat(web): plan and reformulate search queries with the on-device model
kfaracik Jul 22, 2026
3a8c0f7
fix(llm): size the prompt budget by script and retry failed generations
kfaracik Jul 23, 2026
ff35930
feat(web): enrich top results with article text, filtering bot-walls
kfaracik Jul 23, 2026
16398db
feat(web): score retrieval quality from coverage, similarity, agreement
kfaracik Jul 23, 2026
39fa8c1
feat(web): retrieve relevant passages from fetched pages in memory
kfaracik Jul 23, 2026
21a9fe9
feat(web): orchestrate search rounds with adaptive enrich and correction
kfaracik Jul 23, 2026
236c41a
feat(web): hold web-search state per chat in a persisted zustand store
kfaracik Jul 23, 2026
6ac79e3
feat(web): mount the offscreen WebView scrape host
kfaracik Jul 23, 2026
42b476b
feat(web): add a Web toggle to the chat bar
kfaracik Jul 24, 2026
8543f15
fix(chat): keep the input empty when the field echoes a sent message
kfaracik Jul 24, 2026
7aba73d
feat(web): derive message sources and web-search activity
kfaracik Jul 24, 2026
cfe3d62
feat(web): assemble the inline web-search block
kfaracik Jul 24, 2026
b0c60ac
refactor(web): drop the unused agreement summary
kfaracik Jul 24, 2026
a73fc3b
fix(chat): carry web sources into a forked chat
kfaracik Jul 24, 2026
e056fd8
feat(web): run web search on send and feed it to the model
kfaracik Jul 24, 2026
5a070d2
fix(web): validate and sanitize scraped SERP/article data at the RN b…
kfaracik Jul 27, 2026
a1fb41e
fix(web): confine the scrape host to the engine domains and stabilise…
kfaracik Jul 27, 2026
ade0fc7
fix(prompt): respect the char budget when truncating oversized context
kfaracik Jul 27, 2026
3170e37
fix(web): number web sources after the doc blocks and neutralize thei…
kfaracik Jul 27, 2026
6807b85
fix(web): attribute a web source only when it survived into the prompt
kfaracik Jul 27, 2026
adb3066
fix(web): isolate the per-chat Web toggle and validate persisted state
kfaracik Jul 27, 2026
ef6801d
fix(web): harden the favicon, trace-collapse callback, and activity g…
kfaracik Jul 27, 2026
c3184b8
refactor(web): harden the scrape/fetch boundary and group security code
kfaracik Jul 27, 2026
afa13ae
test(web): unit-test the security modules directly
kfaracik Jul 27, 2026
4b2dc23
refactor(web): make page fetching injectable for offline runs
kfaracik Jul 28, 2026
3ae2b4e
fix(web): stop poisoning non-Qwen planners with /no_think and slim th…
kfaracik Jul 28, 2026
cbc670f
fix(web): make generateUtility single-flight
kfaracik Jul 28, 2026
482bae8
fix(web): run the scrape WebView in incognito mode
kfaracik Jul 29, 2026
47d6093
fix(web): actually cancel in-flight work when a search is aborted
kfaracik Jul 29, 2026
5af3031
test: reconcile budget and retry expectations after the main merge
kfaracik Aug 3, 2026
07ad001
fix(icons): tighten book-open viewBox to the drawn artwork
kfaracik Aug 4, 2026
83e455b
refactor(chat): move Messages layout constants into constants/chat-sc…
kfaracik Aug 4, 2026
0642265
fix(chat): stop message rows remounting when the optimistic id is per…
kfaracik Aug 4, 2026
6135578
feat(models): add per-model profiles for web search & generation tuning
kfaracik Aug 5, 2026
8457d18
feat(web): multilingual query-term & language detection
kfaracik Aug 5, 2026
3b215d7
feat(web): retrieval & ranking primitives for the search pipeline
kfaracik Aug 6, 2026
335b360
feat(web): page fetch, content extraction & caching
kfaracik Aug 6, 2026
128c578
feat(web): query planning, reformulation & context assembly
kfaracik Aug 6, 2026
ffd3592
feat(web): per-language stopwords & script segmentation
kfaracik Aug 6, 2026
1bf873d
feat(db): bigram keyword index for CJK/Thai & read-state on sources
kfaracik Aug 6, 2026
675fcae
feat(db): bigram keyword index for CJK/Thai & read-state on sources
kfaracik Aug 6, 2026
e9d3a3d
refactor(benchmark): type bottom-sheet payloads, drop any
kfaracik Aug 7, 2026
a62725d
feat(chat): web search trace & sources UI
kfaracik Aug 7, 2026
b6fce83
fix(chat): keyboard-lift hide guard, source dedup & citation language
kfaracik Aug 7, 2026
4c3c337
fix(chat): keyboard-lift hide guard, source dedup & citation language
kfaracik Aug 7, 2026
ad1d5e0
chore(types): route mock casts through unknown, type model sheet
kfaracik Aug 7, 2026
1bdb5a6
chore(tooling): add CI typecheck; scrape skips blocked engine
kfaracik Aug 7, 2026
5572480
feat(web): per-model context budget
kfaracik Aug 7, 2026
7270d02
test(chat): cover WebFavicon retry & fallback
kfaracik Aug 7, 2026
b9f61a1
fix(web): CR pass — make the branch compile, harden SSRF, fix adaptiv…
kfaracik Aug 9, 2026
6d21779
feat(web): multilingual grounding, memory gating and pinned chat scroll
kfaracik Aug 11, 2026
49e74df
Merge branch 'main' into feat/web-search
kfaracik Aug 11, 2026
cee2af1
fix(web): close the SSRF host bypass, stop the silent embedder download
kfaracik Aug 11, 2026
8a84597
style(chat): route the web-search components through useThemedStyles
kfaracik Aug 11, 2026
0b939f9
feat(web): one search round, five results
kfaracik Aug 11, 2026
f23d7c8
style(test): satisfy prettier in useMessageSources.test
kfaracik Aug 11, 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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,8 @@ jobs:
- name: Check formatting
run: yarn format:check

- name: Typecheck
run: yarn typecheck

- name: Run tests
run: yarn test --forceExit --ci
16 changes: 13 additions & 3 deletions __mocks__/react-native-reanimated.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Manual mock that avoids importing the real module (which needs worklets)
const RN = require('react-native');
const React = require('react');

const createAnimatedComponent = (Component: any) => Component;

Expand Down Expand Up @@ -50,10 +50,18 @@ module.exports = {
default: Animated,
...Animated,
createAnimatedComponent,
useSharedValue: <T>(init: T) => makeSharedValue(init),
useSharedValue: <T>(init: T) => {
const ref: { current: MockSharedValue<T> | null } = React.useRef(null);
if (ref.current === null) ref.current = makeSharedValue(init);
return ref.current;
},
useAnimatedStyle: (fn: () => any) => fn(),
useAnimatedRef: () => ({ current: null }),
useDerivedValue: <T>(fn: () => T) => makeSharedValue(fn()),
useAnimatedReaction: <T>(
prepare: () => T,
react: (current: T, previous: T | null) => void
) => react(prepare(), null),
useAnimatedScrollHandler: (fn: any) => fn,
withTiming: (
val: any,
Expand All @@ -76,7 +84,6 @@ 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 All @@ -91,5 +98,8 @@ module.exports = {
LinearTransition: makeAnimationBuilder(),
FadeIn: makeAnimationBuilder(),
FadeInDown: makeAnimationBuilder(),
FadeInUp: makeAnimationBuilder(),
FadeOut: makeAnimationBuilder(),
FadeOutDown: makeAnimationBuilder(),
FadeOutUp: makeAnimationBuilder(),
};
42 changes: 41 additions & 1 deletion __tests__/ChatBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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';
import type { SharedValue } from 'react-native-reanimated';

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

Expand Down Expand Up @@ -193,7 +194,7 @@ import { useLLMStore } from '../store/llmStore';
import { AudioManager } from 'react-native-audio-api';
import Toast from 'react-native-toast-message';

const mockUseLLMStore = useLLMStore as jest.Mock;
const mockUseLLMStore = useLLMStore as unknown as jest.Mock;
const mockAudioManager = AudioManager as jest.Mocked<typeof AudioManager>;

const downloadedModel = {
Expand All @@ -211,6 +212,8 @@ const downloadedModel = {

const defaultProps = {
chatId: 1,
ref: { current: null },
extraContentPadding: { value: 0 } as unknown as SharedValue<number>,
onSend: jest.fn(),
onSelectModel: jest.fn(),
onSelectPrompt: jest.fn(),
Expand Down Expand Up @@ -307,6 +310,43 @@ describe('downloaded model — text input', () => {
expect(onSend).toHaveBeenCalledWith('Hello', undefined, []);
});

it('stays empty when the native input echoes the sent text back', () => {
renderBar();
const textInput = () =>
screen.getByPlaceholderText('Ask about anything...');
fireEvent.changeText(textInput(), 'czesc test wysylki');
fireEvent.press(screen.getByTestId('send-btn'));
expect(textInput().props.value).toBe('');

fireEvent.changeText(textInput(), 'czesc test wysylki');
expect(textInput().props.value).toBe('');
});

it('accepts genuine typing right after a send', () => {
renderBar();
const textInput = () =>
screen.getByPlaceholderText('Ask about anything...');
fireEvent.changeText(textInput(), 'first');
fireEvent.press(screen.getByTestId('send-btn'));

fireEvent.changeText(textInput(), 'n');
expect(textInput().props.value).toBe('n');
fireEvent.changeText(textInput(), 'next');
expect(textInput().props.value).toBe('next');
});

it('does not swallow a repeat of the same message typed again', () => {
renderBar();
const textInput = () =>
screen.getByPlaceholderText('Ask about anything...');
fireEvent.changeText(textInput(), 'again');
fireEvent.press(screen.getByTestId('send-btn'));

fireEvent.changeText(textInput(), 'a');
fireEvent.changeText(textInput(), 'again');
expect(textInput().props.value).toBe('again');
});

it('shows prompt suggestions when hasMessages is false', () => {
renderBar({ hasMessages: false });
expect(screen.getByTestId('prompt-suggestion')).toBeTruthy();
Expand Down
86 changes: 85 additions & 1 deletion __tests__/MessageItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@ import { fireEvent, render, screen } from '@testing-library/react-native';
type MockLLMState = {
isGenerating: boolean;
isProcessingPrompt: boolean;
isSearchingWeb?: boolean;
webSearchTrace?: unknown[];
};

type MockLLMSelector<T = MockLLMState> = (state: MockLLMState) => T;

type MockWebSearchState = {
isSearchingWeb: boolean;
webSearchTrace: unknown[];
};

type MockWebSearchSelector<T = MockWebSearchState> = (
state: MockWebSearchState
) => T;

type ThinkingBlockMockProps = {
content: string;
isComplete: boolean;
Expand Down Expand Up @@ -40,6 +51,16 @@ jest.mock('../store/llmStore', () => ({
}),
}));

jest.mock('../store/webSearchStore', () => ({
useWebSearchStore: jest.fn(<T,>(selector?: MockWebSearchSelector<T>) => {
const state = {
isSearchingWeb: false,
webSearchTrace: [],
};
return selector ? selector(state) : state;
}),
}));

jest.mock('../components/chat-screen/MarkdownComponent', () => {
const { Text } = require('react-native');
return ({ text }: { text: string }) => <Text testID="markdown">{text}</Text>;
Expand All @@ -59,6 +80,18 @@ jest.mock('../components/chat-screen/ThinkingBlock', () => {

jest.mock('../components/chat-screen/AnimatedChatLoading', () => () => null);

jest.mock('../components/chat-screen/WebSearchBlock', () => {
const { Text } = require('react-native');
return ({ isSearching }: { isSearching?: boolean }) => (
<Text
testID="web-search-block"
accessibilityLabel={`searching:${!!isSearching}`}
>
{''}
</Text>
);
});

jest.mock('@gorhom/bottom-sheet', () => {
const MockReact = require('react') as typeof import('react');
const { View } = require('react-native');
Expand Down Expand Up @@ -88,13 +121,24 @@ jest.mock('@gorhom/bottom-sheet', () => {

import MessageItem from '../components/chat-screen/MessageItem';
import { useLLMStore } from '../store/llmStore';
import { useWebSearchStore } from '../store/webSearchStore';

const mockUseLLMStore = useLLMStore as unknown as jest.Mock;
const mockUseWebSearchStore = useWebSearchStore as unknown as jest.Mock;

const setLLMState = (state: MockLLMState) =>
const setLLMState = (state: MockLLMState) => {
mockUseLLMStore.mockImplementation((selector?: MockLLMSelector) =>
selector ? selector(state) : state
);
const webState: MockWebSearchState = {
isSearchingWeb: state.isSearchingWeb ?? false,
webSearchTrace: state.webSearchTrace ?? [],
};
mockUseWebSearchStore.mockImplementation(
(selector?: MockWebSearchSelector) =>
selector ? selector(webState) : webState
);
};

const baseMessage = {
id: 1,
Expand Down Expand Up @@ -396,3 +440,43 @@ describe('thinking block parsing', () => {
expect(screen.queryByTestId('thinking-block')).toBeNull();
});
});

describe('live web-search trace', () => {
it('shows the web trace in the searching state while searching the web', () => {
setLLMState({
isGenerating: false,
isProcessingPrompt: true,
isSearchingWeb: true,
webSearchTrace: [],
});
renderItem({ content: '', isLastMessage: true });

const block = screen.getByTestId('web-search-block');
expect(block).toBeTruthy();
expect(block.props.accessibilityLabel).toBe('searching:true');
});

it('is not in the searching state once tokens have started streaming', () => {
setLLMState({
isGenerating: true,
isProcessingPrompt: false,
isSearchingWeb: true,
webSearchTrace: [],
});
renderItem({ content: 'Dzisiaj jest słonecznie', isLastMessage: true });

expect(screen.queryByTestId('web-search-block')).toBeNull();
});

it('does not show the searching trace on a non-last message', () => {
setLLMState({
isGenerating: false,
isProcessingPrompt: true,
isSearchingWeb: true,
webSearchTrace: [],
});
renderItem({ content: '', isLastMessage: false });

expect(screen.queryByTestId('web-search-block')).toBeNull();
});
});
2 changes: 1 addition & 1 deletion __tests__/ModelCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@

jest.mock('../components/Chip', () => {
const { Text } = require('react-native');
return ({ title }: any) => <Text testID={`chip-${title}`}>{title}</Text>;

Check warning on line 51 in __tests__/ModelCard.test.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
});

jest.mock('../components/CircleButton', () => {
const { TouchableOpacity } = require('react-native');
return ({ onPress, disabled, testID }: any) => (

Check warning on line 56 in __tests__/ModelCard.test.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
<TouchableOpacity
testID={testID || 'circle-btn'}
onPress={onPress}
Expand All @@ -66,7 +66,7 @@
import { useModelStore, ModelState } from '../store/modelStore';
import { isModelCompatible } from '../utils/modelCompatibility';

const mockUseModelStore = useModelStore as jest.Mock;
const mockUseModelStore = useModelStore as unknown as jest.Mock;
const mockIsModelCompatible = isModelCompatible as jest.Mock;
const mockNetInfoFetch = NetInfo.fetch as jest.Mock;

Expand Down Expand Up @@ -104,8 +104,8 @@
typeof baseModel & {
compactView?: boolean;
selected?: boolean;
onPress?: any;

Check warning on line 107 in __tests__/ModelCard.test.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
wifiWarningSheetRef?: any;

Check warning on line 108 in __tests__/ModelCard.test.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
}
> = {}
) => {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/ModelManagementSheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@
// ModelCard — stub to show model name
jest.mock('../components/model-hub/ModelCard', () => {
const { Text } = require('react-native');
return ({ model }: any) => <Text testID="model-card">{model.modelName}</Text>;

Check warning on line 32 in __tests__/ModelManagementSheet.test.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
});

// BottomSheetModal — immediately render children with injected data
jest.mock('@gorhom/bottom-sheet', () => {
const React = require('react');

Check warning on line 37 in __tests__/ModelManagementSheet.test.tsx

View workflow job for this annotation

GitHub Actions / test

'React' is already declared in the upper scope on line 1 column 8
const { View } = require('react-native');
let _injectedData: any = null;

Check warning on line 39 in __tests__/ModelManagementSheet.test.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type

const BottomSheetModal = React.forwardRef((props: any, _ref: any) => {
if (!props.children || !_injectedData) return null;
Expand All @@ -63,7 +63,7 @@
import { BottomSheetModal } from '@gorhom/bottom-sheet';
import Toast from 'react-native-toast-message';

const mockUseModelStore = useModelStore as jest.Mock;
const mockUseModelStore = useModelStore as unknown as jest.Mock;
const setSheetData = (BottomSheetModal as any).__setData;

const baseModel = {
Expand Down
8 changes: 1 addition & 7 deletions __tests__/ModelSelectSheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import ModelSelectSheet from '../components/bottomSheets/ModelSelectSheet';
import { useModelStore } from '../store/modelStore';
import { router } from 'expo-router';

const mockUseModelStore = useModelStore as jest.Mock;
const mockUseModelStore = useModelStore as unknown as jest.Mock;

const makeModel = (id: number, name: string) => ({
id,
Expand Down Expand Up @@ -108,8 +108,6 @@ const renderSheet = (props = {}) =>
/>
);

// ─── empty state ──────────────────────────────────────────────────────────────

describe('empty state', () => {
it('shows empty state message when no models downloaded', () => {
renderSheet();
Expand All @@ -128,8 +126,6 @@ describe('empty state', () => {
});
});

// ─── with models ──────────────────────────────────────────────────────────────

describe('with downloaded models', () => {
beforeEach(() => {
mockUseModelStore.mockReturnValue({
Expand Down Expand Up @@ -158,8 +154,6 @@ describe('with downloaded models', () => {
});
});

// ─── search filtering ─────────────────────────────────────────────────────────

describe('search filtering', () => {
beforeEach(() => {
mockUseModelStore.mockReturnValue({
Expand Down
90 changes: 90 additions & 0 deletions __tests__/WebFavicon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react-native';

jest.mock('../context/ThemeContext', () => ({
useTheme: () => ({
theme: {
...require('../styles/colors').lightTheme,
insets: { top: 0, bottom: 0, left: 0, right: 0 },
},
}),
}));

import WebFavicon from '../components/chat-screen/WebFavicon';

beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});

const failOnce = () => {
fireEvent(screen.getByTestId('web-favicon'), 'error');
};

describe('WebFavicon — a failed load is retried, not final', () => {
it('requests the icon again after an error, with a cache-busting uri', () => {
render(<WebFavicon url="https://a.example/page" size={16} />);
const first = screen.getByTestId('web-favicon').props.source.uri;

failOnce();
act(() => {
jest.advanceTimersByTime(2000);
});

const retried = screen.getByTestId('web-favicon').props.source.uri;
expect(retried).not.toBe(first);
expect(retried).toContain('retry=1');
});

it('waits out the backoff before retrying', () => {
render(<WebFavicon url="https://a.example/page" size={16} />);

failOnce();
act(() => {
jest.advanceTimersByTime(1999);
});
expect(screen.getByTestId('web-favicon').props.source.uri).not.toContain(
'retry'
);
});

it('gives up for good only after the retry budget is spent', () => {
render(<WebFavicon url="https://a.example/page" size={16} />);

failOnce();
act(() => {
jest.advanceTimersByTime(2000);
});
failOnce();
act(() => {
jest.advanceTimersByTime(6000);
});
expect(screen.getByTestId('web-favicon').props.source.uri).toContain(
'retry=2'
);

failOnce();
act(() => {
jest.advanceTimersByTime(60000);
});
expect(screen.queryByTestId('web-favicon')).toBeNull();
});

it('starts over with a clean slate when the url changes', () => {
const view = render(<WebFavicon url="https://a.example/page" size={16} />);
failOnce();
act(() => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByTestId('web-favicon').props.source.uri).toContain(
'retry=1'
);

view.rerender(<WebFavicon url="https://b.example/page" size={16} />);
const fresh = screen.getByTestId('web-favicon').props.source.uri;
expect(fresh).toContain('b.example');
expect(fresh).not.toContain('retry');
});
});
Loading
Loading