fix(chat): suppress 'stop' badge in metadata footer for normal turn ends

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 15:40:31 +02:00
parent fa15634381
commit 9d945150e0
@@ -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