fix(chat): debounce sidebar reloads so sessions list doesn't flicker mid-stream

ChatView's `.onChange(of: fileWatcher.lastChangeDate)` fired an
unconditional `Task { await viewModel.loadRecentSessions() }` on
every file-watcher tick. During an ACP message stream the watcher
fires 5–10 times per second (every message Hermes persists bumps
`state.db-wal`'s mtime), and each spawned task re-fetched sessions +
previews + project attribution and reassigned `recentSessions` even
though the data was identical. Each reassignment triggered an
@Observable re-render of the chat sidebar; the user saw the chats
list visibly disappear and reappear several times while typing the
first message in a new chat.

Two changes:

* Add `scheduleSessionsRefresh()` to ChatViewModel — coalesces rapid
  ticks into one trailing `loadRecentSessions()` ~500 ms after the
  last tick. ChatView's onChange now calls this instead. The 500 ms
  window is short enough that idle external changes (a session
  created from another `hermes` invocation, a rename from a
  different window) still appear "soon", and long enough to absorb
  a streaming-response burst.
* Add an explicit `await loadRecentSessions()` to
  `autoStartACPAndSend` after the new session id resolves — the
  debounce would otherwise delay the just-created chat from
  appearing in the sidebar by 500 ms after first send. Mirrors what
  `startACPSession` already does at line 619 for the explicit New /
  Resume paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 14:56:59 +02:00
parent 5afd391838
commit 3271391506
2 changed files with 57 additions and 1 deletions
@@ -34,6 +34,20 @@ final class ChatViewModel {
var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:]
/// Debounce handle for watcher-driven `loadRecentSessions` calls.
/// During an active ACP conversation the file watcher fires many
/// times per second (every message Hermes persists writes to
/// `state.db-wal`); without this, every tick spawned a fresh
/// reload task whose `recentSessions = ` reassignment re-rendered
/// the chat sidebar and caused the list to visibly disappear /
/// reappear during a streaming response. The debounce coalesces
/// rapid bursts into one trailing fetch ~500 ms after the last
/// tick. Created/resumed sessions still appear immediately because
/// `startACPSession` and `autoStartACPAndSend` call
/// `loadRecentSessions()` directly outside this path.
@ObservationIgnored
private var sessionsRefreshTask: 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()`.
@@ -334,6 +348,14 @@ final class ChatViewModel {
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Surface the freshly-created session in the chat
// sidebar immediately. We can't lean on the file
// watcher to do this it fires unconditionally
// through `scheduleSessionsRefresh` which has a
// 500 ms debounce. An explicit call here keeps the
// "type see new chat in the list" feedback prompt.
await loadRecentSessions()
// Now send the queued prompt
sendViaACP(client: client, text: text, images: images)
} catch {
@@ -834,6 +856,30 @@ final class ChatViewModel {
// MARK: - Recent Sessions
/// Coalesce rapid `loadRecentSessions` triggers into one trailing
/// fetch. Hooked up to the file-watcher tick in `ChatView`; during
/// an ACP message stream the watcher fires 510 times per second
/// as Hermes appends to `state.db-wal`, and an unconditional
/// reload on each tick would visibly flicker the chat sidebar
/// while the response streams in.
///
/// The 500 ms window is short enough that idle external changes
/// (a session created from another `hermes` invocation, a rename
/// from another window) still appear "soon" without explicit user
/// action, and long enough to absorb a streaming-response burst.
/// Newly created / resumed sessions in *this* window don't depend
/// on the debounce `startACPSession` and `autoStartACPAndSend`
/// call `loadRecentSessions()` synchronously after the session id
/// resolves, so the chat sidebar updates immediately.
func scheduleSessionsRefresh() {
sessionsRefreshTask?.cancel()
sessionsRefreshTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000)
if Task.isCancelled { return }
await self?.loadRecentSessions()
}
}
func loadRecentSessions() async {
let opened = await dataService.open()
guard opened else { return }
+11 -1
View File
@@ -65,7 +65,17 @@ struct ChatView: View {
}
}
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() }
// Debounced rather than immediate. During an active ACP
// message stream the watcher fires many times per second
// (every persisted message bumps `state.db-wal`'s mtime);
// an unconditional reload on each tick caused the chat
// sidebar to visibly flicker as `recentSessions` was
// reassigned over and over with the same data. The
// debounced helper coalesces bursts into one trailing
// fetch ~500 ms after the last tick. New sessions still
// appear immediately because the create/resume paths
// call `loadRecentSessions()` synchronously themselves.
viewModel.scheduleSessionsRefresh()
viewModel.refreshCredentialPreflight()
}
// Live handoff from the per-project Sessions tab: the tab