Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions Wisp/ViewModels/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
71 changes: 71 additions & 0 deletions WispTests/ChatViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading