From 6b66b1c96fd0726e01c05792d2bd7c50fcec5749 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 5 May 2026 21:26:25 +0200 Subject: [PATCH] =?UTF-8?q?perf(ios):=20wire=20v2.7=20perf=20parity=20?= =?UTF-8?q?=E2=80=94=20instrument=20iOS-only=20VMs=20+=20surface=20hydrati?= =?UTF-8?q?on=20banner=20+=20opt-in=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ViewModels/IOSCronViewModel.swift | 27 ++++++++++------- .../ViewModels/IOSMemoryViewModel.swift | 23 ++++++++++----- scarf/Scarf iOS/Chat/ChatView.swift | 10 +++++++ scarf/Scarf iOS/Settings/SettingsView.swift | 29 +++++++++++++++++++ 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift index cc77aeb..f8b373c 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift @@ -29,17 +29,24 @@ public final class IOSCronViewModel { let ctx = context let path = ctx.paths.cronJobsJSON - let result: Result = 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 = 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.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): diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift index da45e2f..0dd3206 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift @@ -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 = await Task.detached { - do { - return .success(try ctx.readTextThrowing(path)) - } catch { - return .failure(error) - } - }.value + let result: Result = await ScarfMon.measureAsync(.diskIO, "ios.memory.load") { + await Task.detached { + do { + return Result.success(try ctx.readTextThrowing(path)) + } catch { + return Result.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)): diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 7d9852f..b552c56 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -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() } diff --git a/scarf/Scarf iOS/Settings/SettingsView.swift b/scarf/Scarf iOS/Settings/SettingsView.swift index fc88a25..75b33ec 100644 --- a/scarf/Scarf iOS/Settings/SettingsView.swift +++ b/scarf/Scarf iOS/Settings/SettingsView.swift @@ -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