From 09e33b299931681704441b1e0f451608c16bcf97 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 5 May 2026 19:43:53 +0200 Subject: [PATCH] perf(chat,activity,transport): skeleton-then-hydrate loaders + SSH cancellation propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/...` 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 ?" 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) --- .../Sources/ScarfCore/ACP/ACPClient.swift | 16 + .../ScarfCore/Models/HermesMessage.swift | 42 +++ .../Backends/RemoteSQLiteBackend.swift | 20 +- .../Services/HermesDataService.swift | 336 +++++++++++++++++- .../ScarfCore/Services/ModelPreflight.swift | 41 +++ .../ScarfCore/Transport/SSHScriptRunner.swift | 72 +++- .../ViewModels/ActivityViewModel.swift | 128 ++++++- .../ViewModels/RichChatViewModel.swift | 215 ++++++++++- .../Activity/Views/ActivityView.swift | 93 ++++- .../Features/Chat/ChatDensitySettings.swift | 11 + .../Chat/ViewModels/ChatViewModel.swift | 239 +++++++++++-- .../Chat/Views/ChatInspectorPane.swift | 10 + .../Chat/Views/ChatSessionListPane.swift | 25 ++ .../scarf/Features/Chat/Views/ChatView.swift | 54 +++ .../Views/CredentialPoolsView.swift | 107 +++++- .../Settings/Views/Tabs/DisplayTab.swift | 16 + scarf/scarf/Localizable.xcstrings | 123 ++++++- 17 files changed, 1461 insertions(+), 87 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift index 1b5b805..7278fbf 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift @@ -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 } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift index 233bd8e..2996f68 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift @@ -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 } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift index 0c6c6da..83e1d25 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift @@ -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]] { diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift index 9e48128..2bcf193 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift @@ -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.. [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) + } } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift index 56efaa6..fa71c36 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift @@ -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 `/...` + /// 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[.. 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() diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ActivityViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ActivityViewModel.swift index 7ec0f2e..5d17d47 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ActivityViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ActivityViewModel.swift @@ -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? public var availableSessions: [(id: String, label: String)] { var seen = Set() @@ -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 { diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 5249ffd..5debd36 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -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? + + /// 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 diff --git a/scarf/scarf/Features/Activity/Views/ActivityView.swift b/scarf/scarf/Features/Activity/Views/ActivityView.swift index f81b374..118dab3 100644 --- a/scarf/scarf/Features/Activity/Views/ActivityView.swift +++ b/scarf/scarf/Features/Activity/Views/ActivityView.swift @@ -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 } } diff --git a/scarf/scarf/Features/Chat/ChatDensitySettings.swift b/scarf/scarf/Features/Chat/ChatDensitySettings.swift index f23f892..be5a484 100644 --- a/scarf/scarf/Features/Chat/ChatDensitySettings.swift +++ b/scarf/scarf/Features/Chat/ChatDensitySettings.swift @@ -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. diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 7379d9d..1ed2d77 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -48,6 +48,17 @@ final class ChatViewModel { @ObservationIgnored private var sessionsRefreshTask: Task? + /// L2 (v2.8) — in-flight coalescing handle for `loadRecentSessions`. + /// On a slow remote each load is a 1.5–2.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 2–3 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? + /// 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 = [ + 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 5–7s 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 diff --git a/scarf/scarf/Features/Chat/Views/ChatInspectorPane.swift b/scarf/scarf/Features/Chat/Views/ChatInspectorPane.swift index ad635a1..197388f 100644 --- a/scarf/scarf/Features/Chat/Views/ChatInspectorPane.swift +++ b/scarf/scarf/Features/Chat/Views/ChatInspectorPane.swift @@ -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 diff --git a/scarf/scarf/Features/Chat/Views/ChatSessionListPane.swift b/scarf/scarf/Features/Chat/Views/ChatSessionListPane.swift index 514c101..a9c0178 100644 --- a/scarf/scarf/Features/Chat/Views/ChatSessionListPane.swift +++ b/scarf/scarf/Features/Chat/Views/ChatSessionListPane.swift @@ -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) diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index 2587393..042edc8 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -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 + ) } } diff --git a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift index 9c53802..866320a 100644 --- a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift +++ b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift @@ -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 / diff --git a/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift index 34c79a5..071ec14 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift @@ -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() } diff --git a/scarf/scarf/Localizable.xcstrings b/scarf/scarf/Localizable.xcstrings index 4c9db9b..4b4cc76 100644 --- a/scarf/scarf/Localizable.xcstrings +++ b/scarf/scarf/Localizable.xcstrings @@ -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..` 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.",