mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user