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