From 53df32429605742603ad671269cff02c311439e5 Mon Sep 17 00:00:00 2001 From: Callum McIntyre Date: Fri, 1 May 2026 20:00:35 +0000 Subject: [PATCH] Fix chat unread: don't mark unread if user viewed the streaming response Add hasSeenCurrentTurnResponse tracking to ChatViewModel so that a false-unread is not triggered when isActive briefly drops to false at the exact moment the result event fires (e.g. view lifecycle timing during a NavigationStack transition). The flag is set via didSet observers on both `status` (when transitioning to .streaming while isActive) and `isActive` (when the user opens a chat already in .streaming). It is reset at the start of each new user turn (sendMessage) and each reconnect (reconnectIfNeeded). markChatUnread now guards on !isActive && !hasSeenCurrentTurnResponse, satisfying both "chat is not open" and "user genuinely hasn't seen this response" before setting the unread indicator. Adds 6 unit tests covering the key scenarios. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Wisp/ViewModels/ChatViewModel.swift | 25 ++++++++-- WispTests/ChatViewModelTests.swift | 71 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/Wisp/ViewModels/ChatViewModel.swift b/Wisp/ViewModels/ChatViewModel.swift index 0757650..61e1b9b 100644 --- a/Wisp/ViewModels/ChatViewModel.swift +++ b/Wisp/ViewModels/ChatViewModel.swift @@ -46,7 +46,13 @@ final class ChatViewModel { let chatId: UUID var messages: [ChatMessage] = [] var inputText = "" - var status: ChatStatus = .idle + var status: ChatStatus = .idle { + didSet { + if isActive, case .streaming = status { + hasSeenCurrentTurnResponse = true + } + } + } var modelName: String? var modelOverride: ClaudeModel? var remoteSessions: [ClaudeSessionEntry] = [] @@ -78,7 +84,18 @@ final class ChatViewModel { private var apiClient: SpritesAPIClient? /// Set by SpriteDetailView to indicate this chat is currently being viewed. /// When true, result events do not trigger the unread indicator. - var isActive: Bool = false + var isActive: Bool = false { + didSet { + if isActive, case .streaming = status { + hasSeenCurrentTurnResponse = true + } + } + } + /// True if the user was viewing this chat while the current turn was streaming. + /// Prevents a false-unread when isActive briefly drops to false at the exact + /// moment the result event fires (e.g. due to view lifecycle timing). + /// Reset to false each time a new user message is sent or a reconnect begins. + private var hasSeenCurrentTurnResponse: Bool = false /// UUIDs of Claude NDJSON events already processed. /// Used by reconnect to skip already-handled events instead of clearing content. @@ -820,6 +837,7 @@ final class ChatViewModel { let worktreeEnabled = UserDefaults.standard.bool(forKey: "worktreePerChat") let needsWorktreeSetup = isFirstMessage && worktreePath == nil && worktreeEnabled + hasSeenCurrentTurnResponse = false status = .connecting // Cancel any orphaned reconnect task (e.g., reconnectIfNeeded fired in the same // run-loop turn before the task body had a chance to set .reconnecting). @@ -950,6 +968,7 @@ final class ChatViewModel { // the session wasn't complete, so no need to wait for the task to start before // the UI reflects that we're reconnecting. reattachToExec also sets this, but // setting synchronously here avoids a brief idle flash while the Task warms up. + hasSeenCurrentTurnResponse = false status = .reconnecting // Cancel any orphaned task that may still be running (e.g., from a concurrent @@ -1938,7 +1957,7 @@ final class ChatViewModel { } private func markChatUnread(modelContext: ModelContext) { - guard !isActive else { return } + guard !isActive && !hasSeenCurrentTurnResponse else { return } guard let chat = fetchChat(modelContext: modelContext) else { return } chat.isUnread = true try? modelContext.save() diff --git a/WispTests/ChatViewModelTests.swift b/WispTests/ChatViewModelTests.swift index a067e98..33fd6a7 100644 --- a/WispTests/ChatViewModelTests.swift +++ b/WispTests/ChatViewModelTests.swift @@ -1574,4 +1574,75 @@ struct ChatViewModelTests { #expect(lines.count == 3) // system + assistant + result only } + + // MARK: - Unread logic + + private func makeResultEvent(sessionId: String = "sess-1") -> ClaudeStreamEvent { + .result(ClaudeResultEvent( + type: "result", subtype: nil, sessionId: sessionId, + isError: false, durationMs: nil, numTurns: nil, result: nil, uuid: nil + )) + } + + @Test func unread_markedWhenNotActiveAndNotSeen() throws { + let ctx = try makeModelContext() + let (vm, chat) = makeChatViewModel(modelContext: ctx) + // isActive = false (default), hasSeenCurrentTurnResponse = false (default) + vm.handleEvent(makeResultEvent(), modelContext: ctx) + #expect(chat.isUnread == true) + } + + @Test func unread_notMarkedWhenActive() throws { + let ctx = try makeModelContext() + let (vm, chat) = makeChatViewModel(modelContext: ctx) + vm.isActive = true + vm.handleEvent(makeResultEvent(), modelContext: ctx) + #expect(chat.isUnread == false) + } + + @Test func unread_notMarkedWhenSeenDuringStreaming() throws { + let ctx = try makeModelContext() + let (vm, chat) = makeChatViewModel(modelContext: ctx) + // Simulate: user was watching while status was .streaming + vm.isActive = true + vm.status = .streaming // triggers status.didSet → hasSeenCurrentTurnResponse = true + vm.isActive = false // user navigates away right at result time + vm.handleEvent(makeResultEvent(), modelContext: ctx) + #expect(chat.isUnread == false) + } + + @Test func unread_notMarkedWhenActivatedMidStream() throws { + let ctx = try makeModelContext() + let (vm, chat) = makeChatViewModel(modelContext: ctx) + // Simulate: streaming started while inactive, then user opened the chat + vm.status = .streaming + vm.isActive = true // triggers isActive.didSet → hasSeenCurrentTurnResponse = true + vm.isActive = false // user navigates away + vm.handleEvent(makeResultEvent(), modelContext: ctx) + #expect(chat.isUnread == false) + } + + @Test func unread_seenFlagNotSetDuringConnecting() throws { + let ctx = try makeModelContext() + let (vm, chat) = makeChatViewModel(modelContext: ctx) + // .connecting is not .streaming — should not set hasSeenCurrentTurnResponse + vm.isActive = true + vm.status = .connecting + vm.isActive = false + vm.handleEvent(makeResultEvent(), modelContext: ctx) + #expect(chat.isUnread == true) + } + + @Test func unread_seenFlagIgnoredWhenConnectingNotStreaming() throws { + let ctx = try makeModelContext() + let (vm, chat) = makeChatViewModel(modelContext: ctx) + // .connecting → .streaming transitions should only set the flag once streaming begins + vm.isActive = true + vm.status = .connecting // not .streaming — flag not set + vm.status = .streaming // now .streaming — flag IS set + vm.isActive = false + vm.handleEvent(makeResultEvent(), modelContext: ctx) + // User watched during .streaming → should NOT be marked unread + #expect(chat.isUnread == false) + } }