Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions __tests__/MessageItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,22 @@ describe('user messages', () => {
renderItem({ role: 'user', content: 'My question' });
expect(screen.getByText('My question')).toBeTruthy();
});

it('keeps the text of a pasted <think> block, dropping only the markers', () => {
renderItem({
role: 'user',
content: 'Pytanie <think>notatka</think> dalej',
});

expect(screen.getByText('Pytanie notatka dalej')).toBeTruthy();
expect(screen.queryByTestId('thinking-block')).toBeNull();
});

it('keeps the text of a pasted unterminated <think> block', () => {
renderItem({ role: 'user', content: 'Look at this: <think>cut off' });

expect(screen.getByText('Look at this: cut off')).toBeTruthy();
});
});

// ─── user messages with image ─────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion __tests__/messageSources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ describe('visibleAnswer', () => {
});

it('drops an unterminated think block (streaming) entirely', () => {
expect(visibleAnswer('visible<think>still reasoning')).toBe('visible ');
expect(visibleAnswer('visible<think>still reasoning')).toBe('visible');
});

it('returns the text unchanged when there is no think block', () => {
Expand Down
124 changes: 124 additions & 0 deletions __tests__/messageText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { Message } from '../database/chatRepository';
import { visibleMessageText } from '../utils/messageText';
import { stripThinkBlocks, thinkBlocksText } from '../utils/thinking';

const message = (overrides: Partial<Message> = {}): Message =>
({
id: 1,
chatId: 1,
role: 'assistant',
content: '',
timestamp: 0,
...overrides,
}) as Message;

describe('stripThinkBlocks', () => {
it('leaves a reply without a think block untouched', () => {
expect(stripThinkBlocks('Plain answer')).toBe('Plain answer');
});

it('drops a closed block and keeps the text around it', () => {
expect(stripThinkBlocks('before<think>hidden</think>after')).toBe(
'beforeafter'
);
});

it('keeps the original spacing of the answer', () => {
expect(stripThinkBlocks('<think>hidden</think>\n\nThe answer.')).toBe(
'The answer.'
);
});

it('drops every block, not just the first', () => {
expect(
stripThinkBlocks('one <think>a</think>two <think>b</think>three')
).toBe('one two three');
});

it('drops an unterminated block and everything after it', () => {
expect(stripThinkBlocks('visible<think>still reasoning')).toBe('visible');
});
});

describe('thinkBlocksText', () => {
it('returns the reasoning of a closed block', () => {
expect(thinkBlocksText('<think>reasoning</think>answer')).toBe('reasoning');
});

it('returns the reasoning of an unterminated block', () => {
expect(thinkBlocksText('<think>interrupted reasoning')).toBe(
'interrupted reasoning'
);
});

it('joins several blocks', () => {
expect(thinkBlocksText('<think>a</think>x<think>b</think>')).toBe('a\n\nb');
});

it('returns an empty string when there is no block', () => {
expect(thinkBlocksText('plain answer')).toBe('');
});
});

describe('visibleMessageText', () => {
it('strips the think block from an assistant reply', () => {
const text = visibleMessageText(
message({ content: '<think>long reasoning</think>The answer is 42.' })
);

expect(text).toBe('The answer is 42.');
});

it('strips an unterminated think block from an interrupted reply', () => {
const text = visibleMessageText(
message({ content: 'Partial answer.<think>reasoning cut off' })
);

expect(text).toBe('Partial answer.');
});

it('falls back to the reasoning when the reply is nothing but a think block', () => {
const text = visibleMessageText(
message({ content: '<think>reasoning cut off' })
);

expect(text).toBe('reasoning cut off');
});

it('strips [n] citation markers when the reply is grounded in sources', () => {
const text = visibleMessageText(
message({
content: '<think>which file?</think>The total was 100 [1].',
sourceDocuments: [{ documentId: 1, name: 'report.pdf' }],
})
);

expect(text).toBe('The total was 100.');
});

it('keeps bracketed numbers when the reply has no sources', () => {
const text = visibleMessageText(message({ content: 'See item [1].' }));

expect(text).toBe('See item [1].');
});

it('copies a user message without think markers but keeps every word', () => {
const content = 'Pytanie <think>notatka</think> dalej';

expect(visibleMessageText(message({ role: 'user', content }))).toBe(
'Pytanie notatka dalej'
);
});

it('copies an assistant reply whose think block has no opening marker', () => {
const content = 'model reasoning</think>The real answer.';

expect(visibleMessageText(message({ content }))).toBe('The real answer.');
});

it('copies an assistant reply with several think blocks, markers included', () => {
const content = 'a<think>x</think>b<think>y</think>c';

expect(visibleMessageText(message({ content }))).toBe('abc');
});
});
139 changes: 139 additions & 0 deletions __tests__/useScrollSettler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { act, renderHook } from '@testing-library/react-native';
import {
SCROLL_SETTLE_STEPS,
useScrollSettler,
} from '../components/chat-screen/useScrollSettler';

const LAST_STEP = SCROLL_SETTLE_STEPS[SCROLL_SETTLE_STEPS.length - 1];

const setup = () => {
const snap = jest.fn();
const { result, unmount } = renderHook(() => useScrollSettler(snap));
return { snap, result, unmount };
};

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

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

describe('useScrollSettler', () => {
it('snaps immediately, then re-snaps at every step', () => {
const { snap, result } = setup();

act(() => result.current.start());
expect(snap).toHaveBeenCalledTimes(1);
expect(snap).toHaveBeenLastCalledWith(true);

act(() => jest.advanceTimersByTime(LAST_STEP.delay));

expect(snap).toHaveBeenCalledTimes(1 + SCROLL_SETTLE_STEPS.length);
expect(snap.mock.calls.slice(1)).toEqual(
SCROLL_SETTLE_STEPS.map((step) => [step.animated])
);
});

it('keeps the send transition animated, then corrects instantly', () => {
const { snap, result } = setup();

act(() => result.current.start());
act(() => jest.advanceTimersByTime(220));
expect(snap.mock.calls.every(([animated]) => animated === true)).toBe(true);

snap.mockClear();
act(() => jest.advanceTimersByTime(LAST_STEP.delay - 220));
expect(snap).toHaveBeenCalled();
expect(snap.mock.calls.every(([animated]) => animated === false)).toBe(
true
);
});

it('keeps re-snapping past the keyboard-dismiss animation', () => {
const { snap, result } = setup();

act(() => result.current.start());
act(() => jest.advanceTimersByTime(300));
const beforeTail = snap.mock.calls.length;

act(() => jest.advanceTimersByTime(LAST_STEP.delay - 300));

expect(snap.mock.calls.length).toBeGreaterThan(beforeTail);
});

it('stops re-snapping once cancelled', () => {
const { snap, result } = setup();

act(() => result.current.start());
snap.mockClear();
act(() => result.current.cancel());

act(() => jest.advanceTimersByTime(LAST_STEP.delay * 2));

expect(snap).not.toHaveBeenCalled();
expect(result.current.isSettling()).toBe(false);
});

it('reports settling only inside the window', () => {
const { result } = setup();

expect(result.current.isSettling()).toBe(false);

act(() => result.current.start());
expect(result.current.isSettling()).toBe(true);

act(() => jest.advanceTimersByTime(LAST_STEP.delay));
expect(result.current.isSettling()).toBe(false);
});

it('resettles only while a pin is in flight', () => {
const { snap, result } = setup();

act(() => result.current.resettle());
expect(snap).not.toHaveBeenCalled();

act(() => result.current.start());
snap.mockClear();
act(() => result.current.resettle());
expect(snap).toHaveBeenLastCalledWith(true);

act(() => jest.advanceTimersByTime(340));
snap.mockClear();
act(() => result.current.resettle());
expect(snap).toHaveBeenLastCalledWith(false);

act(() => jest.advanceTimersByTime(LAST_STEP.delay));
snap.mockClear();
act(() => result.current.resettle());
expect(snap).not.toHaveBeenCalled();
});

it('restarts cleanly when a second message is sent mid-window', () => {
const { snap, result } = setup();

act(() => result.current.start());
act(() => jest.advanceTimersByTime(SCROLL_SETTLE_STEPS[0].delay));
snap.mockClear();

act(() => result.current.start());
expect(snap).toHaveBeenLastCalledWith(true);

snap.mockClear();
act(() => jest.advanceTimersByTime(LAST_STEP.delay));
expect(snap).toHaveBeenCalledTimes(SCROLL_SETTLE_STEPS.length);
});

it('drops pending timers on unmount', () => {
const { snap, result, unmount } = setup();

act(() => result.current.start());
snap.mockClear();
unmount();

act(() => jest.advanceTimersByTime(LAST_STEP.delay * 2));

expect(snap).not.toHaveBeenCalled();
});
});
4 changes: 3 additions & 1 deletion components/chat-screen/ChatScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import useChatSettings from '../../hooks/useChatSettings';
import Toast from 'react-native-toast-message';
import { persistImage } from '../../utils/persistImage';
import { setLastUsedModelId } from '../../utils/lastUsedModel';
import { stripThinkMarkers } from '../../utils/thinking';
import useChatBranching from '../../hooks/useChatBranching';
import {
LAYOUT_HEIGHT_CHANGE_THRESHOLD,
Expand Down Expand Up @@ -187,7 +188,8 @@ export default function ChatScreen({
const isNewChat = !(await checkIfChatExists(db, targetChatId));
if (isNewChat) {
const docName = attachments?.find((a) => a.type === 'document')?.name;
const titleSource = userInput.trim() || docName || 'New chat';
const titleSource =
stripThinkMarkers(userInput).trim() || docName || 'New chat';
const newChatTitle =
titleSource.length > 25
? titleSource.slice(0, 25) + '...'
Expand Down
38 changes: 4 additions & 34 deletions components/chat-screen/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { Message, type SourceDocument } from '../../database/chatRepository';
import { stripCitations } from '../../utils/citations';
import { sourceKey } from '../../utils/contextUtils';
import { parseThinkingContent, stripThinkMarkers } from '../../utils/thinking';

interface MessageItemProps {
message: Message;
Expand All @@ -47,38 +48,6 @@ interface MessageItemProps {
onFork?: (message: Message) => void;
}

const THINK_OPEN = '<think>';
const THINK_CLOSE = '</think>';

const parseThinkingContent = (text: string) => {
const thinkStartIndex = text.indexOf(THINK_OPEN);
if (thinkStartIndex === -1) {
return { normalContent: text, thinkingContent: null, hasThinking: false };
}

const thinkEndIndex = text.indexOf(THINK_CLOSE);
const normalBeforeThink = text.slice(0, thinkStartIndex);
const contentStart = thinkStartIndex + THINK_OPEN.length;

if (thinkEndIndex === -1) {
return {
normalContent: normalBeforeThink,
thinkingContent: text.slice(contentStart),
hasThinking: true,
isThinkingComplete: false,
normalAfterThink: '',
};
}

return {
normalContent: normalBeforeThink,
thinkingContent: text.slice(contentStart, thinkEndIndex),
hasThinking: true,
isThinkingComplete: true,
normalAfterThink: text.slice(thinkEndIndex + THINK_CLOSE.length),
};
};

const MessageItem = memo(
({
message,
Expand All @@ -104,6 +73,7 @@ const MessageItem = memo(
const [lightboxVisible, setLightboxVisible] = useState(false);

const contentParts = parseThinkingContent(content);
const userText = useMemo(() => stripThinkMarkers(content), [content]);
const hasSources = !!sourceDocuments?.length;
const displayedSources = useMemo(() => {
if (!sourceDocuments?.length) return [];
Expand Down Expand Up @@ -229,14 +199,14 @@ const MessageItem = memo(
</View>
</View>
)}
{contentParts.normalContent.trim() && (
{userText.trim() && (
<View style={styles.userBubble} testID="text-bubble">
<View style={styles.userMessageContent}>
<Text
style={styles.userText}
selectable={!SUPPORTS_USER_ACTION_MENU}
>
{contentParts.normalContent}
{userText}
</Text>
</View>
</View>
Expand Down
Loading
Loading