mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
11 Commits
v12-updates
...
v2.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 03c996ee80 | |||
| 8428cbff10 | |||
| 381adfd925 | |||
| 254af46e93 | |||
| 596c844da5 | |||
| ec47d191a1 | |||
| 31e6c31acf | |||
| fcfe1c89d6 | |||
| df1b9caabf | |||
| a41c81c048 | |||
| 88add62997 |
@@ -106,6 +106,19 @@ The foundation of every gated surface above:
|
||||
|
||||
### Bug fixes
|
||||
|
||||
#### Chat composer + transcript (post-merge round)
|
||||
|
||||
- **Typing lag in the chat composer (#67)** — `RichChatInputBar.updateMenuState()` ran on every keystroke and unconditionally wrote both `showMenu` and `selectedIndex`, tripping SwiftUI's "action tried to update multiple times per frame" warning and stalling input. Composer now coalesces writes to deltas, short-circuits when not in slash mode (the common case), and watches `commands.count` instead of re-allocating `commands.map(\.id)` per keystroke.
|
||||
- **Chat font-size slider had no visible effect (#68)** — `RichChatView` only set `\.dynamicTypeSize`, but `ScarfFont` tokens are fixed-point (`Font.system(size: 14, …)`) so dynamic type didn't reach bubble text, reasoning, tool chips, code blocks, or markdown headings. New `\.chatFontScale` env value plumbed through `RichMessageBubble`, `MarkdownContentView`, and `CodeBlockView`; `ChatFontScale.{body, caption, captionStrong, caption2, mono, monoSmall, codeBlock, codeInline}(_:)` helpers mirror the ScarfFont base sizes so 100% is byte-for-byte identical to today's UI.
|
||||
- **Placeholder ghosting on first keystroke (#65)** — `TextEditor`'s NSTextView surfaces a typed glyph one frame before the SwiftUI binding propagates, so the bare `if text.isEmpty` overlay rendered the translucent placeholder text on top of the just-typed character. Pinned an opaque background behind the placeholder rect and switched the conditional to `.opacity(...)` so the view tree stays stable per keystroke.
|
||||
- **Draft text leaked between conversations (#62)** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId` so SwiftUI rebuilds the view (and its `@State`) on session change. Stable fallback string for the "no session selected" window — `UUID()` would have minted a new id per body re-eval and trashed the composer mid-typing.
|
||||
- **Sent message rendered blank after navigating away (#63)** — when a user sent a prompt and immediately resumed a different session before Hermes flushed the row to state.db, `resumeSession`'s `reset()` cleared `messages` and `loadSessionHistory` then read an as-yet-empty DB. New per-session pending-user-messages cache survives `reset()` and re-injects still-pending entries on load; entries clear themselves as soon as a matching DB row catches up.
|
||||
- **No completion notification (#64)** — sending a long prompt and switching to other work required polling the chat to know when the response landed. New `ChatNotificationService` fires a local `UNUserNotificationCenter` banner on prompt completion when Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
|
||||
- **Per-message TTS playback (#66)** — small speaker glyph in each settled assistant bubble's metadata footer; uses `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice, picks up offline. Markdown control characters stripped before speech. The deeper Settings → Voice provider integration (Edge / ElevenLabs / OpenAI / NeuTTS / Piper) is queued as a v2.7 follow-up.
|
||||
- **ACP control-message timeout under gateway concurrency (#61)** — bumped 30s → 60s. State.db lock contention on a healthy host clears in seconds, but the previous 30s watchdog tripped under realistic gateway+ACP concurrency (Discord sync / skill registration / cron scheduling holding write locks during ACP `initialize` / `session/new` / `session/load`). 60s gives lock resolution headroom while still surfacing genuinely broken transports.
|
||||
|
||||
#### Pre-merge
|
||||
|
||||
- **Test target compile** — `M5FeatureVMTests.ScriptedTransport` had drifted off the `ServerTransport` protocol after `cachedSnapshotPath` landed in v2.5.2; added the missing stub. `M0dViewModelsTests` got the `ConnectionStatusViewModel.Status.degraded` argument-name update. `CredentialPoolsGatingTests` got the missing `import ScarfCore`. The full `swift test` suite now runs (and passes — 215 tests across 17 suites).
|
||||
- **iOS package compile** — `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive` used `Foundation.Process` unconditionally, breaking the iOS build entirely (Process is unavailable on the iOS SDK). Wrapped in `#if !os(iOS)` with iOS stubs that throw — backup/restore is Mac-only by design.
|
||||
|
||||
|
||||
@@ -362,10 +362,17 @@ public actor ACPClient {
|
||||
#endif
|
||||
|
||||
// session/prompt streams events and can run for minutes — no hard
|
||||
// timeout. Control messages get a 30s watchdog.
|
||||
// timeout. Control messages get a 60s watchdog. Older versions
|
||||
// capped at 30s, which the field reported (#61) was tripping
|
||||
// under realistic gateway+ACP concurrency: the gateway holds
|
||||
// state.db locks for Discord sync / skill registration / cron
|
||||
// scheduling, and ACP's `initialize` / `session/new` /
|
||||
// `session/load` stall waiting for the lock. SQLite contention
|
||||
// on a healthy host clears in seconds; 60s gives that headroom
|
||||
// while still surfacing genuinely broken transports promptly.
|
||||
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
|
||||
Task { [weak self] in
|
||||
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
||||
try await Task.sleep(nanoseconds: 60 * 1_000_000_000)
|
||||
await self?.timeoutRequest(id: requestId, method: method)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -354,6 +354,19 @@ public final class RichChatViewModel {
|
||||
/// spinner and we don't fan out duplicate page requests.
|
||||
public private(set) var isLoadingEarlier: Bool = false
|
||||
private var nextLocalId = -1
|
||||
|
||||
/// Issue #63: locally-created user messages awaiting state.db
|
||||
/// persistence, keyed by session id. ACP roundtrips Hermes' DB
|
||||
/// write asynchronously, so a user who sends a prompt and
|
||||
/// immediately switches to another session triggers `reset()`
|
||||
/// before Hermes flushes the row — `loadSessionHistory` then reads
|
||||
/// from a DB that doesn't have the message yet, and the bubble
|
||||
/// renders blank or vanishes on return. We hold a per-session
|
||||
/// copy here that survives `reset()` so `loadSessionHistory` can
|
||||
/// re-inject anything still in flight, and clean entries out as
|
||||
/// soon as a matching DB row appears.
|
||||
private var pendingLocalUserMessages: [String: [HermesMessage]] = [:]
|
||||
|
||||
private var streamingAssistantText = ""
|
||||
private var streamingThinkingText = ""
|
||||
private var streamingToolCalls: [HermesToolCall] = []
|
||||
@@ -468,6 +481,12 @@ public final class RichChatViewModel {
|
||||
reasoning: nil
|
||||
)
|
||||
messages.append(message)
|
||||
// Track the local message in the pending-user-messages cache
|
||||
// so a reset/resume cycle on this session before Hermes
|
||||
// persists the row can still re-inject it on return (#63).
|
||||
if let sid = sessionId {
|
||||
pendingLocalUserMessages[sid, default: []].append(message)
|
||||
}
|
||||
// Per-turn stopwatch (v2.5): record the start time only when
|
||||
// we're entering a fresh agent turn. /steer-style mid-run sends
|
||||
// arrive while isAgentWorking is already true; preserve the
|
||||
@@ -972,9 +991,47 @@ public final class RichChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
messages = allMessages
|
||||
// Issue #63 — re-inject any locally-created user messages
|
||||
// we still have on file for this session that haven't yet
|
||||
// shown up in state.db. Covers two paths:
|
||||
// 1. The user just sent a prompt then resumed a different
|
||||
// session before Hermes persisted the row. `reset()` had
|
||||
// cleared `messages` but the per-session pending cache
|
||||
// survived; restore the row here so the bubble doesn't
|
||||
// come back blank.
|
||||
// 2. The DB-resume path on first load — a previously-pending
|
||||
// message Hermes is still mid-write may not appear in
|
||||
// this fetch. We merge it in, and drop it from the cache
|
||||
// as soon as a matching DB row (same content, persisted
|
||||
// id ≥ 0) shows up.
|
||||
let pendingForSession = pendingLocalUserMessages[sessionId] ?? []
|
||||
if pendingForSession.isEmpty {
|
||||
messages = allMessages
|
||||
} else {
|
||||
var merged = allMessages
|
||||
var stillPending: [HermesMessage] = []
|
||||
for local in pendingForSession {
|
||||
let persisted = merged.contains { msg in
|
||||
msg.isUser && msg.id >= 0 && msg.content == local.content
|
||||
}
|
||||
if persisted {
|
||||
continue // DB caught up — drop the local copy
|
||||
}
|
||||
if !merged.contains(where: { $0.id == local.id }) {
|
||||
merged.append(local)
|
||||
}
|
||||
stillPending.append(local)
|
||||
}
|
||||
merged.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
messages = merged
|
||||
if stillPending.isEmpty {
|
||||
pendingLocalUserMessages.removeValue(forKey: sessionId)
|
||||
} else {
|
||||
pendingLocalUserMessages[sessionId] = stillPending
|
||||
}
|
||||
}
|
||||
currentSession = session
|
||||
let minId = allMessages.map(\.id).min() ?? 0
|
||||
let minId = messages.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
// Track the oldest loaded id from THIS session (not the merged
|
||||
// origin) so `loadEarlier()` pages back through the live ACP
|
||||
|
||||
@@ -529,7 +529,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -546,7 +546,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -571,7 +571,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -588,7 +588,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -612,7 +612,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -635,7 +635,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -658,7 +658,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -680,7 +680,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -834,7 +834,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -848,7 +848,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -870,7 +870,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -884,7 +884,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -902,12 +902,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -924,12 +924,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -945,11 +945,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -965,11 +965,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import os
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Posts a "Hermes finished responding" local notification when an
|
||||
/// agent prompt completes while Scarf is not in the foreground
|
||||
/// (issue #64). Users can switch to other work and learn when their
|
||||
/// prompt has landed without polling the chat pane.
|
||||
///
|
||||
/// Authorization is requested lazily on first use. The user's global
|
||||
/// toggle (`scarf.chat.notifyOnComplete`, default on) gates posting,
|
||||
/// and notifications are suppressed when `NSApp.isActive` so users
|
||||
/// who happen to be looking at the chat aren't pinged for nothing.
|
||||
@MainActor
|
||||
final class ChatNotificationService {
|
||||
static let shared = ChatNotificationService()
|
||||
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ChatNotifications")
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
private var hasRequestedAuthorization = false
|
||||
private var isAuthorized = false
|
||||
|
||||
/// AppStorage-shared key for the "notify on completion" toggle.
|
||||
/// Default true; the toggle lives under Settings → Display.
|
||||
static let toggleKey = "scarf.chat.notifyOnComplete"
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Post a local notification announcing prompt completion. Quietly
|
||||
/// no-ops when:
|
||||
/// - The user has disabled the toggle.
|
||||
/// - Scarf is the foreground app (the in-chat status indicator
|
||||
/// is sufficient).
|
||||
/// - The system has not yet granted (or has denied) notification
|
||||
/// authorization.
|
||||
/// `preview` is the first line of the assistant's reply, truncated
|
||||
/// to a sensible length for the lock-screen / notification center.
|
||||
func postPromptCompleted(sessionTitle: String?, preview: String) {
|
||||
let enabled = UserDefaults.standard.object(forKey: Self.toggleKey) as? Bool ?? true
|
||||
guard enabled else { return }
|
||||
|
||||
#if canImport(AppKit)
|
||||
if NSApp?.isActive == true { return }
|
||||
#endif
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let granted = await self.ensureAuthorized()
|
||||
guard granted else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = sessionTitle?.isEmpty == false
|
||||
? "Hermes finished — \(sessionTitle ?? "")"
|
||||
: "Hermes finished responding"
|
||||
content.body = Self.trimmedPreview(preview)
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString,
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
do {
|
||||
try await self.center.add(request)
|
||||
} catch {
|
||||
self.logger.warning("Notification post failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureAuthorized() async -> Bool {
|
||||
if isAuthorized { return true }
|
||||
if hasRequestedAuthorization {
|
||||
// Already asked once this run; respect the current settings.
|
||||
let settings = await center.notificationSettings()
|
||||
isAuthorized = settings.authorizationStatus == .authorized
|
||||
return isAuthorized
|
||||
}
|
||||
hasRequestedAuthorization = true
|
||||
do {
|
||||
let granted = try await center.requestAuthorization(options: [.alert, .sound])
|
||||
isAuthorized = granted
|
||||
return granted
|
||||
} catch {
|
||||
logger.warning("Notification authorization failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// First non-empty line, capped at ~140 chars so the notification
|
||||
/// surface stays readable on every macOS notification style.
|
||||
static func trimmedPreview(_ raw: String) -> String {
|
||||
let firstLine = raw
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.first
|
||||
.map(String.init) ?? raw
|
||||
let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.count <= 140 { return trimmed }
|
||||
let prefix = trimmed.prefix(140).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return prefix + "…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import os
|
||||
import Observation
|
||||
|
||||
/// Per-message text-to-speech for assistant chat replies (issue #66).
|
||||
/// Uses `AVSpeechSynthesizer` with the system voice — no Hermes
|
||||
/// dependency, works offline, picks up the user's macOS Spoken Content
|
||||
/// voice selection automatically.
|
||||
///
|
||||
/// One synthesizer is shared across the app so starting a second
|
||||
/// message's playback automatically interrupts the first. The
|
||||
/// per-message speaker button reads `playingMessageId` to render
|
||||
/// play vs. stop state.
|
||||
///
|
||||
/// The full Hermes-provider TTS pipeline (Edge / ElevenLabs / OpenAI
|
||||
/// / NeuTTS / Piper from Settings → Voice) is deferred to a follow-up
|
||||
/// — wiring per-provider audio fetching, caching, and interruption
|
||||
/// is a much bigger surface than what's needed to give users a
|
||||
/// listen-while-doing-other-work affordance today.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MessageSpeechService: NSObject {
|
||||
static let shared = MessageSpeechService()
|
||||
|
||||
/// The message id currently being spoken, or `nil` when idle.
|
||||
/// Bubbles read this to flip their speaker icon to a stop glyph.
|
||||
private(set) var playingMessageId: Int?
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "MessageSpeech")
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
/// Speak `content`. If a different message is currently playing,
|
||||
/// interrupt it. If the same message is currently playing, this
|
||||
/// stops playback (toggle behavior).
|
||||
func toggle(messageId: Int, content: String) {
|
||||
if playingMessageId == messageId {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
let cleaned = Self.strippedForSpeech(content)
|
||||
guard !cleaned.isEmpty else { return }
|
||||
let utterance = AVSpeechUtterance(string: cleaned)
|
||||
// AVSpeechUtterance honors the user's Spoken Content default
|
||||
// voice when `voice` is `nil`, which is the right behavior:
|
||||
// users who configured a specific macOS voice get it
|
||||
// automatically.
|
||||
utterance.rate = AVSpeechUtteranceDefaultSpeechRate
|
||||
playingMessageId = messageId
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
/// Stop any in-progress speech and clear `playingMessageId`.
|
||||
func stop() {
|
||||
guard playingMessageId != nil else { return }
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
playingMessageId = nil
|
||||
}
|
||||
|
||||
/// Strip markdown control characters before speech so the user
|
||||
/// doesn't hear "asterisk asterisk bold". Code fences and inline
|
||||
/// code are spoken verbatim minus the backticks. Keeps URLs
|
||||
/// readable but drops square-bracket link wrappers.
|
||||
static func strippedForSpeech(_ raw: String) -> String {
|
||||
var out = raw
|
||||
// Fenced code blocks → keep contents
|
||||
out = out.replacingOccurrences(of: "```", with: "")
|
||||
// Inline code → drop backticks
|
||||
out = out.replacingOccurrences(of: "`", with: "")
|
||||
// Bold/italic markers
|
||||
out = out.replacingOccurrences(of: "**", with: "")
|
||||
out = out.replacingOccurrences(of: "__", with: "")
|
||||
// Link syntax: [text](url) → text
|
||||
if let regex = try? NSRegularExpression(
|
||||
pattern: #"\[([^\]]+)\]\([^)]+\)"#,
|
||||
options: []
|
||||
) {
|
||||
let range = NSRange(out.startIndex..., in: out)
|
||||
out = regex.stringByReplacingMatches(
|
||||
in: out,
|
||||
options: [],
|
||||
range: range,
|
||||
withTemplate: "$1"
|
||||
)
|
||||
}
|
||||
return out.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageSpeechService: @preconcurrency AVSpeechSynthesizerDelegate {
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in
|
||||
self.playingMessageId = nil
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in
|
||||
self.playingMessageId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,22 @@ import SwiftUI
|
||||
struct MarkdownContentView: View {
|
||||
let content: String
|
||||
|
||||
/// Chat font scale plumbed from `RichChatView` (issue #68). Defaults
|
||||
/// to 1.0 when this view is used outside the chat surface so other
|
||||
/// callers see the un-scaled rendering.
|
||||
@Environment(\.chatFontScale) private var chatFontScale: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in
|
||||
blockView(block)
|
||||
}
|
||||
}
|
||||
// Paragraphs are rendered as plain `Text(AttributedString)` and
|
||||
// inherit whatever font is set on the enclosing scope. Pin the
|
||||
// scope to the scaled body font so the chat slider actually
|
||||
// moves the visible text.
|
||||
.font(ChatFontScale.body(chatFontScale))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -37,15 +47,19 @@ struct MarkdownContentView: View {
|
||||
// MARK: - Block Views
|
||||
|
||||
private func headingView(level: Int, text: String) -> some View {
|
||||
let font: Font = switch level {
|
||||
case 1: .title.bold()
|
||||
case 2: .title2.bold()
|
||||
case 3: .title3.bold()
|
||||
case 4: .headline
|
||||
default: .subheadline.bold()
|
||||
// Heading sizes scale with `chatFontScale` (issue #68). Bases
|
||||
// mirror the SwiftUI semantic tokens we used previously
|
||||
// (`.title` ≈ 28, `.title2` ≈ 22, `.title3` ≈ 20, `.headline`
|
||||
// ≈ 17, `.subheadline` ≈ 15) so 100% matches today's UI.
|
||||
let baseSize: CGFloat = switch level {
|
||||
case 1: 28
|
||||
case 2: 22
|
||||
case 3: 20
|
||||
case 4: 17
|
||||
default: 15
|
||||
}
|
||||
return Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.font(font)
|
||||
.font(.system(size: baseSize * chatFontScale, weight: .semibold))
|
||||
.textSelection(.enabled)
|
||||
.padding(.top, level <= 2 ? 8 : 4)
|
||||
}
|
||||
@@ -54,11 +68,11 @@ struct MarkdownContentView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let lang = language, !lang.isEmpty {
|
||||
Text(lang)
|
||||
.font(.caption2.bold())
|
||||
.font(ChatFontScale.caption2(chatFontScale).bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(code)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.font(ChatFontScale.codeInline(chatFontScale))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -106,4 +106,74 @@ enum ChatFontScale {
|
||||
let pct = Int((scale * 100).rounded())
|
||||
return "\(pct)%"
|
||||
}
|
||||
|
||||
// MARK: - Scaled font helpers
|
||||
//
|
||||
// ScarfFont's tokens are fixed-point (`Font.system(size: 14, …)`),
|
||||
// so `.environment(\.dynamicTypeSize, …)` doesn't reach them — the
|
||||
// Mac chat slider had no visible effect on bubbles, reasoning,
|
||||
// tool chips, or code blocks (issue #68). These helpers mirror the
|
||||
// ScarfFont base sizes, multiplied by the user's chat scale, and
|
||||
// are used by `RichMessageBubble`, `MarkdownContentView`, and
|
||||
// `CodeBlockView` in place of the static tokens. At scale = 1.0
|
||||
// they're byte-for-byte identical to ScarfFont so the default UI
|
||||
// is unchanged.
|
||||
|
||||
static func body(_ scale: Double) -> Font {
|
||||
.system(size: 14 * scale, weight: .regular)
|
||||
}
|
||||
|
||||
static func bodyEmph(_ scale: Double) -> Font {
|
||||
.system(size: 14 * scale, weight: .medium)
|
||||
}
|
||||
|
||||
static func callout(_ scale: Double) -> Font {
|
||||
.system(size: 15 * scale, weight: .regular)
|
||||
}
|
||||
|
||||
static func caption(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .regular)
|
||||
}
|
||||
|
||||
static func captionStrong(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .semibold)
|
||||
}
|
||||
|
||||
static func caption2(_ scale: Double) -> Font {
|
||||
.system(size: 10 * scale, weight: .medium)
|
||||
}
|
||||
|
||||
static func mono(_ scale: Double) -> Font {
|
||||
.system(size: 13 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
static func monoSmall(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
/// Code-block body — matches `CodeBlockView`'s 12pt mono.
|
||||
static func codeBlock(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
/// Inline code in markdown paragraphs — `.callout` (15pt) mono.
|
||||
static func codeInline(_ scale: Double) -> Font {
|
||||
.system(size: 15 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment plumbing
|
||||
|
||||
private struct ChatFontScaleKey: EnvironmentKey {
|
||||
static let defaultValue: Double = ChatFontScale.default
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Multiplier applied to chat content fonts. Set once on
|
||||
/// `RichChatView`'s root so message bubbles, markdown paragraphs,
|
||||
/// and code blocks scale together. Default 1.0 = today's UI.
|
||||
var chatFontScale: Double {
|
||||
get { self[ChatFontScaleKey.self] }
|
||||
set { self[ChatFontScaleKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +415,21 @@ final class ChatViewModel {
|
||||
)
|
||||
// Re-fetch session from DB to pick up cost/token data Hermes may have written
|
||||
await richChatViewModel.refreshSessionFromDB()
|
||||
// Issue #64 — notify the user that Hermes has
|
||||
// finished if Scarf isn't the foreground app. The
|
||||
// notifier handles the foreground/disabled gating;
|
||||
// we just hand it the latest assistant text and
|
||||
// session title for the body line.
|
||||
if !isSteer {
|
||||
let preview = richChatViewModel.messages
|
||||
.last(where: { $0.isAssistant })?
|
||||
.content ?? ""
|
||||
let title = richChatViewModel.currentSession?.title
|
||||
ChatNotificationService.shared.postPromptCompleted(
|
||||
sessionTitle: title,
|
||||
preview: preview
|
||||
)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
acpStatus = "Cancelled"
|
||||
} catch {
|
||||
|
||||
@@ -44,12 +44,23 @@ struct ChatTranscriptPane: View {
|
||||
if let hint = richChat.transientHint {
|
||||
steeringToast(hint)
|
||||
}
|
||||
// Issue #62: bind composer identity to the active session
|
||||
// ID so SwiftUI rebuilds `RichChatInputBar` (and its
|
||||
// `@State` `text`/`attachments`) when the user switches
|
||||
// conversations. Without this the composer is structurally
|
||||
// identical across sessions and SwiftUI happily reuses the
|
||||
// instance, leaking the unsent draft into the new session.
|
||||
// A stable fallback id covers the brief "no session
|
||||
// selected" window — using `UUID()` here would mint a
|
||||
// fresh value per render and trash the composer on every
|
||||
// body re-eval.
|
||||
RichChatInputBar(
|
||||
onSend: onSend,
|
||||
isEnabled: isEnabled,
|
||||
commands: richChat.availableCommands,
|
||||
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
|
||||
)
|
||||
.id(richChat.sessionId ?? "scarf.chat.no-session")
|
||||
}
|
||||
.background(ScarfColor.backgroundPrimary)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@ struct CodeBlockView: View {
|
||||
|
||||
@State private var copied = false
|
||||
|
||||
/// Chat font scale plumbed from `RichChatView` (issue #68). Defaults
|
||||
/// to 1.0 outside the chat surface.
|
||||
@Environment(\.chatFontScale) private var chatFontScale: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let language, !language.isEmpty {
|
||||
HStack {
|
||||
Text(language)
|
||||
.font(.caption2.bold())
|
||||
.font(ChatFontScale.caption2(chatFontScale).bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
copyButton
|
||||
@@ -31,7 +35,7 @@ struct CodeBlockView: View {
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(code)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.font(ChatFontScale.codeBlock(chatFontScale))
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)))
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
@@ -108,16 +108,33 @@ struct RichChatInputBar: View {
|
||||
)
|
||||
)
|
||||
.overlay(alignment: .topLeading) {
|
||||
if text.isEmpty {
|
||||
Text(supportsImagePrompts
|
||||
? "Message Hermes… / for commands · drag images to attach"
|
||||
: "Message Hermes… / for commands")
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Placeholder ghosting (#65): TextEditor's
|
||||
// NSTextView updates the visible glyphs a frame
|
||||
// before the SwiftUI binding propagates, so a
|
||||
// bare `if text.isEmpty` overlay renders the
|
||||
// translucent placeholder text on top of the
|
||||
// just-typed character — visible as a "behind
|
||||
// or around" ghost. Two mitigations:
|
||||
//
|
||||
// 1. Pin an opaque rectangle behind the
|
||||
// placeholder text. During any single-
|
||||
// frame lag the user sees a clean
|
||||
// placeholder, never layered glyphs.
|
||||
// 2. Use `.opacity(...)` instead of an `if`.
|
||||
// Keeps the view tree stable per
|
||||
// keystroke (removes the per-keystroke
|
||||
// view-mutation churn the composer was
|
||||
// already paying for).
|
||||
Text(supportsImagePrompts
|
||||
? "Message Hermes… / for commands · drag images to attach"
|
||||
: "Message Hermes… / for commands")
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(ScarfColor.backgroundSecondary)
|
||||
.opacity(text.isEmpty ? 1 : 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Drag-drop image attachments. Receives both file URLs
|
||||
// (from Finder) and raw image bitmap data (from
|
||||
@@ -200,7 +217,12 @@ struct RichChatInputBar: View {
|
||||
.onChange(of: text) { _, _ in
|
||||
updateMenuState()
|
||||
}
|
||||
.onChange(of: commands.map(\.id)) { _, _ in
|
||||
// Watch `commands.count` rather than `commands.map(\.id)` — the
|
||||
// mapped form allocates a fresh `[String]` on every body
|
||||
// re-eval (i.e. every keystroke), which is wasted work even
|
||||
// when the array compares equal. The count proxy fires when
|
||||
// the agent advertises new commands.
|
||||
.onChange(of: commands.count) { _, _ in
|
||||
updateMenuState()
|
||||
}
|
||||
.sheet(isPresented: $showCompressSheet) {
|
||||
@@ -358,17 +380,37 @@ struct RichChatInputBar: View {
|
||||
|
||||
private func updateMenuState() {
|
||||
let shouldShow = shouldShowMenu
|
||||
|
||||
// Common case: user is composing normal text and the menu is
|
||||
// already hidden. Skip the filter computation + state writes
|
||||
// entirely so onChange stays cheap. Without this guard typing
|
||||
// recomputes `filteredCommands` on every keystroke even when
|
||||
// the menu can't possibly appear.
|
||||
guard shouldShow || showMenu else { return }
|
||||
|
||||
// Compute desired selection, then only write what changed.
|
||||
// SwiftUI emits "onChange action tried to update multiple
|
||||
// times per frame" when an onChange handler mutates more than
|
||||
// one piece of state per frame; the warning correlates with
|
||||
// unusable typing lag because each redundant write triggers
|
||||
// another body re-eval.
|
||||
let count = filteredCommands.count
|
||||
let newSelection: Int
|
||||
if count == 0 {
|
||||
newSelection = 0
|
||||
} else if selectedIndex >= count {
|
||||
newSelection = count - 1
|
||||
} else if selectedIndex < 0 {
|
||||
newSelection = 0
|
||||
} else {
|
||||
newSelection = selectedIndex
|
||||
}
|
||||
|
||||
if shouldShow != showMenu {
|
||||
showMenu = shouldShow
|
||||
}
|
||||
// Re-clamp selection whenever the filtered list may have shrunk.
|
||||
let count = filteredCommands.count
|
||||
if count == 0 {
|
||||
selectedIndex = 0
|
||||
} else if selectedIndex >= count {
|
||||
selectedIndex = count - 1
|
||||
} else if selectedIndex < 0 {
|
||||
selectedIndex = 0
|
||||
if newSelection != selectedIndex {
|
||||
selectedIndex = newSelection
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@ struct RichChatView: View {
|
||||
}
|
||||
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
||||
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale))
|
||||
// ScarfFont tokens are fixed-point so dynamicTypeSize alone
|
||||
// doesn't move bubble / markdown / code-block text. Plumb the
|
||||
// raw scale via `\.chatFontScale` so chat content views can
|
||||
// read it and scale their explicit sizes too (issue #68).
|
||||
.environment(\.chatFontScale, fontScale)
|
||||
// Animate side-pane shows/hides so the transcript reflows
|
||||
// smoothly rather than snapping. ~180ms feels responsive
|
||||
// without being jarring.
|
||||
|
||||
@@ -14,6 +14,11 @@ struct RichMessageBubble: View, Equatable {
|
||||
|
||||
@Environment(ChatViewModel.self) private var chatViewModel
|
||||
|
||||
/// Chat-only font scale set on `RichChatView`. Chat content uses
|
||||
/// these multiplied sizes (issue #68); other surfaces still see
|
||||
/// the static ScarfFont tokens at scale = 1.0.
|
||||
@Environment(\.chatFontScale) private var chatFontScale: Double
|
||||
|
||||
/// Scarf-local chat density preferences (issues #47 / #48). All
|
||||
/// three default to today's UI. Read here so the reasoning + tool-
|
||||
/// call switches don't have to thread the values through every
|
||||
@@ -68,7 +73,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
HStack {
|
||||
Spacer(minLength: 80)
|
||||
Text(message.content)
|
||||
.scarfStyle(.body)
|
||||
.font(ChatFontScale.body(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.onAccent)
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 14)
|
||||
@@ -91,7 +96,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
Text(time, style: .time)
|
||||
.font(ScarfFont.caption2)
|
||||
.font(ChatFontScale.caption2(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
@@ -183,7 +188,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
private var reasoningDisclosure: some View {
|
||||
DisclosureGroup {
|
||||
Text(message.preferredReasoning ?? "")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.italic()
|
||||
.textSelection(.enabled)
|
||||
@@ -194,11 +199,11 @@ struct RichMessageBubble: View, Equatable {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 11))
|
||||
Text("REASONING")
|
||||
.scarfStyle(.captionStrong)
|
||||
.font(ChatFontScale.captionStrong(chatFontScale))
|
||||
.tracking(0.5)
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("· \(tokens) tok")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
}
|
||||
@@ -222,7 +227,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
Text(message.preferredReasoning ?? "")
|
||||
.font(ScarfFont.caption)
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
.italic()
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.textSelection(.enabled)
|
||||
@@ -281,7 +286,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(color)
|
||||
Text(call.functionName)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.lineLimit(1)
|
||||
@@ -341,28 +346,70 @@ struct RichMessageBubble: View, Equatable {
|
||||
HStack(spacing: 8) {
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("\(tokens) tok")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
}
|
||||
if let reason = message.finishReason, !reason.isEmpty {
|
||||
Text("·")
|
||||
Text(reason)
|
||||
.scarfStyle(.caption)
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
}
|
||||
if let time = message.timestamp {
|
||||
Text("·")
|
||||
Text(time, style: .time)
|
||||
.scarfStyle(.caption)
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
}
|
||||
if let seconds = turnDuration {
|
||||
Text("·")
|
||||
Text(RichChatViewModel.formatTurnDuration(seconds))
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.help("Wall-clock duration of this turn")
|
||||
}
|
||||
// Per-message TTS playback toggle (issue #66). Only on
|
||||
// settled assistant bubbles — streaming bubble (id == 0)
|
||||
// would speak partial text. Empty content has nothing to
|
||||
// speak.
|
||||
if message.id != 0, !message.content.isEmpty {
|
||||
speakButton
|
||||
}
|
||||
}
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `Equatable` short-circuit — the parent only needs to pass
|
||||
/// stable id + content; this view re-renders on its own when
|
||||
/// playback state flips.
|
||||
private var speakButton: some View {
|
||||
SpeakMessageButton(messageId: message.id, content: message.content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stand-alone speaker button so the `MessageSpeechService`
|
||||
/// observation doesn't get short-circuited by `RichMessageBubble`'s
|
||||
/// `Equatable`. Only the button re-renders when playback flips —
|
||||
/// the bubble itself stays optimised.
|
||||
private struct SpeakMessageButton: View {
|
||||
let messageId: Int
|
||||
let content: String
|
||||
|
||||
@State private var speech = MessageSpeechService.shared
|
||||
|
||||
var body: some View {
|
||||
let isPlaying = speech.playingMessageId == messageId
|
||||
Button {
|
||||
speech.toggle(messageId: messageId, content: content)
|
||||
} label: {
|
||||
Image(systemName: isPlaying ? "stop.circle.fill" : "speaker.wave.2")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(isPlaying ? ScarfColor.accent : ScarfColor.foregroundFaint)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(isPlaying ? "Stop speaking" : "Read this reply aloud")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Block Parsing
|
||||
|
||||
@@ -22,6 +22,10 @@ struct DisplayTab: View {
|
||||
private var showSessionsList: Bool = true
|
||||
@AppStorage(ChatDensityKeys.showInspector)
|
||||
private var showInspector: Bool = true
|
||||
/// Background-completion notifications (issue #64). Default on so
|
||||
/// users new to Scarf get the async-aware UX out of the box.
|
||||
@AppStorage(ChatNotificationService.toggleKey)
|
||||
private var notifyOnComplete: Bool = true
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") {
|
||||
@@ -64,6 +68,7 @@ struct DisplayTab: View {
|
||||
|
||||
SettingsSection(title: "Feedback", icon: "bell") {
|
||||
ToggleRow(label: "Bell on Complete", isOn: viewModel.config.display.bellOnComplete) { viewModel.setBellOnComplete($0) }
|
||||
ToggleRow(label: "Notify when Hermes finishes", isOn: notifyOnComplete) { notifyOnComplete = $0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user