fix(chat): preserve local user messages across resume cycles (#63)

When a user sent a prompt and immediately switched to a different
session before Hermes flushed the row to state.db, `resumeSession`
ran `reset()` (which clears `messages`) and then
`loadSessionHistory` read the un-persisted DB and replaced the
array with an empty result. The user's bubble came back blank or
disappeared on return.

Hold local-only user messages (negative ids) in a per-session
cache that survives `reset()`. `loadSessionHistory` re-injects any
still-pending entries for the loaded session, dedups against any
DB row that finally caught up (matching content with persisted id
≥ 0), and clears the cache as the DB confirms each entry.

Cache is bounded by sessions sent-in during one app run; entries
clean themselves out as Hermes persists, and orphaned entries
(deleted sessions etc.) are tiny and never re-surface since
session ids are unique per session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 15:33:37 +02:00
parent 31e6c31acf
commit ec47d191a1
@@ -354,6 +354,19 @@ public final class RichChatViewModel {
/// spinner and we don't fan out duplicate page requests.
public private(set) var isLoadingEarlier: Bool = false
private var nextLocalId = -1
/// Issue #63: locally-created user messages awaiting state.db
/// persistence, keyed by session id. ACP roundtrips Hermes' DB
/// write asynchronously, so a user who sends a prompt and
/// immediately switches to another session triggers `reset()`
/// before Hermes flushes the row `loadSessionHistory` then reads
/// from a DB that doesn't have the message yet, and the bubble
/// renders blank or vanishes on return. We hold a per-session
/// copy here that survives `reset()` so `loadSessionHistory` can
/// re-inject anything still in flight, and clean entries out as
/// soon as a matching DB row appears.
private var pendingLocalUserMessages: [String: [HermesMessage]] = [:]
private var streamingAssistantText = ""
private var streamingThinkingText = ""
private var streamingToolCalls: [HermesToolCall] = []
@@ -468,6 +481,12 @@ public final class RichChatViewModel {
reasoning: nil
)
messages.append(message)
// Track the local message in the pending-user-messages cache
// so a reset/resume cycle on this session before Hermes
// persists the row can still re-inject it on return (#63).
if let sid = sessionId {
pendingLocalUserMessages[sid, default: []].append(message)
}
// Per-turn stopwatch (v2.5): record the start time only when
// we're entering a fresh agent turn. /steer-style mid-run sends
// arrive while isAgentWorking is already true; preserve the
@@ -972,9 +991,47 @@ public final class RichChatViewModel {
}
}
// Issue #63 re-inject any locally-created user messages
// we still have on file for this session that haven't yet
// shown up in state.db. Covers two paths:
// 1. The user just sent a prompt then resumed a different
// session before Hermes persisted the row. `reset()` had
// cleared `messages` but the per-session pending cache
// survived; restore the row here so the bubble doesn't
// come back blank.
// 2. The DB-resume path on first load a previously-pending
// message Hermes is still mid-write may not appear in
// this fetch. We merge it in, and drop it from the cache
// as soon as a matching DB row (same content, persisted
// id 0) shows up.
let pendingForSession = pendingLocalUserMessages[sessionId] ?? []
if pendingForSession.isEmpty {
messages = allMessages
} else {
var merged = allMessages
var stillPending: [HermesMessage] = []
for local in pendingForSession {
let persisted = merged.contains { msg in
msg.isUser && msg.id >= 0 && msg.content == local.content
}
if persisted {
continue // DB caught up drop the local copy
}
if !merged.contains(where: { $0.id == local.id }) {
merged.append(local)
}
stillPending.append(local)
}
merged.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
messages = merged
if stillPending.isEmpty {
pendingLocalUserMessages.removeValue(forKey: sessionId)
} else {
pendingLocalUserMessages[sessionId] = stillPending
}
}
currentSession = session
let minId = allMessages.map(\.id).min() ?? 0
let minId = messages.map(\.id).min() ?? 0
nextLocalId = min(minId - 1, -1)
// Track the oldest loaded id from THIS session (not the merged
// origin) so `loadEarlier()` pages back through the live ACP