perf(chat,activity,transport): skeleton-then-hydrate loaders + SSH cancellation propagation

Major perf overhaul for slow-remote contexts. Chats and Activity now
render in <2s instead of timing out at 30s; abandoned SSH work is
killed within 100ms instead of pinning a ControlMaster session.

* Skeleton-then-hydrate chat loader. New `fetchSkeletonMessages`
  selects user+assistant rows only (skips role='tool', NULLs
  tool_calls + reasoning at the SQL level). Wire payload bounded by
  conversational text alone — sub-second on remote regardless of
  underlying tool result blob sizes. Background `startToolHydration`
  pages through `hydrateAssistantToolCalls` (5-id batches) to splice
  tool calls in. Tool-result CONTENT is opt-in via Settings → Display
  → "Load tool results in past chats" (default off); inspector pane
  lazy-fetches per-result via `fetchToolResult(callId:)` on expand.

* Skeleton-then-hydrate Activity loader. New
  `fetchRecentToolCallSkeleton` returns metadata-only rows in ~3 KB
  for 50 entries; placeholder ActivityRows render immediately, real
  per-call entries swap in as paged hydration completes. Loading
  pill in the page header, orange transport-error banner replaces
  the pre-fix silent empty state.

* SSH cancellation propagation. `Task.detached` and unstructured
  `Task<...> { ... }` don't inherit cancellation from awaiting
  parents — without bridging, killing a Swift Task left the ssh
  subprocess running for the full 30s deadline, pinning a remote
  sqlite query and a ControlMaster session. Wired
  `withTaskCancellationHandler` through `SSHScriptRunner.run` and
  `RemoteSQLiteBackend.query`; cancellation now reaches `Process`
  within ~100ms. New `ssh.cancelled` ScarfMon event.

* L1 single-id retry. When a 5-id `hydrateAssistantToolCalls` page
  trips the 30s timeout (one row carries an oversized tool_calls
  blob — long Edit args, big diffs), fall back to single-id queries
  to isolate the whale. Non-whale rows in the same batch hydrate
  normally; whale row stays bare. New `mac.hydrateToolCalls.singleTimeout`
  event tracks how often the recovery fires.

* L2 in-flight coalescing for `loadRecentSessions`. File-watcher
  deltas during streaming used to stack 2-3 parallel sessions-list
  reload tasks; subsequent callers now await the active one. New
  `mac.loadRecentSessions.coalesced` event tracks dedup hits.

* Loading-state UX hardening. New `isStartingSession` flag flips
  synchronously on user click so the chat sidebar greys + disables
  immediately instead of waiting for `client.start()` to return
  (5-7s on remote). Phase-typed status: "Spawning hermes acp…" →
  "Authenticating…" → "Loading session…" → "Loading history…" →
  "Ready". `ChatSessionListPane` overlays a ProgressView showing
  the current phase.

* Partial-result detection. `fetchMessagesOutcome` distinguishes a
  transport failure from a genuine empty result; `loadSessionHistory`
  surfaces "Couldn't load full chat history — connection timed out"
  through the existing acpError triplet so the user sees what
  happened instead of a silent empty transcript.

* Model/provider mismatch banner. `ModelPreflight.detectMismatch`
  recognizes when `model.default` carries a `<provider>/...` prefix
  that disagrees with `model.provider` (e.g. anthropic prefix +
  nous active provider after switching OAuth via Credential Pools).
  Banner offers one-click fix in either direction. Companion: ACP
  error classifier recognizes `model_not_found` / `404 messages`
  and surfaces "Hermes pins each session to its original model —
  start a new chat" so the pinned-model failure mode has a clear
  recovery path.

* OAuth-completion provider swap prompt. After successful OAuth in
  Credential Pools, if the just-authed provider differs from
  `model.provider` in config.yaml, surface "Switch active provider
  to <name>?" with [Switch] / [Keep current] instead of
  auto-dismissing.

All 302 ScarfCore tests pass. New ScarfMon events documented in the
Performance-Monitoring wiki page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-05 19:43:53 +02:00
parent 9f2e2ecfcd
commit 09e33b2999
17 changed files with 1461 additions and 87 deletions
@@ -730,6 +730,22 @@ public enum ACPErrorHint {
|| haystack.localizedCaseInsensitiveContains("429") {
return Classification(hint: "Your AI provider returned a rate-limit error. Try again in a moment.")
}
// Model-availability failure. Hermes pins each session to the
// model that opened it, so resuming an old session whose model
// is no longer available (provider deprecation, OAuth swapped
// to a different provider, model name changed) returns a 404
// / model_not_found from the upstream provider surfaced as
// an opaque "-32603 Internal error" in chat. v2.8 surfaces a
// clear "session is pinned" hint with the recovery path.
if haystack.localizedCaseInsensitiveContains("model_not_found")
|| haystack.localizedCaseInsensitiveContains("model not found")
|| haystack.localizedCaseInsensitiveContains("invalid_model")
|| haystack.localizedCaseInsensitiveContains("model is not available")
|| haystack.localizedCaseInsensitiveContains("unknown model")
|| (haystack.contains("404") && (haystack.localizedCaseInsensitiveContains("model")
|| haystack.localizedCaseInsensitiveContains("messages"))) {
return Classification(hint: "This session was created with a model the provider no longer offers. Hermes pins each session to its original model — start a new chat to use your current model, or run `hermes sessions clone` in Terminal to copy this conversation onto the new model.")
}
return nil
}
@@ -64,6 +64,28 @@ public struct HermesMessage: Identifiable, Sendable {
if let rc = reasoningContent, !rc.isEmpty { return rc }
return reasoning
}
/// Return a copy of this message with `toolCalls` replaced. Used
/// by the v2.8 two-phase chat loader: skeleton fetch returns
/// messages with empty `toolCalls`; the background hydrate splices
/// the parsed values in without re-fetching the conversational
/// columns.
public func withToolCalls(_ newCalls: [HermesToolCall]) -> HermesMessage {
HermesMessage(
id: id,
sessionId: sessionId,
role: role,
content: content,
toolCallId: toolCallId,
toolCalls: newCalls,
toolName: toolName,
timestamp: timestamp,
tokenCount: tokenCount,
finishReason: finishReason,
reasoning: reasoning,
reasoningContent: reasoningContent
)
}
}
public struct HermesToolCall: Identifiable, Sendable, Codable {
@@ -210,3 +232,23 @@ public enum ToolKind: String, Sendable, CaseIterable {
}
}
}
/// Outcome of a `fetchMessagesOutcome` call. `transportError` is non-nil
/// only when the underlying SSH/SQLite call hit a transport-layer
/// failure (timeout, ControlMaster drop) distinguishes a genuine
/// empty session from a silent partial-load. The chat resume path uses
/// it to surface a "couldn't load full history" banner.
public struct MessageFetchOutcome: Sendable {
public let messages: [HermesMessage]
public let transportError: String?
public init(messages: [HermesMessage], transportError: String?) {
self.messages = messages
self.transportError = transportError
}
/// True when the fetch tripped a transport failure. Distinct from
/// `messages.isEmpty` an empty session is a successful zero-row
/// result, while a transport error is "we don't know what's there."
public var didTimeOut: Bool { transportError != nil }
}
@@ -182,7 +182,10 @@ public actor RemoteSQLiteBackend: HermesQueryBackend {
// the dedup actually fires in the wild.
if let existing = inFlightQueries[inlined] {
ScarfMon.event(.sqlite, "query.coalesced", count: 1)
return try await existing.value
return try await withTaskCancellationHandler(
operation: { try await existing.value },
onCancel: { existing.cancel() }
)
}
let task = Task<[Row], Error> { [self] in
try await ScarfMon.measureAsync(.sqlite, "query") {
@@ -208,7 +211,20 @@ public actor RemoteSQLiteBackend: HermesQueryBackend {
}
inFlightQueries[inlined] = task
defer { inFlightQueries[inlined] = nil }
return try await task.value
// v2.8 propagate parent task cancellation INTO the
// unstructured `task`. `Task<...>{ ... }` doesn't inherit
// cancellation from the awaiting context, so without this a
// cancelled chat-hydration / dashboard-refresh would keep
// the ssh subprocess alive for the full 30s queryTimeout
// pinning a remote sqlite query and a ControlMaster
// session slot. With the bridge, the inner task's awaits
// see a cancelled parent and `SSHScriptRunner.run`'s own
// cancellation handler (v2.8) kills the ssh process inside
// the next 100ms poll.
return try await withTaskCancellationHandler(
operation: { try await task.value },
onCancel: { task.cancel() }
)
}
public func queryBatch(_ statements: [(sql: String, params: [SQLValue])]) async throws -> [[Row]] {
@@ -167,6 +167,33 @@ public actor HermesDataService {
return cols
}
/// Skeleton column set for the v2.8 two-phase chat loader. Returns
/// EVERYTHING needed to render a user-or-assistant bubble id,
/// role, content, timestamp, token_count, finish_reason but
/// hard-NULLs the heavy content-bearing columns (`tool_calls`,
/// `reasoning`, `reasoning_content`) at the SQL level so the wire
/// payload is bounded by the conversational text alone. A
/// 30-message session with multi-page tool result blobs that
/// previously timed out the 30s SSH budget reduces here to a few
/// KB. The chat appears in seconds; tool details fill in via
/// `hydrateAssistantToolCalls(...)` and `hydrateToolResults(...)`
/// in the background.
///
/// The schema-shape match against `messageFromRow` is exact
/// same column ordering as `messageColumnsLight`. The literal
/// NULLs let messageFromRow's defaulting paths fill empty
/// arrays / nils without any callee changes.
private var messageColumnsSkeleton: String {
var cols = """
id, session_id, role, content, tool_call_id, NULL AS tool_calls,
tool_name, timestamp, token_count, finish_reason
"""
if hasV07Schema {
cols += ", NULL AS reasoning"
}
return cols
}
// MARK: - Session Queries
public func fetchSessions(limit: Int = QueryDefaults.sessionLimit) async -> [HermesSession] {
@@ -213,6 +240,22 @@ public actor HermesDataService {
limit: Int,
before: Int? = nil
) async -> [HermesMessage] {
await fetchMessagesOutcome(sessionId: sessionId, limit: limit, before: before).messages
}
/// Outcome-returning variant of `fetchMessages`. Distinguishes a
/// successful empty result (genuinely zero rows) from a transport
/// failure (SSH timeout, ControlMaster drop) so callers can decide
/// whether to silently render the rows or surface a "couldn't load
/// full history" banner. The plain `fetchMessages` shape stays so
/// background paths (reconcile, polling, sessions detail) keep
/// their silent-best-effort behavior only the chat-resume path
/// asks for the outcome.
public func fetchMessagesOutcome(
sessionId: String,
limit: Int,
before: Int? = nil
) async -> MessageFetchOutcome {
await ScarfMon.measureAsync(.sessionLoad, "mac.fetchMessages") {
// Use the lite column set excludes reasoning_content which
// can be 20+ KB per message on thinking-model sessions and
@@ -235,8 +278,208 @@ public actor HermesDataService {
// is DESC for the LIMIT to bite the newest rows, so reverse.
let messages = rows.map { messageFromRow($0) }.reversed() as [HermesMessage]
ScarfMon.event(.sessionLoad, "mac.fetchMessages.rows", count: messages.count)
return MessageFetchOutcome(messages: messages, transportError: nil)
} catch let BackendError.transport(reason) {
// SSH timeout / ControlMaster drop / connection blip. The
// chat resume path renders the partial-result banner so
// the user sees "couldn't load full history" instead of
// an empty transcript. v2.8.
ScarfMon.event(.sessionLoad, "mac.fetchMessages.transportError", count: 1)
return MessageFetchOutcome(messages: [], transportError: reason)
} catch {
return MessageFetchOutcome(messages: [], transportError: nil)
}
}
}
/// Phase 1 of the v2.8 two-phase chat loader. Fetches user +
/// assistant rows ONLY (skips `role='tool'` entirely) with
/// `tool_calls`, `reasoning`, and `reasoning_content` hard-NULLed
/// at the SQL level. The wire payload is bounded by the
/// conversational text alone a 30-message session whose tool
/// results blob ran 100KB+ per row drops from a 30s timeout to a
/// few hundred ms. The chat is rendered immediately; tool details
/// fill in via `hydrateAssistantToolCalls` and `hydrateToolResults`
/// in background tasks.
///
/// Returns the same `MessageFetchOutcome` shape as the full
/// `fetchMessagesOutcome` so the caller can distinguish a
/// transport failure (banner-worthy) from a genuinely empty
/// session.
public func fetchSkeletonMessages(
sessionId: String,
limit: Int,
before: Int? = nil
) async -> MessageFetchOutcome {
await ScarfMon.measureAsync(.sessionLoad, "mac.fetchSkeletonMessages") {
let sql: String
let params: [SQLValue]
if let before {
sql = "SELECT \(messageColumnsSkeleton) FROM messages WHERE session_id = ? AND role IN ('user','assistant') AND id < ? ORDER BY id DESC LIMIT ?"
params = [.text(sessionId), .integer(Int64(before)), .integer(Int64(limit))]
} else {
sql = "SELECT \(messageColumnsSkeleton) FROM messages WHERE session_id = ? AND role IN ('user','assistant') ORDER BY id DESC LIMIT ?"
params = [.text(sessionId), .integer(Int64(limit))]
}
do {
let rows = try await backend.query(sql, params: params)
let messages = rows.map { messageFromRow($0) }.reversed() as [HermesMessage]
ScarfMon.event(.sessionLoad, "mac.fetchSkeletonMessages.rows", count: messages.count)
return MessageFetchOutcome(messages: messages, transportError: nil)
} catch let BackendError.transport(reason) {
ScarfMon.event(.sessionLoad, "mac.fetchSkeletonMessages.transportError", count: 1)
return MessageFetchOutcome(messages: [], transportError: reason)
} catch {
return MessageFetchOutcome(messages: [], transportError: nil)
}
}
}
/// Phase 2a of the two-phase loader. Hydrate `tool_calls` for
/// assistant rows in `messageIds`. Returns parsed `[HermesToolCall]`
/// keyed by message id caller splices into the existing
/// `HermesMessage` values to bring the tool cards online without
/// a full re-fetch. Empty / missing `tool_calls` rows are omitted
/// from the result.
///
/// **Paged into 5-id batches.** A single 25-id IN-clause query
/// returning 10 large `tool_calls` JSON blobs (a long Edit's args
/// can be 100KB+ on its own) tripped the 30s SSH timeout in
/// 2026-05-05 dogfooding. Pages run sequentially so the worst
/// case is one slow batch instead of one slow whole-fetch and
/// the user sees tool cards trickle in newest-first as each page
/// completes, since the caller drives the splice + UI rebuild.
public func hydrateAssistantToolCalls(
messageIds: [Int]
) async -> [Int: [HermesToolCall]] {
guard !messageIds.isEmpty else { return [:] }
return await ScarfMon.measureAsync(.sessionLoad, "mac.hydrateToolCalls") {
// Page newest-first: callers pass ids in chronological
// order from the skeleton fetch; the tail of that array is
// the most-recent assistant turn, which is the one the
// user is most likely looking at.
let pageSize = 5
let pages = stride(from: 0, to: messageIds.count, by: pageSize).map {
Array(messageIds[$0..<min($0 + pageSize, messageIds.count)])
}.reversed()
var out: [Int: [HermesToolCall]] = [:]
for page in pages {
// Bail immediately if the parent task got cancelled
// (chat switch, view dismiss). v2.8 without this
// explicit check the catch-all below would swallow
// `CancellationError` and keep firing batches against
// the abandoned session, defeating the whole point of
// the cancellation propagation chain we wired through
// SSHScriptRunner + RemoteSQLiteBackend.
if Task.isCancelled {
ScarfMon.event(.sessionLoad, "mac.hydrateToolCalls.cancelled", count: 1)
return out
}
let placeholders = Array(repeating: "?", count: page.count).joined(separator: ",")
let sql = "SELECT id, tool_calls FROM messages WHERE id IN (\(placeholders)) AND tool_calls IS NOT NULL AND tool_calls != '' AND tool_calls != '[]'"
let params: [SQLValue] = page.map { .integer(Int64($0)) }
do {
let rows = try await backend.query(sql, params: params)
for row in rows {
let id = row.int(at: 0)
let json = row.optionalString(at: 1)
let parsed = parseToolCalls(json)
if !parsed.isEmpty {
out[id] = parsed
}
}
} catch is CancellationError {
// Parent cancelled mid-page return what we have
// and stop. Distinct from the transport-timeout
// path below, which is a per-page failure.
ScarfMon.event(.sessionLoad, "mac.hydrateToolCalls.cancelled", count: 1)
return out
} catch let BackendError.transport(reason) {
// One page tripped the 30s timeout at least one
// id in this batch carries an oversized tool_calls
// blob (multi-hundred-KB Edit args, big diffs).
// L1 (v2.8) fall back to single-id queries to
// isolate the whale. The non-whale ids in the same
// batch hydrate normally; only the actual offender
// stays bare. Adds at most `page.count` extra
// round-trips on a timeout, but each is bounded by
// its own queryTimeout so we won't compound the
// wait beyond ~30s per id.
ScarfMon.event(.sessionLoad, "mac.hydrateToolCalls.pageTimeout", count: 1)
Self.logger.warning("hydrateToolCalls page timed out (\(page.count) ids), falling back to single-id retry: \(reason, privacy: .public)")
for id in page {
if Task.isCancelled { return out }
do {
let singleSQL = "SELECT id, tool_calls FROM messages WHERE id = ? AND tool_calls IS NOT NULL AND tool_calls != '' AND tool_calls != '[]'"
let rows = try await backend.query(singleSQL, params: [.integer(Int64(id))])
for row in rows {
let rid = row.int(at: 0)
let json = row.optionalString(at: 1)
let parsed = parseToolCalls(json)
if !parsed.isEmpty {
out[rid] = parsed
}
}
} catch is CancellationError {
return out
} catch let BackendError.transport(singleReason) {
// This is the whale. Skip it the user
// can still expand the assistant message;
// only the per-call cards on this row
// stay bare. Recorded so future captures
// show how often we hit a single-id
// timeout vs. a batch timeout.
ScarfMon.event(.sessionLoad, "mac.hydrateToolCalls.singleTimeout", count: 1)
Self.logger.warning("hydrateToolCalls single-id retry timed out (id=\(id)): \(singleReason, privacy: .public)")
continue
} catch {
Self.logger.warning("hydrateToolCalls single-id retry failed (id=\(id)): \(error.localizedDescription, privacy: .public)")
continue
}
}
continue
} catch {
Self.logger.warning("hydrateAssistantToolCalls page failed: \(error.localizedDescription, privacy: .public)")
continue
}
}
ScarfMon.event(.sessionLoad, "mac.hydrateToolCalls.rows", count: out.count)
return out
}
}
/// Phase 2b of the two-phase loader. Fetch `role='tool'` rows in
/// `[minId, maxId]` for `sessionId`. These are the heavy ones
/// a single tool result can carry a multi-page text blob. The
/// caller pages through the id range in chunks (newest-first) so
/// each round-trip is bounded.
///
/// Returns `[HermesMessage]` in DESC order (newest first) the
/// caller can splice into the live `messages` array. Transport
/// failures fall through to an empty result with a warning logged
/// the chat is already usable without tool results, so this is
/// best-effort rather than banner-worthy.
public func fetchToolResultsInRange(
sessionId: String,
minId: Int,
maxId: Int,
limit: Int = 50
) async -> [HermesMessage] {
await ScarfMon.measureAsync(.sessionLoad, "mac.hydrateToolResults") {
let sql = "SELECT \(messageColumnsLight) FROM messages WHERE session_id = ? AND role = 'tool' AND id >= ? AND id <= ? ORDER BY id DESC LIMIT ?"
let params: [SQLValue] = [
.text(sessionId),
.integer(Int64(minId)),
.integer(Int64(maxId)),
.integer(Int64(limit))
]
do {
let rows = try await backend.query(sql, params: params)
let messages = rows.map { messageFromRow($0) }
ScarfMon.event(.sessionLoad, "mac.hydrateToolResults.rows", count: messages.count)
return messages
} catch {
Self.logger.warning("fetchToolResultsInRange failed: \(error.localizedDescription, privacy: .public)")
return []
}
}
@@ -309,18 +552,87 @@ public actor HermesDataService {
}
public func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) async -> [HermesMessage] {
let sql = """
SELECT \(messageColumns)
FROM messages
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
ORDER BY timestamp DESC
LIMIT ?
"""
do {
let rows = try await backend.query(sql, params: [.integer(Int64(limit))])
return rows.map { messageFromRow($0) }
} catch {
return []
await fetchRecentToolCallsOutcome(limit: limit).messages
}
/// Phase L (v2.8) skeleton fetch for the Activity feed. Returns
/// metadata-only rows for tool-call-bearing messages `id`,
/// `session_id`, `role`, `timestamp`. Everything fat (`content`,
/// `tool_calls` JSON, `reasoning`, `reasoning_content`) is NULLed
/// at the SQL level. The wire payload for 50 rows drops to
/// ~3-5 KB regardless of how big the underlying tool_calls blobs
/// are. `ActivityViewModel` renders placeholder "Loading tool
/// calls" rows from the skeleton, then pages through
/// `hydrateAssistantToolCalls` to fill the real rows in.
///
/// Mirrors `fetchSkeletonMessages` for the chat path same
/// philosophy: get something on screen fast, hydrate the heavy
/// columns in the background.
public func fetchRecentToolCallSkeleton(
limit: Int = QueryDefaults.toolCallLimit
) async -> MessageFetchOutcome {
await ScarfMon.measureAsync(.sessionLoad, "mac.fetchToolCallSkeleton") {
// Project everything as NULL except the four columns
// ActivityEntry actually needs to render a placeholder
// row. The WHERE clause still hits the tool_calls
// column so SQLite reads it from disk but it never
// travels back over SSH.
let cols: String
if hasV07Schema {
cols = "id, session_id, role, NULL AS content, NULL AS tool_call_id, NULL AS tool_calls, NULL AS tool_name, timestamp, NULL AS token_count, NULL AS finish_reason, NULL AS reasoning"
} else {
cols = "id, session_id, role, NULL AS content, NULL AS tool_call_id, NULL AS tool_calls, NULL AS tool_name, timestamp, NULL AS token_count, NULL AS finish_reason"
}
let sql = """
SELECT \(cols)
FROM messages
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
ORDER BY timestamp DESC
LIMIT ?
"""
do {
let rows = try await backend.query(sql, params: [.integer(Int64(limit))])
let messages = rows.map { messageFromRow($0) }
ScarfMon.event(.sessionLoad, "mac.fetchToolCallSkeleton.rows", count: messages.count)
return MessageFetchOutcome(messages: messages, transportError: nil)
} catch let BackendError.transport(reason) {
ScarfMon.event(.sessionLoad, "mac.fetchToolCallSkeleton.transportError", count: 1)
Self.logger.warning("fetchRecentToolCallSkeleton transport error: \(reason, privacy: .public)")
return MessageFetchOutcome(messages: [], transportError: reason)
} catch {
Self.logger.warning("fetchRecentToolCallSkeleton failed: \(error.localizedDescription, privacy: .public)")
return MessageFetchOutcome(messages: [], transportError: nil)
}
}
}
/// Outcome variant of `fetchRecentToolCalls` distinguishes a
/// genuinely empty result from a transport failure so Activity can
/// surface a banner instead of the empty-state. v2.8.
public func fetchRecentToolCallsOutcome(
limit: Int = QueryDefaults.toolCallLimit
) async -> MessageFetchOutcome {
await ScarfMon.measureAsync(.sessionLoad, "mac.fetchRecentToolCalls") {
let sql = """
SELECT \(messageColumnsLight)
FROM messages
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
ORDER BY timestamp DESC
LIMIT ?
"""
do {
let rows = try await backend.query(sql, params: [.integer(Int64(limit))])
let messages = rows.map { messageFromRow($0) }
ScarfMon.event(.sessionLoad, "mac.fetchRecentToolCalls.rows", count: messages.count)
return MessageFetchOutcome(messages: messages, transportError: nil)
} catch let BackendError.transport(reason) {
ScarfMon.event(.sessionLoad, "mac.fetchRecentToolCalls.transportError", count: 1)
Self.logger.warning("fetchRecentToolCalls transport error: \(reason, privacy: .public)")
return MessageFetchOutcome(messages: [], transportError: reason)
} catch {
Self.logger.warning("fetchRecentToolCalls failed: \(error.localizedDescription, privacy: .public)")
return MessageFetchOutcome(messages: [], transportError: nil)
}
}
}
@@ -53,4 +53,45 @@ public enum ModelPreflight: Sendable {
let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased()
return trimmed.isEmpty || trimmed == "unknown"
}
/// Result of a `model.default` `model.provider` mismatch check.
/// Captures the case where `model.default` carries a `<provider>/...`
/// prefix that doesn't match the standalone `model.provider` key
/// observed in 2026-05-05 dogfooding when switching OAuth providers
/// via Credential Pools left the prior provider's model name
/// stranded in `model.default`. Hermes can't reconcile the two and
/// chats die with an opaque `-32603 Internal error` at first prompt.
public struct Mismatch: Sendable, Equatable {
/// The provider prefix found in `model.default` (e.g. `"anthropic"`).
public let prefixProvider: String
/// The standalone `model.provider` value (e.g. `"nous"`).
public let activeProvider: String
/// The full `model.default` string as configured.
public let modelDefault: String
/// The bare model id (with the prefix stripped) what the user
/// would see if Scarf rewrites `model.default` for them.
public let bareModel: String
}
/// Detect a `model.default` / `model.provider` mismatch. Returns
/// `nil` when there's no provider prefix on `model.default`, when
/// either field is unset, or when the prefix matches the provider.
/// Uses case-insensitive comparison Hermes accepts both
/// `Anthropic/...` and `anthropic/...` casings in the wild.
public static func detectMismatch(_ config: HermesConfig) -> Mismatch? {
let modelDefault = config.model.trimmingCharacters(in: .whitespaces)
let activeProvider = config.provider.trimmingCharacters(in: .whitespaces)
guard !isUnset(modelDefault), !isUnset(activeProvider) else { return nil }
guard let slash = modelDefault.firstIndex(of: "/") else { return nil }
let prefix = String(modelDefault[..<slash])
let bare = String(modelDefault[modelDefault.index(after: slash)...])
guard !prefix.isEmpty, !bare.isEmpty else { return nil }
guard prefix.caseInsensitiveCompare(activeProvider) != .orderedSame else { return nil }
return Mismatch(
prefixProvider: prefix,
activeProvider: activeProvider,
modelDefault: modelDefault,
bareModel: bare
)
}
}
@@ -25,6 +25,30 @@ import Foundation
/// callers can treat both uniformly.
public enum SSHScriptRunner {
/// Thread-safe boolean flag used to bridge parent-task cancellation
/// into the detached `Task` body that owns the ssh subprocess.
/// `Task.detached { ... }` does NOT inherit cancellation from the
/// awaiting parent; without this flag, cancelling a chat-load /
/// hydration / activity-fetch Task only throws `CancellationError`
/// at the chat layer while the ssh subprocess keeps running until
/// its 30s timeout fires pinning a remote sqlite query (and a
/// ControlMaster session slot) for the full deadline. v2.8 fix
/// observed in 2026-05-05 dogfooding: rapid chat-switching left a
/// chain of stale 30s ssh subprocesses behind, blocking the
/// dashboard's queryBatch and producing a "spinning" load.
private final class CancelFlag: @unchecked Sendable {
private let lock = NSLock()
private var _cancelled = false
var isCancelled: Bool {
lock.lock(); defer { lock.unlock() }
return _cancelled
}
func cancel() {
lock.lock(); defer { lock.unlock() }
_cancelled = true
}
}
public enum Outcome: Sendable {
/// Couldn't even reach the remote (process spawn failed,
/// timeout before any output, network refused). Carries the
@@ -47,23 +71,37 @@ public enum SSHScriptRunner {
/// the file compiles everywhere.
public static func run(script: String, context: ServerContext, timeout: TimeInterval = 30) async -> Outcome {
await ScarfMon.measureAsync(.transport, "ssh.run") {
#if os(macOS)
switch context.kind {
case .local:
return await runLocally(script: script, timeout: timeout)
case .ssh(let config):
return await runOverSSH(script: script, config: config, timeout: timeout)
}
#else
return .connectFailure("SSHScriptRunner is only available on macOS")
#endif
// Bridge parent cancellation into the detached subprocess
// task. Without this, killing a chat-hydration Task on a
// session switch only unwinds Swift state the ssh
// subprocess keeps holding a remote sqlite query + a
// ControlMaster session for the full 30s timeout. v2.8.
let cancelFlag = CancelFlag()
return await withTaskCancellationHandler(
operation: {
#if os(macOS)
switch context.kind {
case .local:
return await runLocally(script: script, timeout: timeout, cancelFlag: cancelFlag)
case .ssh(let config):
return await runOverSSH(script: script, config: config, timeout: timeout, cancelFlag: cancelFlag)
}
#else
return .connectFailure("SSHScriptRunner is only available on macOS")
#endif
},
onCancel: {
cancelFlag.cancel()
ScarfMon.event(.transport, "ssh.cancelled", count: 1)
}
)
}
}
// MARK: - SSH path
#if os(macOS)
private static func runOverSSH(script: String, config: SSHConfig, timeout: TimeInterval) async -> Outcome {
private static func runOverSSH(script: String, config: SSHConfig, timeout: TimeInterval, cancelFlag: CancelFlag) async -> Outcome {
var sshArgv: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(SSHTransport.controlDirPath())/%C",
@@ -126,7 +164,13 @@ public enum SSHScriptRunner {
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
if Task.isCancelled {
// Honor BOTH the detached-task's own cancellation flag
// (set by the parent's `withTaskCancellationHandler`)
// and the legacy `Task.isCancelled` check in case the
// detached body gets cancelled directly. The flag is
// the load-bearing path; Task.isCancelled is harmless
// belt-and-suspenders.
if cancelFlag.isCancelled || Task.isCancelled {
proc.terminate()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
@@ -159,7 +203,7 @@ public enum SSHScriptRunner {
// MARK: - Local path
private static func runLocally(script: String, timeout: TimeInterval) async -> Outcome {
private static func runLocally(script: String, timeout: TimeInterval, cancelFlag: CancelFlag) async -> Outcome {
return await Task.detached { () -> Outcome in
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
@@ -176,7 +220,7 @@ public enum SSHScriptRunner {
}
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
if Task.isCancelled {
if cancelFlag.isCancelled || Task.isCancelled {
proc.terminate()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
@@ -23,6 +23,13 @@ public final class ActivityViewModel {
public var toolResult: String?
public var sessionPreviews: [String: String] = [:]
public var isLoading = true
/// True while the Phase 2 background fill is paging through
/// `hydrateAssistantToolCalls`. Drives a "Loading tool details"
/// pill in the page header so the user knows the placeholder
/// rows on screen will fill in. v2.8.
public var isHydratingToolCalls = false
@ObservationIgnored
private var hydrationTask: Task<Void, Never>?
public var availableSessions: [(id: String, label: String)] {
var seen = Set<String>()
@@ -34,8 +41,29 @@ public final class ActivityViewModel {
}
public var filteredActivity: [ActivityEntry] {
let entries = toolMessages.flatMap { message in
message.toolCalls.map { call in
let entries = toolMessages.flatMap { message -> [ActivityEntry] in
// v2.8 emit a single "Loading tool calls" placeholder
// entry per skeleton message (one whose tool_calls JSON
// hasn't been hydrated yet). The user sees the timeline
// shape immediately; real entries replace the placeholder
// in-place when `hydrateAssistantToolCalls` returns.
// Filtering still works (we apply the session filter
// below) but kind filter hides placeholders since
// .other is the placeholder's default kind.
guard !message.toolCalls.isEmpty else {
return [ActivityEntry(
id: "skeleton-\(message.id)",
sessionId: message.sessionId,
toolName: "Loading tool details…",
kind: .other,
summary: "",
arguments: "",
messageContent: "",
timestamp: message.timestamp,
isPlaceholder: true
)]
}
return message.toolCalls.map { call in
ActivityEntry(
id: call.callId,
sessionId: message.sessionId,
@@ -49,14 +77,34 @@ public final class ActivityViewModel {
}
}
return entries.filter { entry in
let kindOk = filterKind == nil || entry.kind == filterKind
// Placeholders bypass the kind filter so they don't all
// disappear when the user picks a non-`.other` filter
// chip they still represent rows that may resolve to
// the matching kind once hydrated.
let kindOk = filterKind == nil || entry.isPlaceholder || entry.kind == filterKind
let sessionOk = filterSessionId == nil || entry.sessionId == filterSessionId
return kindOk && sessionOk
}
}
/// Last load's transport-failure reason, if any. Activity surfaces
/// this to the user instead of leaving the empty-state visible
/// (which the user reads as "no activity" rather than "couldn't
/// reach the host"). v2.8.
public var loadError: String?
public func load() async {
// Cancel any in-flight hydration from a prior load (e.g. a
// file-watcher delta firing while the prior pass was still
// paging). The new skeleton replaces the message set, so
// hydrating against the old ids would just splice into rows
// that no longer exist.
hydrationTask?.cancel()
hydrationTask = nil
isHydratingToolCalls = false
isLoading = true
loadError = nil
// refresh() = close + reopen, which forces a fresh snapshot pull on
// remote contexts. Using open() here would short-circuit after the
// first load and show stale data for the view's lifetime. The DB
@@ -64,12 +112,68 @@ public final class ActivityViewModel {
// results without re-opening cleanup() closes on disappear.
let opened = await dataService.refresh()
guard opened else {
loadError = "Couldn't reach \(context.displayName) — check the SSH connection and pull-to-refresh to retry."
isLoading = false
return
}
toolMessages = await dataService.fetchRecentToolCalls(limit: 200)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 200)
// v2.8 Phase L skeleton-then-hydrate. Phase 1 metadata
// fetch is bounded by 50 rows × ~50 bytes (id + session_id +
// role + timestamp; tool_calls JSON is NULLed at the SQL
// level) 3 KB on the wire regardless of how big the
// underlying tool_calls blobs are. Comes back in
// sub-second on healthy remotes; placeholder rows render
// immediately. Phase 2 (paged hydrate) fills the real
// tool details in via 5-id batches in the background.
let outcome = await dataService.fetchRecentToolCallSkeleton(limit: 50)
toolMessages = outcome.messages
if let reason = outcome.transportError {
loadError = "Couldn't load activity from \(context.displayName) — the connection timed out (\(reason)). Pull to refresh to retry."
isLoading = false
return
}
sessionPreviews = await dataService.fetchSessionPreviews(limit: 50)
isLoading = false
// Phase 2 background hydrate. Mirrors the chat path's
// `startToolHydration`. Newest-first (the splice happens in
// batch order), cancellable via `cleanup()` / next `load()`.
startToolCallHydration()
}
/// Phase 2 of the v2.8 Activity loader. Pages through
/// `hydrateAssistantToolCalls` in batches of 5 ids and splices
/// the parsed `[HermesToolCall]` arrays into the existing
/// `toolMessages` skeleton. Once a message has its tool calls,
/// `filteredActivity` swaps the placeholder entry for the real
/// per-call entries on the next observation tick.
private func startToolCallHydration() {
let messageIds = toolMessages
.filter { $0.toolCalls.isEmpty && $0.id > 0 }
.map(\.id)
guard !messageIds.isEmpty else {
isHydratingToolCalls = false
return
}
isHydratingToolCalls = true
let dataService = self.dataService
hydrationTask = Task { @MainActor [weak self] in
defer { self?.isHydratingToolCalls = false }
// Page in 5-id batches matching the chat path
// hydrateAssistantToolCalls already does the paging
// internally; here we just hand it all the ids and
// let it return whatever it could pull. Parent task
// cancellation propagates down via the v2.8 SSH
// cancellation handler we wired through SSHScriptRunner.
let map = await dataService.hydrateAssistantToolCalls(messageIds: messageIds)
guard let self else { return }
if Task.isCancelled { return }
if !map.isEmpty {
self.toolMessages = self.toolMessages.map { msg in
guard msg.toolCalls.isEmpty, let calls = map[msg.id] else { return msg }
return msg.withToolCalls(calls)
}
}
}
}
public func selectEntry(_ entry: ActivityEntry?) async {
@@ -82,6 +186,9 @@ public final class ActivityViewModel {
}
public func cleanup() async {
hydrationTask?.cancel()
hydrationTask = nil
isHydratingToolCalls = false
await dataService.close()
}
}
@@ -95,6 +202,13 @@ public struct ActivityEntry: Identifiable, Sendable {
public let arguments: String
public let messageContent: String
public let timestamp: Date?
/// True for skeleton entries emitted while the v2.8 two-phase
/// loader is still hydrating tool_calls JSON for the underlying
/// message. ActivityRow renders these as greyed "Loading" rows
/// so the user sees the timeline shape without the per-call
/// detail. Splice happens in-place when hydration completes
/// the placeholder vanishes and the real entries take its slot.
public let isPlaceholder: Bool
public init(
id: String,
@@ -104,7 +218,8 @@ public struct ActivityEntry: Identifiable, Sendable {
summary: String,
arguments: String,
messageContent: String,
timestamp: Date?
timestamp: Date?,
isPlaceholder: Bool = false
) {
self.id = id
self.sessionId = sessionId
@@ -114,6 +229,7 @@ public struct ActivityEntry: Identifiable, Sendable {
self.arguments = arguments
self.messageContent = messageContent
self.timestamp = timestamp
self.isPlaceholder = isPlaceholder
}
public var prettyArguments: String {
@@ -64,6 +64,23 @@ public final class RichChatViewModel {
public var messages: [HermesMessage] = []
public var currentSession: HermesSession?
public var messageGroups: [MessageGroup] = []
/// True while the v2.8 two-phase loader's background hydration
/// (tool_calls JSON + tool result rows) is in flight. Chat header
/// shows "Loading tool details" so the user knows the bare
/// transcript they're looking at will fill in. Cleared once both
/// hydration passes finish or the session-id changes underneath.
public var isHydratingTools: Bool = false
@ObservationIgnored
private var hydrationTask: Task<Void, Never>?
/// UserDefaults key controlling whether the chat resume path
/// auto-fetches the CONTENT of tool result rows (`role='tool'`) for
/// past messages. Defaults false a single tool result blob
/// (file dump, stack trace) can be hundreds of KB; bulk-fetching
/// all of them during chat resume on a slow remote can blow past
/// the 30s SSH timeout. The Mac Settings Display tab exposes
/// the toggle (mirror string in `ChatDensityKeys`).
public static let loadHistoricalToolResultsKey = "scarf.chat.loadHistoricalToolResults"
/// True from the moment the user sends a prompt until the ACP
/// `promptComplete` event arrives. Covers the whole round-trip
/// including auxiliary post-processing (title generation, usage
@@ -423,6 +440,9 @@ public final class RichChatViewModel {
public func reset() {
debounceTask?.cancel()
hydrationTask?.cancel()
hydrationTask = nil
isHydratingTools = false
stopActivePolling()
Task { await dataService.close() }
messages = []
@@ -1064,8 +1084,17 @@ public final class RichChatViewModel {
return
}
// v2.8 two-phase loader. Phase 1 skeleton: user + assistant
// rows only, no tool_calls JSON, no reasoning, no
// reasoning_content. Wire payload bounded by conversational
// text alone so chats with multi-page tool result blobs (the
// 30s-timeout case) come up in seconds. Phase 2 (kicked off
// below in a Task.detached) fills tool calls + tool results in
// the background the chat is usable while it runs.
let pageSize = HistoryPageSize.initial
var allMessages = await dataService.fetchMessages(sessionId: sessionId, limit: pageSize)
let originOutcome = await dataService.fetchSkeletonMessages(sessionId: sessionId, limit: pageSize)
var allMessages = originOutcome.messages
var transportFailure: String? = originOutcome.transportError
// Race-check #2: session id may have changed during the
// long fetch (the most common race a 30s timeout on a
// big session lets the user switch to a small one and back).
@@ -1084,16 +1113,19 @@ public final class RichChatViewModel {
if let acpId = acpSessionId, acpId != sessionId {
originSessionId = sessionId
self.sessionId = acpId
let acpMessages = await dataService.fetchMessages(sessionId: acpId, limit: pageSize)
let acpOutcome = await dataService.fetchSkeletonMessages(sessionId: acpId, limit: pageSize)
// Race-check #3: same guard, after the second fetch.
guard self.sessionId == acpId else {
ScarfMon.event(.sessionLoad, "mac.hydrateMessages.dropped", count: 1)
return
}
if !acpMessages.isEmpty {
allMessages.append(contentsOf: acpMessages)
if let acpErr = acpOutcome.transportError, transportFailure == nil {
transportFailure = acpErr
}
if !acpOutcome.messages.isEmpty {
allMessages.append(contentsOf: acpOutcome.messages)
allMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
moreHistory = moreHistory || acpMessages.count >= pageSize
moreHistory = moreHistory || acpOutcome.messages.count >= pageSize
}
}
@@ -1152,9 +1184,182 @@ public final class RichChatViewModel {
hasMoreHistory = moreHistory
ScarfMon.event(.sessionLoad, "mac.hydrateMessages.rows", count: messages.count)
buildMessageGroups()
// Partial-result detection if a fetch tripped a transport
// failure (SSH timeout / ControlMaster drop) the user is now
// looking at zero or near-zero messages with no idea why. The
// pre-v2.8 behavior was a silent empty transcript. Surface a
// banner via the existing acpError triplet so the user sees
// "couldn't load full history connection slow." We assume
// more history exists (so the "Load earlier" affordance is
// honest about the gap) caller can retry by reopening the
// session.
if let reason = transportFailure {
acpError = "Couldn't load full chat history — the connection to \(dataService.context.displayName) timed out."
acpErrorHint = "Reopen the session to retry, or check the SSH link if this keeps happening."
acpErrorDetails = reason
acpErrorOAuthProvider = nil
hasMoreHistory = true
} else {
// v2.8 kick off background hydration of tool_calls JSON
// and tool result rows for the just-loaded skeleton.
// Non-blocking on the main load path (chat is usable).
startToolHydration(loadingForSession: self.sessionId ?? sessionId)
}
} // end measureAsync(.sessionLoad, "mac.hydrateMessages")
}
/// Phase 2 of the two-phase chat loader. Pulls `tool_calls` JSON
/// for the loaded assistant rows, then fetches `role='tool'` rows
/// in the loaded id range and splices both into `messages` /
/// `messageGroups` without disturbing what the user is already
/// reading. Cancellable restarting (a session switch, a
/// `reset()`) drops any in-flight pass.
///
/// Tool calls go in first because they live ON the existing
/// assistant message and surface the most-visible UI affordance
/// (the tool card chips). Tool result content rows go in second
/// because they're the heaviest payload and the UI degrades
/// gracefully without them (the cards still show "running" /
/// "complete" state; only the result body is missing).
private func startToolHydration(loadingForSession: String) {
hydrationTask?.cancel()
let sessionForLoad = loadingForSession
let dataService = self.dataService
hydrationTask = Task { @MainActor [weak self] in
guard let self else { return }
self.isHydratingTools = true
defer { self.isHydratingTools = false }
// Snapshot the assistant ids + id range from the messages
// we just loaded. Doing this on MainActor keeps us in step
// with the observable view of `messages`; the actual
// SQL calls happen in `await` slots that release the actor.
let assistantIds = self.messages
.filter { $0.isAssistant && $0.id > 0 }
.map(\.id)
guard let minId = self.messages.map(\.id).min(),
let maxId = self.messages.map(\.id).max(),
!assistantIds.isEmpty || minId < maxId else {
return
}
// Phase 2a tool_calls JSON. Splice parsed values into
// each assistant message that has them.
let toolCallMap = await dataService.hydrateAssistantToolCalls(messageIds: assistantIds)
if Task.isCancelled || self.sessionId != sessionForLoad {
ScarfMon.event(.sessionLoad, "mac.hydrateTools.dropped", count: 1)
return
}
if !toolCallMap.isEmpty {
self.messages = self.messages.map { msg in
guard msg.isAssistant, let calls = toolCallMap[msg.id] else { return msg }
return msg.withToolCalls(calls)
}
self.buildMessageGroups()
}
// Phase 2b tool result rows. Default OFF (v2.8). A
// single tool result blob (file dump, stack trace) can run
// hundreds of KB; bulk-fetching all of them during chat
// resume on a slow remote was the cause of the 30s timeout
// observed in 2026-05-05 dogfooding. Users can opt in via
// Settings Display "Load tool results in past chats"
// when bandwidth is plentiful. Tool call CARDS still
// render either way (`tool_calls` JSON loads in Phase 2a);
// only the inspector pane's "Output" section is empty
// until the user opens a card, at which point a per-call
// lazy fetch fills it in.
let loadResults = UserDefaults.standard.bool(
forKey: Self.loadHistoricalToolResultsKey
)
guard loadResults else {
ScarfMon.event(.sessionLoad, "mac.hydrateTools.skippedToolResults", count: 1)
return
}
let toolResults = await dataService.fetchToolResultsInRange(
sessionId: sessionForLoad,
minId: minId,
maxId: maxId
)
if Task.isCancelled || self.sessionId != sessionForLoad {
ScarfMon.event(.sessionLoad, "mac.hydrateTools.dropped", count: 1)
return
}
if !toolResults.isEmpty {
var merged = self.messages
let existingIds = Set(merged.map(\.id))
for tr in toolResults where !existingIds.contains(tr.id) {
merged.append(tr)
}
merged.sort { lhs, rhs in
let lt = lhs.timestamp ?? .distantPast
let rt = rhs.timestamp ?? .distantPast
if lt != rt { return lt < rt }
return lhs.id < rhs.id
}
self.messages = merged
self.buildMessageGroups()
}
ScarfMon.event(.sessionLoad, "mac.hydrateTools.complete", count: 1)
}
}
/// Lazy-load the content of a single tool result by call id and
/// splice it into `messages` / `messageGroups` as a synthetic
/// `role='tool'` row. Used by `ChatInspectorPane` when the user
/// opens a tool call card whose result hasn't been hydrated yet
/// (auto-hydrate is opt-in via `loadHistoricalToolResultsKey`).
/// No-op when the result is already present in the transcript or
/// the session id has changed underneath us.
@MainActor
public func loadToolResultIfMissing(callId: String) async {
guard let sessionForLoad = sessionId else { return }
// Already in the transcript? Done.
if messages.contains(where: { $0.toolCallId == callId && $0.isToolResult }) {
return
}
guard let content = await dataService.fetchToolResult(callId: callId) else {
return
}
guard self.sessionId == sessionForLoad else { return }
// Build a synthetic tool result row. We don't have the original
// row id (would need a second SELECT) so we use a negative
// local id that won't collide with persisted rows. The bubble
// and inspector both key on `toolCallId`, not `id`, for tool
// results so this is enough to render correctly.
let placeholderId = nextLocalId
nextLocalId -= 1
let synthetic = HermesMessage(
id: placeholderId,
sessionId: sessionForLoad,
role: "tool",
content: content,
toolCallId: callId,
toolCalls: [],
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: nil,
reasoning: nil,
reasoningContent: nil
)
messages.append(synthetic)
// Re-sort so the tool result lands next to its assistant
// parent. ID-based ordering preserves the chronological order
// of all the persisted rows; the synthetic placeholder uses a
// negative id so it slots in last fine for inspector display
// since the inspector keys on toolCallId.
messages.sort { lhs, rhs in
let lt = lhs.timestamp ?? .distantPast
let rt = rhs.timestamp ?? .distantPast
if lt != rt { return lt < rt }
return lhs.id < rhs.id
}
buildMessageGroups()
ScarfMon.event(.sessionLoad, "mac.lazyToolResult.fetched", count: 1)
}
// MARK: - Load Earlier (pagination)
/// Page back through the current session's DB-persisted history
@@ -19,12 +19,17 @@ struct ActivityView: View {
VStack(spacing: 0) {
pageHeader
filterStrip
if let err = viewModel.loadError {
loadErrorBanner(err)
}
ScrollView {
LazyVStack(alignment: .leading, spacing: ScarfSpace.s5) {
ForEach(groupedByDay) { group in
dayGroup(group)
}
if viewModel.filteredActivity.isEmpty && !viewModel.isLoading {
if viewModel.isLoading && viewModel.filteredActivity.isEmpty {
loadingState
} else if viewModel.filteredActivity.isEmpty && viewModel.loadError == nil {
emptyState
}
}
@@ -43,6 +48,53 @@ struct ActivityView: View {
.sheet(isPresented: detailSheetBinding) { detailSheet }
}
/// Spinner + label rendered while the first load is in flight and
/// the feed is still empty. v2.8 fix pre-fix, `isLoading=true`
/// rendered nothing because the empty-state was gated on
/// `!isLoading`, leaving the user staring at a blank pane during
/// the SSH round-trip.
private var loadingState: some View {
HStack(spacing: ScarfSpace.s3) {
ProgressView().controlSize(.small)
Text("Loading activity…")
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundMuted)
}
.frame(maxWidth: .infinity)
.padding(ScarfSpace.s6)
}
/// Orange banner shown above the feed when the most recent load
/// hit a transport failure. Replaces the silent empty-state that
/// pre-v2.8 left users thinking Activity was broken.
private func loadErrorBanner(_ message: String) -> some View {
HStack(alignment: .top, spacing: ScarfSpace.s2) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Couldn't load activity")
.scarfStyle(.bodyEmph)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.textSelection(.enabled)
}
Spacer()
Button("Retry") {
Task { await viewModel.load() }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(ScarfSpace.s3)
.background(Color.orange.opacity(0.08))
.overlay(
Rectangle().fill(Color.orange.opacity(0.25)).frame(height: 1),
alignment: .bottom
)
}
// MARK: - Page header
private var pageHeader: some View {
@@ -56,6 +108,17 @@ struct ActivityView: View {
.foregroundStyle(ScarfColor.foregroundMuted)
}
Spacer()
if viewModel.isHydratingToolCalls {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Loading tool details…")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 4)
.background(.thinMaterial, in: Capsule())
}
}
.padding(.horizontal, ScarfSpace.s6)
.padding(.top, ScarfSpace.s5)
@@ -321,19 +384,25 @@ private struct ActivityRow: View {
ZStack {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(toneBackground)
Image(systemName: entry.kind.icon)
.font(.system(size: 12))
.foregroundStyle(toneForeground)
if entry.isPlaceholder {
ProgressView().controlSize(.mini)
} else {
Image(systemName: entry.kind.icon)
.font(.system(size: 12))
.foregroundStyle(toneForeground)
}
}
.frame(width: 26, height: 26)
VStack(alignment: .leading, spacing: 1) {
Text(entry.toolName)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
.foregroundStyle(entry.isPlaceholder ? ScarfColor.foregroundMuted : ScarfColor.foregroundPrimary)
.lineLimit(1)
Group {
if entry.summary.isEmpty {
if entry.isPlaceholder {
Text("Tool calls hydrating in the background…")
} else if entry.summary.isEmpty {
Text(entry.kind.displayName)
} else {
Text(entry.summary)
@@ -345,16 +414,20 @@ private struct ActivityRow: View {
.truncationMode(.middle)
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.system(size: 11))
.foregroundStyle(ScarfColor.foregroundFaint)
if !entry.isPlaceholder {
Image(systemName: "chevron.right")
.font(.system(size: 11))
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
.padding(.horizontal, ScarfSpace.s4)
.padding(.vertical, ScarfSpace.s3 - 2)
.background(hover ? ScarfColor.backgroundTertiary.opacity(0.6) : Color.clear)
.background(hover && !entry.isPlaceholder ? ScarfColor.backgroundTertiary.opacity(0.6) : Color.clear)
.opacity(entry.isPlaceholder ? 0.65 : 1.0)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(entry.isPlaceholder)
.onHover { hover = $0 }
}
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// Scarf-local chat rendering preferences (issues #47 / #48).
///
@@ -22,6 +23,16 @@ enum ChatDensityKeys {
/// When hidden, clicking a tool card auto-flips it back on so the
/// click does what the user expects (`ToolCallCard.onFocus`). Issue #58.
static let showInspector = "scarf.chat.showInspector"
/// v2.8 opt-in auto-fetch of tool result CONTENT in past chats.
/// Defaults FALSE because a single tool result blob (file dump,
/// stack trace) can be hundreds of KB; bulk-fetching all of them
/// during chat resume on a slow remote can blow past the 30s SSH
/// timeout (observed in 2026-05-05 dogfooding). When false, tool
/// CALL cards still render (the `tool_calls` JSON path is bounded
/// and fast); only the inspector pane's "Output" section is empty
/// until the user expands a card, at which point we lazy-fetch
/// just that single result via `fetchToolResult(callId:)`.
static let loadHistoricalToolResults = RichChatViewModel.loadHistoricalToolResultsKey
}
/// How `RichMessageBubble` renders the per-call tool widgets.
@@ -48,6 +48,17 @@ final class ChatViewModel {
@ObservationIgnored
private var sessionsRefreshTask: Task<Void, Never>?
/// L2 (v2.8) in-flight coalescing handle for `loadRecentSessions`.
/// On a slow remote each load is a 1.52.5s SSH round-trip; the
/// 500 ms `scheduleSessionsRefresh` debounce only suppresses a
/// pending tick, not one that's already executing. Without this
/// guard, file-watcher deltas during a stream stack 23 parallel
/// loadRecentSessions tasks (observed at t=305844 in 2026-05-05
/// dogfooding). The in-flight pointer lets a second caller await
/// the active task instead of spawning another SSH subprocess.
@ObservationIgnored
private var inFlightSessionLoad: Task<Void, Never>?
/// Per-recent-session project attribution. Keyed by `HermesSession.id`,
/// value is the project's display name. Populated alongside
/// `recentSessions` via a single batched read in `loadRecentSessions()`.
@@ -122,21 +133,57 @@ final class ChatViewModel {
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
var acpStatus: String = ""
/// User-facing status strings that all map to "the session is in
/// the middle of being established." Centralized so the toolbar
/// status pill, the chat-pane loader, and `ChatSessionListPane`'s
/// click-gating stay in sync. v2.8 added `loadingHistory` after
/// the user reported the chat looked engageable while the
/// 30-second `fetchMessages` was still in flight on a slow remote.
static let preparingPhases: Set<String> = [
ACPPhase.spawning,
ACPPhase.authenticating,
ACPPhase.creatingSession,
ACPPhase.creatingNewSession,
ACPPhase.loadingSession,
ACPPhase.loadingHistory
]
enum ACPPhase {
static let spawning = "Spawning hermes acp…"
static let authenticating = "Authenticating…"
static let creatingSession = "Creating session…"
static let creatingNewSession = "Creating new session…"
static let loadingSession = "Loading session…"
static let loadingHistory = "Loading history…"
static let ready = "Ready"
static let agentWorking = "Agent working…"
static let cancelled = "Cancelled"
static let failed = "Failed"
static let error = "Error"
static let connectionLost = "Connection lost"
}
/// Set true the moment the user kicks off a session-start path
/// (resume / new / continue), cleared when the ACP session is
/// fully ready or has failed. Decoupled from `hasActiveProcess`
/// that flag only flips true AFTER `client.start()` succeeds,
/// which on remote contexts is a 57s window where the user sees
/// nothing happening even though they've just clicked. v2.8
/// fixes the gap between row-click and overlay-appears that
/// the user reported in 2026-05-05 dogfooding.
var isStartingSession: Bool = false
/// True while a session is being established or restored from the user
/// kicking off "start chat" or "resume session" until the ACP session is
/// ready for messages. The chat pane uses this to show a loader in place
/// of the empty-state placeholder.
/// of the empty-state placeholder; `ChatSessionListPane` uses it to
/// disable session-row taps so the user can't queue up a second
/// switch while the first is still mid-boot (v2.8).
var isPreparingSession: Bool {
if isStartingSession { return true }
guard hasActiveProcess else { return false }
switch acpStatus {
case "Starting...",
"Creating session...",
"Creating new session...",
"Loading session...":
return true
default:
return acpStatus.hasPrefix("Reconnecting")
}
if Self.preparingPhases.contains(acpStatus) { return true }
return acpStatus.hasPrefix("Reconnecting")
}
/// Error triplet moved to RichChatViewModel in M7 #2 so ScarfGo can
/// share the same banner. These are forwarding accessors to keep
@@ -160,6 +207,16 @@ final class ChatViewModel {
/// True when `hasAnyAICredential()` returned false at last preflight.
var missingCredentials: Bool = false
/// `model.default` / `model.provider` mismatch detected by the
/// last `refreshConfigDiagnostics` pass. Drives the "Configuration
/// mismatch" banner in `errorBanner`. Nil when config is coherent
/// or unset. v2.8 observed in dogfooding when switching OAuth
/// providers via Credential Pools left a stale model prefix
/// behind (e.g. `model.default: anthropic/...` with
/// `model.provider: nous`); chats died with `-32603 Internal error`
/// at first prompt with no diagnostic.
var modelProviderMismatch: ModelPreflight.Mismatch?
/// Set when chat-start is blocked because the active server's
/// `config.yaml` has no `model.default` / `model.provider`. The chat
/// view observes this and presents `ChatModelPreflightSheet`; on
@@ -191,6 +248,72 @@ final class ChatViewModel {
missingCredentials = !fileService.hasAnyAICredential()
}
/// Re-reads config.yaml and refreshes the
/// `model.default` / `model.provider` mismatch state. Off-MainActor
/// because `loadConfig()` is a synchronous file read (and an SSH
/// round-trip on remote contexts). Safe to call from `.task` or
/// after a write that would have changed config.
func refreshConfigDiagnostics() {
let svc = fileService
Task.detached { [weak self] in
let config = svc.loadConfig()
let mismatch = ModelPreflight.detectMismatch(config)
await MainActor.run { [weak self] in
self?.modelProviderMismatch = mismatch
}
}
}
/// Persist a one-click mismatch fix. Aligns `model.provider` to the
/// prefix carried in `model.default` (the user's "I just authed
/// against this provider, that's what the prefix means" intent).
/// Triggers a config-diagnostics refresh on completion to clear the
/// banner if the write took. Failures fall through to the existing
/// `acpError` banner so the user sees something happened.
func alignProviderToModelPrefix(_ mismatch: ModelPreflight.Mismatch) {
let svc = fileService
Task.detached { [weak self] in
// We pass the bare model so config.yaml ends up with a
// clean (provider-prefix-free) model name alongside the
// matching provider matches what `confirmModelPreflight`
// writes for a fresh setup.
let ok = svc.setModelAndProvider(
model: mismatch.bareModel,
provider: mismatch.prefixProvider
)
await MainActor.run { [weak self] in
guard let self else { return }
if ok {
self.modelProviderMismatch = nil
} else {
self.acpError = "Couldn't write the new provider to config.yaml. Open Settings to fix manually."
}
}
}
}
/// Persist the inverse mismatch fix strip the provider prefix
/// off `model.default` and keep `model.provider` as the active
/// authoritative value. Use case: the user genuinely intended to
/// switch their active provider and the stale prefix is the bug.
func stripPrefixFromModelDefault(_ mismatch: ModelPreflight.Mismatch) {
let svc = fileService
Task.detached { [weak self] in
let ok = svc.setModelAndProvider(
model: mismatch.bareModel,
provider: mismatch.activeProvider
)
await MainActor.run { [weak self] in
guard let self else { return }
if ok {
self.modelProviderMismatch = nil
} else {
self.acpError = "Couldn't rewrite model.default in config.yaml. Open Settings to fix manually."
}
}
}
}
/// Forwarders to the ScarfCore implementation so the error-banner
/// state lives in one place (M7 #2). The per-site logging label
/// stays here only the storage is shared.
@@ -218,6 +341,12 @@ final class ChatViewModel {
/// Terminal mode ignores the prompt the wizard runs in rich-chat
/// only.
func startNewSession(projectPath: String?, initialPrompt: String?) {
// Flip the loading flag synchronously on the user's tap so
// SwiftUI paints the session-list overlay on the same tick
// `startACPSession` won't reach `acpStatus = .spawning`
// until the Task body runs, which on remote contexts is
// multiple seconds after the click. v2.8.
isStartingSession = true
voiceEnabled = false
ttsEnabled = false
isRecording = false
@@ -248,6 +377,7 @@ final class ChatViewModel {
}
func resumeSession(_ sessionId: String) {
isStartingSession = true
voiceEnabled = false
ttsEnabled = false
isRecording = false
@@ -262,6 +392,7 @@ final class ChatViewModel {
}
func continueLastSession() {
isStartingSession = true
voiceEnabled = false
ttsEnabled = false
isRecording = false
@@ -272,6 +403,7 @@ final class ChatViewModel {
Task { @MainActor in
let opened = await dataService.open()
if !opened {
isStartingSession = false
acpError = context.isRemote
? "Couldn't reach \(context.displayName). Check the SSH connection and try again."
: "Couldn't open the Hermes state database."
@@ -334,6 +466,7 @@ final class ChatViewModel {
/// between the DB read and ACP `session/load`, producing a silent prompt
/// failure with no UI feedback.
private func autoStartACPAndSend(text: String, images: [ChatImageAttachment] = []) {
isStartingSession = true
// Show the user message immediately
richChatViewModel.addUserMessage(text: text)
@@ -344,8 +477,9 @@ final class ChatViewModel {
self.acpClient = client
do {
acpStatus = ACPPhase.spawning
try await client.start()
acpStatus = await client.statusMessage
acpStatus = ACPPhase.authenticating
startACPEventLoop(client: client)
startHealthMonitor(client: client)
@@ -355,21 +489,22 @@ final class ChatViewModel {
let resolvedSessionId: String
if let existing = sessionToResume {
acpStatus = "Loading session..."
acpStatus = ACPPhase.loadingSession
do {
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: existing)
} catch {
logger.info("Session \(existing) not found in ACP, creating new session")
acpStatus = "Creating new session..."
acpStatus = ACPPhase.creatingNewSession
resolvedSessionId = try await client.newSession(cwd: cwd)
}
} else {
acpStatus = "Creating session..."
acpStatus = ACPPhase.creatingSession
resolvedSessionId = try await client.newSession(cwd: cwd)
}
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
acpStatus = ACPPhase.ready
isStartingSession = false
// Surface the freshly-created session in the chat
// sidebar immediately. We can't lean on the file
@@ -382,7 +517,8 @@ final class ChatViewModel {
// Now send the queued prompt
sendViaACP(client: client, text: text, images: images)
} catch {
acpStatus = "Failed"
acpStatus = ACPPhase.failed
isStartingSession = false
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
hasActiveProcess = false
acpClient = nil
@@ -454,14 +590,14 @@ final class ChatViewModel {
}
}
} else {
acpStatus = "Agent working..."
acpStatus = ACPPhase.agentWorking
}
acpPromptTask = Task { @MainActor in
do {
let result = try await ScarfMon.measureAsync(.chatStream, "mac.sendPrompt") {
try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
}
acpStatus = "Ready"
acpStatus = ACPPhase.ready
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: result)
)
@@ -483,9 +619,9 @@ final class ChatViewModel {
)
}
} catch is CancellationError {
acpStatus = "Cancelled"
acpStatus = ACPPhase.cancelled
} catch {
acpStatus = "Error"
acpStatus = ACPPhase.error
await recordACPFailure(error, client: client, context: "ACP prompt failed")
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
@@ -508,6 +644,10 @@ final class ChatViewModel {
ScarfMon.event(.sessionLoad, "mac.startACPSession", count: 1)
stopACP()
clearACPErrorState()
// stopACP() clears `isStartingSession` (it's a generic teardown
// helper used by disconnect paths too). Re-arm it here so the
// session-list overlay stays up through the entire boot.
isStartingSession = true
// Pre-flight: bail before opening any ACP plumbing if the
// active server's `config.yaml` has no primary model or
@@ -522,10 +662,11 @@ final class ChatViewModel {
modelPreflightReason = preflight.reason
acpStatus = ""
hasActiveProcess = false
isStartingSession = false
return
}
acpStatus = "Starting..."
acpStatus = ACPPhase.spawning
let client = ACPClient.forMacApp(context: context)
self.acpClient = client
@@ -564,7 +705,7 @@ final class ChatViewModel {
do {
// Start ACP process and event loop FIRST
try await client.start()
acpStatus = await client.statusMessage
acpStatus = ACPPhase.authenticating
startACPEventLoop(client: client)
startHealthMonitor(client: client)
@@ -588,26 +729,34 @@ final class ChatViewModel {
let resolvedSessionId: String
if let sessionId {
acpStatus = "Loading session..."
acpStatus = ACPPhase.loadingSession
do {
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
} catch {
logger.info("Session \(sessionId) not found in ACP, creating new session with history")
acpStatus = "Creating new session..."
acpStatus = ACPPhase.creatingNewSession
resolvedSessionId = try await client.newSession(cwd: cwd)
}
// Load messages from both origin CLI session and ACP session
// Surface "Loading history" before the (potentially
// 30s) message-history fetch fires. Pre-fix the user
// saw "Loading session" through start(), then jump
// straight to "Ready" the moment the bytes hit the
// pane but the actual hydrate is the slowest step
// on a remote and the pane looked engageable while
// the SQLite query was still pending. v2.8.
acpStatus = ACPPhase.loadingHistory
await richChatViewModel.loadSessionHistory(
sessionId: sessionId,
acpSessionId: resolvedSessionId
)
} else {
acpStatus = "Creating session..."
acpStatus = ACPPhase.creatingSession
resolvedSessionId = try await client.newSession(cwd: cwd)
}
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
acpStatus = ACPPhase.ready
isStartingSession = false
// Attribute this session to the project it was started
// under, so the per-project Sessions tab can surface it
@@ -685,7 +834,8 @@ final class ChatViewModel {
sendViaACP(client: client, text: prompt, images: [])
}
} catch {
acpStatus = "Failed"
acpStatus = ACPPhase.failed
isStartingSession = false
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
hasActiveProcess = false
acpClient = nil
@@ -702,7 +852,12 @@ final class ChatViewModel {
ScarfMon.measure(.chatStream, "mac.handleACPEvent") {
self?.richChatViewModel.handleACPEvent(event)
}
self?.acpStatus = await client.statusMessage
// Don't overwrite a phase-typed acpStatus with the
// ACP-side "Connected" string mid-stream; we promote
// to ready/agentWorking from the call sites that own
// the lifecycle. The event-loop side-effect is
// the heartbeat leave acpStatus alone here.
_ = await client.statusMessage
}
// Stream ended if we weren't cancelled, the connection died
if !Task.isCancelled {
@@ -768,7 +923,7 @@ final class ChatViewModel {
for attempt in 1...Self.maxReconnectAttempts {
guard !Task.isCancelled else { return }
acpStatus = "Reconnecting (\(attempt)/\(Self.maxReconnectAttempts))..."
acpStatus = "Reconnecting (\(attempt)/\(Self.maxReconnectAttempts))"
logger.info("Reconnect attempt \(attempt)/\(Self.maxReconnectAttempts) for session \(sessionId)")
// Backoff delay (skip on first attempt for fast recovery)
@@ -805,7 +960,7 @@ final class ChatViewModel {
// Reconcile in-memory messages with what Hermes persisted to DB
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
acpStatus = ACPPhase.ready
clearACPErrorState()
startACPEventLoop(client: client)
@@ -830,7 +985,7 @@ final class ChatViewModel {
private func showConnectionFailure() {
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
acpStatus = "Connection lost"
acpStatus = ACPPhase.connectionLost
clearACPErrorState()
acpError = "Connection lost. Use the Session menu to reconnect."
}
@@ -850,6 +1005,7 @@ final class ChatViewModel {
acpClient = nil
hasActiveProcess = false
isHandlingDisconnect = false
isStartingSession = false
}
// MARK: - Model preflight
@@ -935,6 +1091,25 @@ final class ChatViewModel {
}
func loadRecentSessions() async {
// L2 (v2.8) coalesce against an in-flight load. If one's
// already running, await its completion instead of spawning a
// parallel one. Drops the 2-3× contention seen during file-
// watcher streams.
if let existing = inFlightSessionLoad {
ScarfMon.event(.sessionLoad, "mac.loadRecentSessions.coalesced", count: 1)
await existing.value
return
}
let task = Task { @MainActor [weak self] in
guard let self else { return }
await self.performLoadRecentSessions()
}
inFlightSessionLoad = task
await task.value
inFlightSessionLoad = nil
}
private func performLoadRecentSessions() async {
// Measure the full wall-clock cost of a sessions sidebar reload,
// from DB open through the off-main attribution read to the final
// observable assignment. Surfaces fetch regressions and SQLite
@@ -44,6 +44,16 @@ struct ChatInspectorPane: View {
}
}
.background(ScarfColor.backgroundSecondary)
// v2.8 lazy-load the tool result content when the inspector
// opens for a call whose result wasn't auto-hydrated. The
// chat-resume path skips Phase 2b by default (the bulk fetch
// can blow past the 30s SSH timeout on remote contexts), so
// the inspector is the user-initiated lazy path.
.task(id: chatViewModel.focusedToolCallId) {
guard let id = chatViewModel.focusedToolCallId,
chatViewModel.focusedToolCall?.result == nil else { return }
await chatViewModel.richChatViewModel.loadToolResultIfMissing(callId: id)
}
}
// MARK: - Header
@@ -55,6 +55,31 @@ struct ChatSessionListPane: View {
.padding(.horizontal, 6)
.padding(.bottom, ScarfSpace.s2)
}
// While a session is mid-boot the SSH tunnel is bottlenecked
// on the in-flight start/load letting the user queue up a
// second session-switch ends with both fights racing for
// the same backend (we've seen the small fast chat lose to
// a 30s timeout from the prior big chat). Disable the
// entire pane (taps + visual) during prep, plus a
// ProgressView so the cause is obvious. v2.8.
.disabled(chatViewModel.isPreparingSession)
.opacity(chatViewModel.isPreparingSession ? 0.55 : 1.0)
.overlay {
if chatViewModel.isPreparingSession {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text(chatViewModel.acpStatus.isEmpty ? "Loading…" : chatViewModel.acpStatus)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, ScarfSpace.s2)
.background(.thinMaterial, in: Capsule())
.padding(.bottom, ScarfSpace.s5)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.allowsHitTesting(false)
}
}
footer
}
.background(ScarfColor.backgroundTertiary)
@@ -48,6 +48,7 @@ struct ChatView: View {
.task {
await viewModel.loadRecentSessions()
viewModel.refreshCredentialPreflight()
viewModel.refreshConfigDiagnostics()
// Cold-launch handoff: if the user clicked "New Chat" on
// a project before ChatView had a chance to render, the
// coordinator was already populated. Consume the request
@@ -136,6 +137,15 @@ struct ChatView: View {
if viewModel.richChatViewModel.isStreamingThoughtsOnly {
return "Thinking…"
}
// v2.8 promote the otherwise-ready status to a more honest
// "Loading tool details" while the two-phase loader's
// background hydration is still pulling tool_calls JSON and
// tool result rows. The bare conversation transcript is
// already on screen; this just tells the user that the
// missing tool cards / result bodies are on their way.
if viewModel.richChatViewModel.isHydratingTools {
return "Loading tool details…"
}
return viewModel.acpStatus.isEmpty ? "Active" : viewModel.acpStatus
}
@@ -233,6 +243,50 @@ struct ChatView: View {
.frame(height: 1),
alignment: .bottom
)
} else if let mismatch = viewModel.modelProviderMismatch, !viewModel.hasActiveProcess {
// Provider/model mismatch `model.default` carries one
// provider prefix while `model.provider` names another.
// Hermes can't reconcile and the chat dies with -32603 at
// first prompt. v2.8 surfaces a one-click fix for both
// directions: align provider to the model's prefix
// (likely the user just authed against `prefixProvider`),
// or strip the prefix to keep the active provider intact.
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 4) {
Text("Model/provider mismatch in config.yaml")
.font(.callout)
Text("`model.default` is `\(mismatch.modelDefault)` but `model.provider` is `\(mismatch.activeProvider)`. Chats will fail at first prompt until this is reconciled.")
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
HStack(spacing: 6) {
Button("Use \(mismatch.prefixProvider)") {
viewModel.alignProviderToModelPrefix(mismatch)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.help("Set model.provider = \(mismatch.prefixProvider) and model.default = \(mismatch.bareModel).")
Button("Keep \(mismatch.activeProvider)") {
viewModel.stripPrefixFromModelDefault(mismatch)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Strip the prefix from model.default, leaving model.provider = \(mismatch.activeProvider).")
}
.padding(.top, 2)
}
Spacer()
}
.padding(10)
.background(Color.orange.opacity(0.08))
.overlay(
Rectangle()
.fill(Color.orange.opacity(0.25))
.frame(height: 1),
alignment: .bottom
)
}
}
@@ -590,6 +590,22 @@ private struct AddCredentialSheet: View {
/// regular `OAuthFlowController` silently stalls, so we route Nous
/// through ``NousSignInSheet`` instead.
@State private var showNousSignIn: Bool = false
/// Provider/model swap prompt presented after a successful OAuth.
/// Captures the just-authed provider and the active config so the
/// confirm sheet can show the user what's about to change. Nil
/// when no swap is offered (already aligned, or user dismissed).
@State private var pendingProviderSwap: PendingProviderSwap?
/// Snapshot of the post-OAuth state used to render the
/// "Switch active provider?" sheet. Frozen at the moment OAuth
/// succeeded so the sheet stays consistent if config.yaml is
/// edited concurrently.
private struct PendingProviderSwap: Identifiable {
let id = UUID()
let newProvider: String
let currentProvider: String
let currentModelDefault: String
}
private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) }
@@ -633,12 +649,44 @@ private struct AddCredentialSheet: View {
// off `succeeded` which the controller sets only when hermes exited
// zero AND the output has no failure markers. The 0.8s delay lets the
// user see the success banner before the sheet disappears.
//
// v2.8 before auto-dismissing, check whether the just-authed
// provider matches `model.provider` in config.yaml. If they
// disagree, surface the "Switch active provider?" sheet so the
// user doesn't have to dig into Settings to make the new
// credentials actually drive chats. Detected entirely on the
// detached read; only the present-sheet branch keeps the user
// from auto-dismissing.
.onChange(of: viewModel.oauthFlow.succeeded) { _, newValue in
guard newValue else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
onDismiss()
let trimmedProvider = providerID.trimmingCharacters(in: .whitespaces)
let ctx = viewModel.context
Task.detached {
let svc = HermesFileService(context: ctx)
let config = svc.loadConfig()
let activeProvider = config.provider.trimmingCharacters(in: .whitespaces)
let modelDefault = config.model.trimmingCharacters(in: .whitespaces)
let needsSwap = !trimmedProvider.isEmpty
&& !activeProvider.isEmpty
&& trimmedProvider.caseInsensitiveCompare(activeProvider) != .orderedSame
await MainActor.run {
if needsSwap {
pendingProviderSwap = PendingProviderSwap(
newProvider: trimmedProvider,
currentProvider: activeProvider,
currentModelDefault: modelDefault
)
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
onDismiss()
}
}
}
}
}
.sheet(item: $pendingProviderSwap) { swap in
providerSwapSheet(swap: swap)
}
// Nous sign-in is a parallel flow that bypasses OAuthFlowController.
// When it completes, the parent list refreshes from auth.json just
// like it does after a regular OAuth add so we dismiss the
@@ -938,6 +986,61 @@ private struct AddCredentialSheet: View {
}
}
/// "Switch active provider?" sheet shown after a successful OAuth
/// when the just-authed provider doesn't match `model.provider` in
/// config.yaml. Without this, the user has to remember to open
/// Settings and swap the provider manually they'd otherwise hit
/// the v2.8 mismatch banner on the very next chat. v2.8.
private func providerSwapSheet(swap: PendingProviderSwap) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.title2)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 4) {
Text("Switch active provider to \(swap.newProvider)?")
.font(.headline)
Text("`\(swap.newProvider)` is now authenticated, but `model.provider` in config.yaml is still `\(swap.currentProvider)`.")
.font(.callout)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if !swap.currentModelDefault.isEmpty {
Text("Current `model.default`: `\(swap.currentModelDefault)` — Hermes will pick a default for `\(swap.newProvider)` if you switch.")
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
HStack {
Button("Keep \(swap.currentProvider)") {
pendingProviderSwap = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { onDismiss() }
}
Spacer()
Button("Switch to \(swap.newProvider)") {
let target = swap.newProvider
let ctx = viewModel.context
pendingProviderSwap = nil
Task.detached {
let svc = HermesFileService(context: ctx)
// Empty model lets Hermes pick its own default
// for the new provider matches the Nous Portal
// path and avoids re-introducing a stale prefix.
_ = svc.setModelAndProvider(model: "", provider: target)
await MainActor.run {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { onDismiss() }
}
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(minWidth: 460)
}
/// Gate-aware OAuth primary action. For PKCE providers it's the
/// unchanged "Start OAuth" button; for Nous it's "Sign in to Nous
/// Portal" (opens ``NousSignInSheet``); for other device-code /
@@ -26,6 +26,14 @@ struct DisplayTab: View {
/// users new to Scarf get the async-aware UX out of the box.
@AppStorage(ChatNotificationService.toggleKey)
private var notifyOnComplete: Bool = true
/// v2.8 opt-in tool-result content load when resuming past
/// chats. Default off so slow remotes don't blow past the SSH
/// timeout on chats with multi-page tool output. Tool call cards
/// still render either way; only the inspector's "Output"
/// section is empty until the user opens a card (lazy-fetched
/// per-call).
@AppStorage(ChatDensityKeys.loadHistoricalToolResults)
private var loadHistoricalToolResults: Bool = false
var body: some View {
SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") {
@@ -42,6 +50,14 @@ struct DisplayTab: View {
FontScaleRow(scale: $fontScale)
ToggleRow(label: "Sessions list", isOn: showSessionsList) { showSessionsList = $0 }
ToggleRow(label: "Tool inspector", isOn: showInspector) { showInspector = $0 }
ToggleRow(
label: "Load tool results in past chats",
isOn: loadHistoricalToolResults
) { loadHistoricalToolResults = $0 }
Text("Off (default) keeps past chat resumes fast on slow remotes — tool call cards still render, but the inspector lazy-loads each result when you open it.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundMuted)
.padding(.leading, 168)
DensityFootnote()
}
+119 -4
View File
@@ -1011,6 +1011,18 @@
"comment" : "A description of the slash command feature.",
"isCommentAutoGenerated" : true
},
"`%@` is now authenticated, but `model.provider` in config.yaml is still `%@`." : {
"comment" : "A callout explaining why the user is being prompted to switch providers.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "`%1$@` is now authenticated, but `model.provider` in config.yaml is still `%2$@`."
}
}
}
},
"`%@` uses a different sign-in flow." : {
"comment" : "A description of the sign-in flow for a given provider.",
"isCommentAutoGenerated" : true
@@ -1023,6 +1035,16 @@
"comment" : "A warning that will be shown in a restore sheet if",
"isCommentAutoGenerated" : true
},
"`model.default` is `%@` but `model.provider` is `%@`. Chats will fail at first prompt until this is reconciled." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "`model.default` is `%1$@` but `model.provider` is `%2$@`. Chats will fail at first prompt until this is reconciled."
}
}
}
},
"`npx` not found on the Hermes host." : {
},
@@ -4824,10 +4846,6 @@
}
}
},
"Click Re-authenticate to refresh tokens. Removing or rotating providers is still done via `hermes auth …` in a terminal." : {
"comment" : "A description of how to refresh OAuth-authed",
"isCommentAutoGenerated" : true
},
"Click to inspect this tool call" : {
"comment" : "A tooltip for a tool call button.",
"isCommentAutoGenerated" : true
@@ -5457,6 +5475,10 @@
"comment" : "The title of the configuration sheet. The argument is the name of the template.",
"isCommentAutoGenerated" : true
},
"Configured under `auxiliary.%@` in config.yaml" : {
"comment" : "A note that the task is configured under `auxiliary.<task>.` in config.yaml.",
"isCommentAutoGenerated" : true
},
"Connect timeout" : {
"localizations" : {
"de" : {
@@ -6020,6 +6042,10 @@
"comment" : "A message indicating that an image could not be loaded. The argument is the error description.",
"isCommentAutoGenerated" : true
},
"Couldn't load activity" : {
"comment" : "A title for the banner that appears when",
"isCommentAutoGenerated" : true
},
"Couldn't reset memory" : {
"comment" : "A title for an alert that can't reset memory.",
"isCommentAutoGenerated" : true
@@ -6652,6 +6678,18 @@
"comment" : "Name of the curator task.",
"isCommentAutoGenerated" : true
},
"Current `model.default`: `%@` — Hermes will pick a default for `%@` if you switch." : {
"comment" : "A footnote that shows the name of the default model for the current provider.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Current `model.default`: `%1$@` — Hermes will pick a default for `%2$@` if you switch."
}
}
}
},
"Current: %@" : {
"localizations" : {
"de" : {
@@ -11273,6 +11311,9 @@
"keep (not installed by template)" : {
"comment" : "A description of a file that is not part of the template's installation.",
"isCommentAutoGenerated" : true
},
"Keep %@" : {
},
"Keep tokens fresh" : {
"comment" : "Title of a section that lets you enable a cron job that refreshes OAuth tokens.",
@@ -11875,6 +11916,10 @@
}
}
},
"Loading activity…" : {
"comment" : "A loading message displayed while the activity feed is loading.",
"isCommentAutoGenerated" : true
},
"Loading catalog…" : {
"comment" : "A placeholder text that appears when the catalog is loading.",
"isCommentAutoGenerated" : true
@@ -11929,6 +11974,9 @@
}
}
}
},
"Loading tool details…" : {
},
"Local" : {
"localizations" : {
@@ -12863,6 +12911,9 @@
"Model override" : {
"comment" : "A label for the model override field in a slash command editor.",
"isCommentAutoGenerated" : true
},
"Model/provider mismatch in config.yaml" : {
},
"Models" : {
"extractionState" : "stale",
@@ -13872,6 +13923,10 @@
"comment" : "A message displayed when there are no kanban tasks.",
"isCommentAutoGenerated" : true
},
"No matches" : {
"comment" : "A message that appears when a search query matches no",
"isCommentAutoGenerated" : true
},
"No matches for \"%@\"." : {
"comment" : "A message that appears when a search yields no results. The argument is the search term.",
"isCommentAutoGenerated" : true
@@ -14000,6 +14055,10 @@
"comment" : "A message that appears when the user is not logged in to Nous Portal.",
"isCommentAutoGenerated" : true
},
"No models match \"%@\"." : {
"comment" : "A search term that was used to filter the list of models.",
"isCommentAutoGenerated" : true
},
"No output yet — this job hasn't run, or its output file is gone." : {
"comment" : "A message displayed when a cron job's output",
"isCommentAutoGenerated" : true
@@ -14726,6 +14785,10 @@
"comment" : "A label for a disabled skill.",
"isCommentAutoGenerated" : true
},
"Off (default) keeps past chat resumes fast on slow remotes — tool call cards still render, but the inspector lazy-loads each result when you open it." : {
"comment" : "A footnote that describes the effect of the font scale.",
"isCommentAutoGenerated" : true
},
"Offline • bundled list" : {
},
@@ -15414,6 +15477,10 @@
}
}
},
"Other tasks in config.yaml" : {
"comment" : "Title of a section of the Auxiliary tab that lists tasks in the config.yaml file that aren't part of the predefined list.",
"isCommentAutoGenerated" : true
},
"Output" : {
"localizations" : {
"de" : {
@@ -17100,6 +17167,10 @@
"comment" : "A message that instructs the user to re-authenticate AI providers and MCP servers if they weren't included in the backup.",
"isCommentAutoGenerated" : true
},
"Re-authenticate refreshes tokens; the trash icon removes the provider from auth.json." : {
"comment" : "A description of how to remove an OAuth provider.",
"isCommentAutoGenerated" : true
},
"Re-run" : {
"localizations" : {
"de" : {
@@ -17753,6 +17824,10 @@
"comment" : "Title of a dialog that asks the user to confirm removing a project from Scarf's project list.",
"isCommentAutoGenerated" : true
},
"Remove OAuth provider %@?" : {
"comment" : "A confirmation dialog asking the user to confirm the removal of an OAuth provider. The argument is the name of the OAuth provider.",
"isCommentAutoGenerated" : true
},
"Remove Server…" : {
"comment" : "A label for a button that removes a server.",
"isCommentAutoGenerated" : true
@@ -17926,6 +18001,10 @@
"comment" : "A title that says \"Removed\" followed by the name of a project.",
"isCommentAutoGenerated" : true
},
"Removes this OAuth provider from auth.json. You'll need to re-authenticate before Hermes can use it again. The upstream provider account is not revoked." : {
"comment" : "A confirmation dialog message for removing an OAuth provider.",
"isCommentAutoGenerated" : true
},
"Removing…" : {
"comment" : "Text displayed in a progress view when the template uninstall is in progress.",
"isCommentAutoGenerated" : true
@@ -18279,6 +18358,10 @@
"comment" : "A button that resets the user's memory.",
"isCommentAutoGenerated" : true
},
"Reset provider" : {
"comment" : "A button that resets the provider for a task to `auto`.",
"isCommentAutoGenerated" : true
},
"Restart" : {
"localizations" : {
"de" : {
@@ -20659,6 +20742,16 @@
"comment" : "A tooltip for the star button in the Manage Servers view.",
"isCommentAutoGenerated" : true
},
"Set model.provider = %@ and model.default = %@." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Set model.provider = %1$@ and model.default = %2$@."
}
}
}
},
"Settings" : {
"localizations" : {
"de" : {
@@ -22194,6 +22287,9 @@
"Stored under `quick_commands:` in config.yaml." : {
"comment" : "A description of the quick commands feature.",
"isCommentAutoGenerated" : true
},
"Strip the prefix from model.default, leaving model.provider = %@." : {
},
"Strip the template's begin/end block, preserve everything else in MEMORY.md" : {
"comment" : "A description of the action to remove a memory block.",
@@ -22410,10 +22506,18 @@
"comment" : "A button that switches to a profile and relaunches Scarf.",
"isCommentAutoGenerated" : true
},
"Switch active provider to %@?" : {
"comment" : "A prompt asking the user to switch to a new provider after OAuth.",
"isCommentAutoGenerated" : true
},
"Switch to '%@' and relaunch Scarf?" : {
"comment" : "A confirmation dialog asking the user to confirm switching to a new profile and relaunching Scarf. The argument is the name of the profile to switch to.",
"isCommentAutoGenerated" : true
},
"Switch to %@" : {
"comment" : "The default action button.",
"isCommentAutoGenerated" : true
},
"Switch to This Profile" : {
"extractionState" : "stale",
"localizations" : {
@@ -23007,6 +23111,10 @@
}
}
},
"These auxiliary tasks are present in your `config.yaml` but Scarf doesn't have a typed editor for them. The most common fix is to reset their provider to `auto` so Hermes inherits the main provider. For finer edits, use **Open in Editor** at the top of Settings." : {
"comment" : "A description of the auxiliary task rows that show up when you have a config.yaml file that has tasks that Scarf doesn't know how to edit.",
"isCommentAutoGenerated" : true
},
"These files weren't installed by the template (the agent or you created them after install), so Scarf left them in place along with the folder itself." : {
"comment" : "A description of the files Scarf left in place when uninstalling a template.",
"isCommentAutoGenerated" : true
@@ -23737,6 +23845,10 @@
}
}
},
"Tool calls hydrating in the background…" : {
"comment" : "A placeholder text that appears in the activity log.",
"isCommentAutoGenerated" : true
},
"Tool Filters" : {
"localizations" : {
"de" : {
@@ -24512,6 +24624,9 @@
}
}
}
},
"Use %@" : {
},
"Use `{{argument}}` to substitute the user's input. `{{argument | default: \"…\"}}` provides a fallback when the user invokes the command without arguments." : {
"comment" : "A description of how to use the placeholder syntax in a slash command prompt.",