perf(ios): wire v2.7 perf parity — instrument iOS-only VMs + surface hydration banner + opt-in toggle

Most of the v2.7 perf work was already covered on iOS via shared
code in ScarfCore — `RichChatViewModel.loadSessionHistory` (and
its skeleton-then-hydrate path), `hydrateAssistantToolCalls`,
`fetchSkeletonMessages`, `fetchRecentToolCallSkeleton`,
`ModelPreflight.detectMismatch`, and the `RemoteSQLiteBackend`
cancellation handler all flow through to the ScarfGo chat
unchanged. `CitadelServerTransport.streamScript` already
honors `Task.isCancelled` correctly via `withThrowingTaskGroup` +
`Task.checkCancellation()`, so the SSH-cancellation-on-nav-away
chain works on iOS without the Mac-side `SSHScriptRunner` fix.

Three iOS-specific gaps closed:

* IOSCronViewModel.load + IOSMemoryViewModel.load wrapped in
  `ScarfMon.measureAsync(.diskIO, "ios.cron.load")` /
  `"ios.memory.load"` — parity with the Mac `cron.load` /
  `memory.load` events. `ios.memory.load.bytes` records the
  payload size for the loaded file.
* iOS Settings → "Chat (Scarf)" section gains a toggle bound to
  `RichChatViewModel.loadHistoricalToolResultsKey` so iOS users
  can opt into Phase 2b bulk tool-result hydration, same as the
  Mac DisplayTab. The shared key means the gate inside
  `startToolHydration` reads the right value automatically — no
  extra plumbing needed.
* iOS ChatView surfaces `isHydratingTools` as a "Loading tool
  details…" connection banner (mirrors the Mac toolbar pill
  added in v2.7 perf work). Sits between the existing
  "Thinking…" banner and the empty-view fallback so chat status
  is always honest about what the agent and Scarf are doing.

Both Mac and iOS targets build clean; all 321 ScarfCore tests
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-05 21:26:25 +02:00
parent 97ec4d2882
commit 6b66b1c96f
4 changed files with 72 additions and 17 deletions
@@ -29,17 +29,24 @@ public final class IOSCronViewModel {
let ctx = context
let path = ctx.paths.cronJobsJSON
let result: Result<CronJobsFile, Error> = await Task.detached {
do {
guard let data = ctx.readData(path) else {
throw LoadError.missingFile(path: path)
// v2.7 instrumented for parity with Mac `cron.load`. iOS
// Cron load is a single SFTP read of jobs.json so should be
// snappy on most remotes; this measure point makes the cost
// visible in ScarfMon traces alongside the rest of the iOS
// load paths.
let result: Result<CronJobsFile, Error> = await ScarfMon.measureAsync(.diskIO, "ios.cron.load") {
await Task.detached {
do {
guard let data = ctx.readData(path) else {
throw LoadError.missingFile(path: path)
}
let decoded = try JSONDecoder().decode(CronJobsFile.self, from: data)
return .success(decoded)
} catch {
return Result<CronJobsFile, Error>.failure(error)
}
let decoded = try JSONDecoder().decode(CronJobsFile.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}.value
}.value
}
switch result {
case .success(let file):
@@ -96,15 +96,24 @@ public final class IOSMemoryViewModel {
// Run the file read on a detached task `readTextThrowing`
// blocks on transport I/O, and we don't want the MainActor
// hanging during a remote SFTP fetch.
// v2.7 instrumented for parity with Mac `memory.load`.
// iOS path is one SFTP read per Memory tab open (per kind:
// memory / user / soul); the bytes counter shows payload
// size alongside latency.
let ctx = context
let path = kind.path(on: context)
let result: Result<String?, Error> = await Task.detached {
do {
return .success(try ctx.readTextThrowing(path))
} catch {
return .failure(error)
}
}.value
let result: Result<String?, Error> = await ScarfMon.measureAsync(.diskIO, "ios.memory.load") {
await Task.detached {
do {
return Result<String?, Error>.success(try ctx.readTextThrowing(path))
} catch {
return Result<String?, Error>.failure(error)
}
}.value
}
if case .success(.some(let loaded)) = result {
ScarfMon.event(.diskIO, "ios.memory.load.bytes", count: 0, bytes: loaded.utf8.count)
}
switch result {
case .success(.some(let loaded)):
+10
View File
@@ -412,6 +412,16 @@ struct ChatView: View {
tint: ScarfColor.info,
showSpinner: true
)
} else if controller.vm.isHydratingTools {
// v2.7 Phase 2 tool-call hydration is in flight.
// Bare conversation skeleton is already on screen;
// this banner tells the user the tool cards are
// about to fill in.
connectionBannerStrip(
text: "Loading tool details…",
tint: ScarfColor.info,
showSpinner: true
)
} else {
EmptyView()
}
@@ -13,6 +13,13 @@ struct SettingsView: View {
@State private var vm: IOSSettingsViewModel
@State private var showRawYAML = false
@State private var editingSpec: SettingSpec?
/// v2.7 Scarf-local opt-in to bulk-fetch tool result CONTENT
/// when resuming past chats. Default off; the shared
/// `RichChatViewModel` reads this same UserDefaults key on
/// every chat resume so iOS gets the same skeleton-then-hydrate
/// behavior as Mac.
@AppStorage(RichChatViewModel.loadHistoricalToolResultsKey)
private var loadHistoricalToolResults: Bool = false
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
@@ -164,6 +171,28 @@ struct SettingsView: View {
yesNoRow("Inline diffs", vm.config.display.inlineDiffs)
LabeledContent("Personality", value: vm.config.personality)
}
chatScarfSection
}
/// v2.7 Scarf-local chat preferences. Mirrors the Mac Settings
/// Display "Load tool results in past chats" toggle. Lives in
/// its own section so it's clear these are app-side settings, not
/// Hermes config values.
@ViewBuilder
private var chatScarfSection: some View {
Section {
Toggle(isOn: $loadHistoricalToolResults) {
VStack(alignment: .leading, spacing: 2) {
Text("Load tool results in past chats")
.font(.body)
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.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} header: {
Text("Chat (Scarf)")
}
}
@ViewBuilder