From 9d945150e00db2c9ba65e873fe34bf21881b0a60 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 4 May 2026 15:40:31 +0200 Subject: [PATCH] fix(chat): suppress 'stop' badge in metadata footer for normal turn ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every text-bearing assistant turn finalizes with `finishReason="stop"` (set by `RichChatViewModel.finalizeStreamingMessage` line 881 — the standard end-of-turn signal Hermes/ACP/OpenAI all emit). The `metadataFooter` in `RichMessageBubble` was rendering it unconditionally, so every assistant bubble carried a `· stop · TIME` footer. Combined with terse model output (e.g. deepseek-v4-flash emitting only a brief status line before ending the turn), the badge created a misleading "the agent gave up" impression — there was no warning, error, or actual failure. Match the convention used by ChatGPT, Claude.ai, Cursor, etc.: suppress the badge for normal end-of-turn (`stop` / `end_turn`), reserve it for abnormal terminations the user actually wants to see (`max_tokens`, `length`, `error`, `refusal`, `content_filter`, …). When it does render, color it with severity tone — warning yellow for "response cut short" cases, danger red for failures and refusals, muted otherwise. The existing `handlePromptComplete` system-message-injection path (line 725-751) for non-`end_turn` stops still surfaces those cases explicitly at the top of the chat — this change only trims the always-on badge from the per-message footer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Chat/Views/RichMessageBubble.swift | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift index 8f0f685..e924217 100644 --- a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift +++ b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift @@ -348,10 +348,13 @@ struct RichMessageBubble: View, Equatable { Text("\(tokens) tok") .font(ChatFontScale.monoSmall(chatFontScale)) } - if let reason = message.finishReason, !reason.isEmpty { + if let reason = message.finishReason, + Self.shouldShowFinishReason(reason) + { Text("·") Text(reason) .font(ChatFontScale.caption(chatFontScale)) + .foregroundStyle(Self.finishReasonTone(reason)) } if let time = message.timestamp { Text("·") @@ -377,6 +380,36 @@ struct RichMessageBubble: View, Equatable { .padding(.leading, 4) } + /// Whether `finishReason` should render as a visible badge in the + /// message footer. `stop` and `end_turn` are normal end-of-turn + /// signals — `RichChatViewModel.finalizeStreamingMessage` stamps + /// `"stop"` on every text-bearing turn-final assistant message — + /// so showing them creates the impression that something stopped + /// the agent prematurely. We suppress them and reserve the badge + /// for abnormal terminations (max_tokens, error, refusal, + /// content_filter, …) the user actually wants to see. Matches + /// the conventions in ChatGPT, Claude.ai, Cursor, etc. + private static func shouldShowFinishReason(_ reason: String) -> Bool { + let normalized = reason.trimmingCharacters(in: .whitespaces).lowercased() + return !["stop", "end_turn", "end-turn", ""].contains(normalized) + } + + /// Visual tone for an abnormal finish-reason badge. Severity + /// scales: warning (yellow) for "the response was cut short" cases + /// the user can usually retry, danger (red) for outright failures + /// or refusals, muted otherwise so unrecognized reasons stay + /// readable but un-alarming. + private static func finishReasonTone(_ reason: String) -> Color { + switch reason.lowercased() { + case "max_tokens", "length", "content_filter": + return ScarfColor.warning + case "error", "refusal": + return ScarfColor.danger + default: + return ScarfColor.foregroundMuted + } + } + /// Speaker glyph that toggles `AVSpeechSynthesizer` playback for /// the assistant reply. Lives in its own view so the /// `MessageSpeechService` observation doesn't fight the bubble's