diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts
index 85ebed41c..2e8663b20 100644
--- a/frontend/src/core/messages/utils.ts
+++ b/frontend/src/core/messages/utils.ts
@@ -431,8 +431,13 @@ export function getAssistantTurnCopyData(
.reverse()
.filter((message) => message.type === "ai")
.map((message) => {
+ // extractContentFromMessage never returns null, so fall back to
+ // reasoning on empty text (same rule as getMessageCopyData) —
+ // otherwise a reasoning-only turn loses its copy button entirely.
const content = extractContentFromMessage(message);
- return content ?? extractReasoningContentFromMessage(message) ?? "";
+ return content.length > 0
+ ? content
+ : (extractReasoningContentFromMessage(message) ?? "");
})
.find((content) => content.length > 0) ?? null
);
@@ -483,14 +488,23 @@ function splitInlineReasoning(content: string): InlineReasoningSplit {
const reasoningParts: string[] = [];
// First pass: strip every fully closed `...` pair and
- // collect its body as reasoning.
- let cleaned = content.replace(THINK_TAG_RE, (_, reasoning: string) => {
- const normalized = reasoning.trim();
- if (normalized) {
- reasoningParts.push(normalized);
- }
- return "";
- });
+ // collect its body as reasoning. A pair whose opener sits right after a
+ // backtick is the model talking about the tag literally inside markdown
+ // inline code (same guard as the streaming pass below) — leave it in the
+ // rendered content instead of hollowing out the code span.
+ let cleaned = content.replace(
+ THINK_TAG_RE,
+ (match: string, reasoning: string, offset: number) => {
+ if (content[offset - 1] === "`") {
+ return match;
+ }
+ const normalized = reasoning.trim();
+ if (normalized) {
+ reasoningParts.push(normalized);
+ }
+ return "";
+ },
+ );
// Streaming-safe pass: a `` opener whose `` has not arrived
// yet means the rest of the chunk is reasoning in flight. Route it into the
diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts
index 027352b42..2a9b2d685 100644
--- a/frontend/tests/unit/core/messages/utils.test.ts
+++ b/frontend/tests/unit/core/messages/utils.test.ts
@@ -456,6 +456,30 @@ describe("inline tag splitting", () => {
expect(hasReasoning(message)).toBe(false);
});
+ test("a closed pair inside markdown inline code stays in content", () => {
+ // The model talking about the tag literally, e.g. when explaining prompt
+ // engineering. Hollowing the code span out into the reasoning panel
+ // corrupts the visible answer.
+ const message = aiMessage(
+ "Wrap your reasoning in `your reasoning` before answering.",
+ );
+ expect(extractContentFromMessage(message)).toBe(
+ "Wrap your reasoning in `your reasoning` before answering.",
+ );
+ expect(extractReasoningContentFromMessage(message)).toBeNull();
+ expect(hasReasoning(message)).toBe(false);
+ });
+
+ test("a closed pair outside code is still stripped when another sits in inline code", () => {
+ const message = aiMessage(
+ "real reasoningUse `text` markers.",
+ );
+ expect(extractContentFromMessage(message)).toBe(
+ "Use `text` markers.",
+ );
+ expect(extractReasoningContentFromMessage(message)).toBe("real reasoning");
+ });
+
test("a backtick-prefixed mid-stream is not split into reasoning", () => {
// Simulates the moment the model has emitted the opening backtick and
// `` for a literal documentation reference, before the closing
@@ -713,6 +737,22 @@ test("hides assistant copy data while that turn is streaming", () => {
expect(getAssistantTurnCopyData(messages, { isStreaming: true })).toBeNull();
});
+test("falls back to reasoning for a reasoning-only assistant turn's copy data", () => {
+ // A turn can end with reasoning but no answer text (e.g. stopped during
+ // thinking). getMessageCopyData already copies the reasoning in that case;
+ // the turn-level copy button must not disappear instead.
+ const messages = [
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "",
+ additional_kwargs: { reasoning_content: "the actual reasoning" },
+ },
+ ] as Message[];
+
+ expect(getAssistantTurnCopyData(messages)).toBe("the actual reasoning");
+});
+
test("marks the latest assistant message as streaming", () => {
const messages = [
{