Skip to content

Commit 93b0d8a

Browse files
kfaracikclaude
andcommitted
fix(chat): copy the text the bubble shows, keep pasted think markup readable (#269)
Copying an assistant reply put `message.content` on the clipboard verbatim, including the `<think>…</think>` block and the `[n]` citation markers that are hidden on screen. Pasting that back produced a user bubble that looked almost empty, because `parseThinkingContent` ran for every role and the user branch rendered only the text *before* `<think>` — dropping the block and everything after `</think>`. - Copy now goes through `visibleMessageText`: think blocks stripped, citation markers stripped when the reply is grounded in sources, and a reply that is nothing but reasoning falls back to that reasoning instead of an empty string. - User messages keep every word; only the think markers are dropped, so pasted markup never swallows the bubble. - The think parser was duplicated in `MessageItem` and `messageSources`; both now share `utils/thinking.ts`, which also handles an orphan `</think>`. - New chat titles are built from the marker-free text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 395d767 commit 93b0d8a

9 files changed

Lines changed: 263 additions & 55 deletions

File tree

__tests__/MessageItem.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,22 @@ describe('user messages', () => {
251251
renderItem({ role: 'user', content: 'My question' });
252252
expect(screen.getByText('My question')).toBeTruthy();
253253
});
254+
255+
it('keeps the text of a pasted <think> block, dropping only the markers', () => {
256+
renderItem({
257+
role: 'user',
258+
content: 'Pytanie <think>notatka</think> dalej',
259+
});
260+
261+
expect(screen.getByText('Pytanie notatka dalej')).toBeTruthy();
262+
expect(screen.queryByTestId('thinking-block')).toBeNull();
263+
});
264+
265+
it('keeps the text of a pasted unterminated <think> block', () => {
266+
renderItem({ role: 'user', content: 'Look at this: <think>cut off' });
267+
268+
expect(screen.getByText('Look at this: cut off')).toBeTruthy();
269+
});
254270
});
255271

256272
// ─── user messages with image ─────────────────────────────────────────────────

__tests__/messageSources.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ describe('visibleAnswer', () => {
354354
});
355355

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

360360
it('returns the text unchanged when there is no think block', () => {

__tests__/messageText.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { Message } from '../database/chatRepository';
2+
import { visibleMessageText } from '../utils/messageText';
3+
import { stripThinkBlocks, thinkBlocksText } from '../utils/thinking';
4+
5+
const message = (overrides: Partial<Message> = {}): Message =>
6+
({
7+
id: 1,
8+
chatId: 1,
9+
role: 'assistant',
10+
content: '',
11+
timestamp: 0,
12+
...overrides,
13+
}) as Message;
14+
15+
describe('stripThinkBlocks', () => {
16+
it('leaves a reply without a think block untouched', () => {
17+
expect(stripThinkBlocks('Plain answer')).toBe('Plain answer');
18+
});
19+
20+
it('drops a closed block and keeps the text around it', () => {
21+
expect(stripThinkBlocks('before<think>hidden</think>after')).toBe(
22+
'beforeafter'
23+
);
24+
});
25+
26+
it('keeps the original spacing of the answer', () => {
27+
expect(stripThinkBlocks('<think>hidden</think>\n\nThe answer.')).toBe(
28+
'The answer.'
29+
);
30+
});
31+
32+
it('drops every block, not just the first', () => {
33+
expect(
34+
stripThinkBlocks('one <think>a</think>two <think>b</think>three')
35+
).toBe('one two three');
36+
});
37+
38+
it('drops an unterminated block and everything after it', () => {
39+
expect(stripThinkBlocks('visible<think>still reasoning')).toBe('visible');
40+
});
41+
});
42+
43+
describe('thinkBlocksText', () => {
44+
it('returns the reasoning of a closed block', () => {
45+
expect(thinkBlocksText('<think>reasoning</think>answer')).toBe('reasoning');
46+
});
47+
48+
it('returns the reasoning of an unterminated block', () => {
49+
expect(thinkBlocksText('<think>interrupted reasoning')).toBe(
50+
'interrupted reasoning'
51+
);
52+
});
53+
54+
it('joins several blocks', () => {
55+
expect(thinkBlocksText('<think>a</think>x<think>b</think>')).toBe('a\n\nb');
56+
});
57+
58+
it('returns an empty string when there is no block', () => {
59+
expect(thinkBlocksText('plain answer')).toBe('');
60+
});
61+
});
62+
63+
describe('visibleMessageText', () => {
64+
it('strips the think block from an assistant reply', () => {
65+
const text = visibleMessageText(
66+
message({ content: '<think>long reasoning</think>The answer is 42.' })
67+
);
68+
69+
expect(text).toBe('The answer is 42.');
70+
});
71+
72+
it('strips an unterminated think block from an interrupted reply', () => {
73+
const text = visibleMessageText(
74+
message({ content: 'Partial answer.<think>reasoning cut off' })
75+
);
76+
77+
expect(text).toBe('Partial answer.');
78+
});
79+
80+
it('falls back to the reasoning when the reply is nothing but a think block', () => {
81+
const text = visibleMessageText(
82+
message({ content: '<think>reasoning cut off' })
83+
);
84+
85+
expect(text).toBe('reasoning cut off');
86+
});
87+
88+
it('strips [n] citation markers when the reply is grounded in sources', () => {
89+
const text = visibleMessageText(
90+
message({
91+
content: '<think>which file?</think>The total was 100 [1].',
92+
sourceDocuments: [{ documentId: 1, name: 'report.pdf' }],
93+
})
94+
);
95+
96+
expect(text).toBe('The total was 100.');
97+
});
98+
99+
it('keeps bracketed numbers when the reply has no sources', () => {
100+
const text = visibleMessageText(message({ content: 'See item [1].' }));
101+
102+
expect(text).toBe('See item [1].');
103+
});
104+
105+
it('copies a user message without think markers but keeps every word', () => {
106+
const content = 'Pytanie <think>notatka</think> dalej';
107+
108+
expect(visibleMessageText(message({ role: 'user', content }))).toBe(
109+
'Pytanie notatka dalej'
110+
);
111+
});
112+
113+
it('copies an assistant reply whose think block has no opening marker', () => {
114+
const content = 'model reasoning</think>The real answer.';
115+
116+
expect(visibleMessageText(message({ content }))).toBe('The real answer.');
117+
});
118+
119+
it('copies an assistant reply with several think blocks, markers included', () => {
120+
const content = 'a<think>x</think>b<think>y</think>c';
121+
122+
expect(visibleMessageText(message({ content }))).toBe('abc');
123+
});
124+
});

components/chat-screen/ChatScreen.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import useChatSettings from '../../hooks/useChatSettings';
4646
import Toast from 'react-native-toast-message';
4747
import { persistImage } from '../../utils/persistImage';
4848
import { setLastUsedModelId } from '../../utils/lastUsedModel';
49+
import { stripThinkMarkers } from '../../utils/thinking';
4950
import useChatBranching from '../../hooks/useChatBranching';
5051
import {
5152
LAYOUT_HEIGHT_CHANGE_THRESHOLD,
@@ -187,7 +188,8 @@ export default function ChatScreen({
187188
const isNewChat = !(await checkIfChatExists(db, targetChatId));
188189
if (isNewChat) {
189190
const docName = attachments?.find((a) => a.type === 'document')?.name;
190-
const titleSource = userInput.trim() || docName || 'New chat';
191+
const titleSource =
192+
stripThinkMarkers(userInput).trim() || docName || 'New chat';
191193
const newChatTitle =
192194
titleSource.length > 25
193195
? titleSource.slice(0, 25) + '...'

components/chat-screen/MessageItem.tsx

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { Message, type SourceDocument } from '../../database/chatRepository';
2828
import { stripCitations } from '../../utils/citations';
2929
import { sourceKey } from '../../utils/contextUtils';
30+
import { parseThinkingContent, stripThinkMarkers } from '../../utils/thinking';
3031

3132
interface MessageItemProps {
3233
message: Message;
@@ -47,38 +48,6 @@ interface MessageItemProps {
4748
onFork?: (message: Message) => void;
4849
}
4950

50-
const THINK_OPEN = '<think>';
51-
const THINK_CLOSE = '</think>';
52-
53-
const parseThinkingContent = (text: string) => {
54-
const thinkStartIndex = text.indexOf(THINK_OPEN);
55-
if (thinkStartIndex === -1) {
56-
return { normalContent: text, thinkingContent: null, hasThinking: false };
57-
}
58-
59-
const thinkEndIndex = text.indexOf(THINK_CLOSE);
60-
const normalBeforeThink = text.slice(0, thinkStartIndex);
61-
const contentStart = thinkStartIndex + THINK_OPEN.length;
62-
63-
if (thinkEndIndex === -1) {
64-
return {
65-
normalContent: normalBeforeThink,
66-
thinkingContent: text.slice(contentStart),
67-
hasThinking: true,
68-
isThinkingComplete: false,
69-
normalAfterThink: '',
70-
};
71-
}
72-
73-
return {
74-
normalContent: normalBeforeThink,
75-
thinkingContent: text.slice(contentStart, thinkEndIndex),
76-
hasThinking: true,
77-
isThinkingComplete: true,
78-
normalAfterThink: text.slice(thinkEndIndex + THINK_CLOSE.length),
79-
};
80-
};
81-
8251
const MessageItem = memo(
8352
({
8453
message,
@@ -104,6 +73,7 @@ const MessageItem = memo(
10473
const [lightboxVisible, setLightboxVisible] = useState(false);
10574

10675
const contentParts = parseThinkingContent(content);
76+
const userText = useMemo(() => stripThinkMarkers(content), [content]);
10777
const hasSources = !!sourceDocuments?.length;
10878
const displayedSources = useMemo(() => {
10979
if (!sourceDocuments?.length) return [];
@@ -229,14 +199,14 @@ const MessageItem = memo(
229199
</View>
230200
</View>
231201
)}
232-
{contentParts.normalContent.trim() && (
202+
{userText.trim() && (
233203
<View style={styles.userBubble} testID="text-bubble">
234204
<View style={styles.userMessageContent}>
235205
<Text
236206
style={styles.userText}
237207
selectable={!SUPPORTS_USER_ACTION_MENU}
238208
>
239-
{contentParts.normalContent}
209+
{userText}
240210
</Text>
241211
</View>
242212
</View>

components/chat-screen/Messages.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import BranchMarker from './BranchMarker';
4949
import Toast from 'react-native-toast-message';
5050
import { SUPPORTS_USER_ACTION_MENU } from '../../constants/chat-screen';
5151
import { useKeyboardLift } from './useKeyboardLift';
52+
import { visibleMessageText } from '../../utils/messageText';
5253

5354
/**
5455
* Height of the opaque system navigation bar the list paints behind. Android
@@ -512,7 +513,7 @@ const Messages = ({
512513

513514
const handleCopyMessage = useCallback(
514515
async (message: Message) => {
515-
await Clipboard.setStringAsync(message.content);
516+
await Clipboard.setStringAsync(visibleMessageText(message));
516517
if (message.role === 'user') {
517518
closeUserActionMenu();
518519
}

utils/messageSources.ts

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@ import {
1717
NEGATION_CUE_EN,
1818
NO_ANSWER_PATTERNS_EN,
1919
NO_ANSWER_PATTERNS_PL,
20-
THINK_CLOSE,
21-
THINK_OPEN,
2220
} from '../constants/citations';
21+
import { outsideThinkSegments } from './thinking';
2322

2423
export interface SourceRow {
2524
id: number;
@@ -115,22 +114,8 @@ const overlapWithAnswer = (
115114
};
116115

117116
// Attribute against the visible reply only; the <think> block surveys every source and inflates overlap.
118-
export const visibleAnswer = (answer: string): string => {
119-
const parts: string[] = [];
120-
let cursor = 0;
121-
let open = answer.indexOf(THINK_OPEN);
122-
123-
while (open !== -1) {
124-
parts.push(answer.slice(cursor, open));
125-
const close = answer.indexOf(THINK_CLOSE, open + THINK_OPEN.length);
126-
if (close === -1) return `${parts.join(' ')} `;
127-
cursor = close + THINK_CLOSE.length;
128-
open = answer.indexOf(THINK_OPEN, cursor);
129-
}
130-
131-
parts.push(answer.slice(cursor));
132-
return parts.join(' ');
133-
};
117+
export const visibleAnswer = (answer: string): string =>
118+
outsideThinkSegments(answer).join(' ');
134119

135120
const affirmativeAnswer = (visibleReply: string): string =>
136121
(visibleReply.match(CITATION_SENTENCE_PATTERN) ?? [visibleReply])

utils/messageText.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Message } from '../database/chatRepository';
2+
import { stripCitations } from './citations';
3+
import {
4+
stripThinkBlocks,
5+
stripThinkMarkers,
6+
thinkBlocksText,
7+
} from './thinking';
8+
9+
export const visibleMessageText = (message: Message): string => {
10+
if (message.role !== 'assistant') return stripThinkMarkers(message.content);
11+
12+
const answer =
13+
stripThinkBlocks(message.content) || thinkBlocksText(message.content);
14+
15+
return message.sourceDocuments?.length
16+
? stripCitations(answer).trim()
17+
: answer;
18+
};

0 commit comments

Comments
 (0)