Skip to content
Merged
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
1 change: 1 addition & 0 deletions Packages/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ let package = Package(
targets: [
.target(
name: "MessageList",
dependencies: ["RxCodeCore"],
path: "Sources/MessageList",
swiftSettings: [
.defaultIsolation(MainActor.self),
Expand Down
45 changes: 16 additions & 29 deletions Packages/Sources/MessageList/MessageList.swift
Original file line number Diff line number Diff line change
@@ -1,40 +1,23 @@
import Foundation
import RxCodeCore
import SwiftUI
import os

/// Temporary diagnostics for the "list scrolls a lot when streaming finishes"
/// report. Counts every real `proxy.scrollTo(bottomAnchor)` call and logs it
/// with a millisecond timestamp + reason so we can see how many fire and from
/// which trigger. Remove once the scroll-churn is understood.
/// Counts scroll activity without logging on the hot main-actor path. The app
/// periodically drains these counters into its performance diagnostic file.
@MainActor
enum ScrollToBottomDiag {
/// Counts only real `proxy.scrollTo(bottomAnchor)` calls — the answer to
/// "how many scroll-to-bottom API calls were triggered".
private static var count = 0
/// Counts requests that were *asked for* but bailed before any scroll API
/// call (spacer absorbed the growth, or a pin owns the position). Kept on a
/// separate counter/label so it never inflates the actual-call count above.
private static var skippedCount = 0
private static let log = Logger(subsystem: "com.claudework", category: "ScrollToBottomDiag")
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f
}()

/// Log an actual `proxy.scrollTo` call. Invoke immediately before the real
/// scroll so the count stays an accurate tally of scroll API invocations.
static func record(_ reason: String, animated: Bool, streaming: Bool) {
count += 1
let ts = formatter.string(from: Date())
log.info("[ScrollToBottom] #\(count, privacy: .public) \(ts, privacy: .public) reason=\(reason, privacy: .public) animated=\(animated, privacy: .public) streaming=\(streaming, privacy: .public)")
PerformanceDiagnostics.increment("scroll.actual.total")
PerformanceDiagnostics.increment("scroll.actual.reason.\(reason)")
if animated { PerformanceDiagnostics.increment("scroll.actual.animated") }
if streaming { PerformanceDiagnostics.increment("scroll.actual.streaming") }
}

/// Log a scroll request that was requested but skipped (no scroll API call).
static func recordSkipped(_ reason: String, animated: Bool, streaming: Bool) {
skippedCount += 1
let ts = formatter.string(from: Date())
log.info("[ScrollToBottomSkipped] #\(skippedCount, privacy: .public) \(ts, privacy: .public) reason=\(reason, privacy: .public) animated=\(animated, privacy: .public) streaming=\(streaming, privacy: .public)")
PerformanceDiagnostics.increment("scroll.skipped.total")
PerformanceDiagnostics.increment("scroll.skipped.reason.\(reason)")
if animated { PerformanceDiagnostics.increment("scroll.skipped.animated") }
if streaming { PerformanceDiagnostics.increment("scroll.skipped.streaming") }
}
}

Expand Down Expand Up @@ -438,7 +421,11 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
}

private func scheduleScrollToBottom(proxy: ScrollViewProxy) {
guard bottomScrollTask == nil else { return }
guard bottomScrollTask == nil else {
PerformanceDiagnostics.increment("scroll.schedule.coalesced")
return
}
PerformanceDiagnostics.increment("scroll.schedule.created")
let delayNanoseconds = scrollToBottomDelayNanoseconds()
bottomScrollTask = Task { @MainActor in
if delayNanoseconds > 0 {
Expand Down
68 changes: 22 additions & 46 deletions Packages/Sources/RxCodeChatKit/MessageListView.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import SwiftUI
import Combine
import RxCodeCore
import os

#if os(macOS)

Expand Down Expand Up @@ -58,19 +57,6 @@ struct MessageListView: View {
@State private var isAtBottom = true
@State private var scrollRequestTask: Task<Void, Never>?

private static let log = Logger(subsystem: "com.claudework", category: "MessageListView")
/// Temporary diagnostics: counts scroll-to-bottom *requests* (pre-coalesce)
/// so we can correlate per-token request churn with the actual scrollTo calls
/// logged in MessageList. Remove once scroll-churn is understood.
private static var scrollRequestCount = 0
private static let diagTimestampFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f
}()
private static func diagTimestamp() -> String {
diagTimestampFormatter.string(from: Date())
}
private static let bottomAnchorID = "message-list-bottom-anchor"
private static let endOfScreenAnchorID = "message-list-end-of-screen"
private static let userScrollDownDelta: CGFloat = 4
Expand Down Expand Up @@ -193,10 +179,10 @@ struct MessageListView: View {
requestScrollToBottomIfAtBottom()
}
.onChange(of: isSessionReady) { _, new in
Self.log.info("[MessageList.ready] isSessionReady=\(new) sid=\(windowState.currentSessionId ?? "<nil>", privacy: .public) settled=\(settledItems.count)")
PerformanceDiagnostics.increment(new ? "chat.session_ready.true" : "chat.session_ready.false")
}
.onChange(of: settledItems.count) { _, new in
Self.log.info("[MessageList.settled] settled=\(new) sid=\(windowState.currentSessionId ?? "<nil>", privacy: .public) isSessionReady=\(isSessionReady) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)")
PerformanceDiagnostics.record("chat.settled_items", value: Double(new))
}
.overlay {
if settledItems.isEmpty && !chatBridge.isStreaming && windowState.currentSessionId == nil {
Expand All @@ -209,8 +195,8 @@ struct MessageListView: View {
// MARK: - Session lifecycle

private func handleSessionTask() async {
let sid = windowState.currentSessionId ?? "<nil>"
Self.log.info("[MessageList.task] fired sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) isStreaming=\(chatBridge.isStreaming) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)")
PerformanceDiagnostics.increment("chat.session_task.fired")
PerformanceDiagnostics.record("chat.bridge_messages", value: Double(chatBridge.messages.count))
// When the CLI emits its first `system:init` event mid-stream, AppState
// swaps currentSessionId from the local "pending-..." placeholder to
// the real CLI sid. That id change re-fires this task even though the
Expand All @@ -220,7 +206,7 @@ struct MessageListView: View {
rebuildSettledItems()
if !isSessionReady { isSessionReady = true }
requestScrollToBottomIfAtBottom()
Self.log.info("[MessageList.task] streaming-path settled=\(settledItems.count) sid=\(sid, privacy: .public)")
PerformanceDiagnostics.increment("chat.session_task.streaming_path")
return
}
isSessionReady = false
Expand All @@ -236,16 +222,12 @@ struct MessageListView: View {
pendingIndicatorSpacerReduction = 0
activeTurnMaxMeasuredHeight = 0
rebuildSettledItems()
Self.log.info("[MessageList.task] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)")
// Empty sessions appear instantly — unless we're still loading persisted
// messages from disk, in which case `handleLoadingChange` fades the list
// in once messages arrive, avoiding the empty → populated "blink".
guard !settledItems.isEmpty else {
if !chatBridge.isLoadingFromDisk {
isSessionReady = true
Self.log.info("[MessageList.task] empty + not-loading → ready sid=\(sid, privacy: .public)")
} else {
Self.log.info("[MessageList.task] empty + still-loading → waiting for disk sid=\(sid, privacy: .public)")
}
return
}
Expand All @@ -259,13 +241,11 @@ struct MessageListView: View {
}

private func handleLoadingChange(_ isLoading: Bool) {
let sid = windowState.currentSessionId ?? "<nil>"
Self.log.info("[MessageList.onLoadChange] isLoading=\(isLoading) sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) settled=\(settledItems.count)")
PerformanceDiagnostics.increment(isLoading ? "chat.disk_loading.started" : "chat.disk_loading.finished")
// When a background disk load finishes for a freshly switched session,
// rebuild the settled list and fade in — same sequence as the .task above.
guard !isLoading else { return }
rebuildSettledItems()
Self.log.info("[MessageList.onLoadChange] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public)")
readyTask?.cancel()
requestScrollToBottom()
anchor.resetToBottom()
Expand All @@ -279,7 +259,7 @@ struct MessageListView: View {
}

private func handleStreamingChange(old: Bool, new: Bool) {
logScrollState("streamingChange", extra: "old=\(old) new=\(new) lastRole=\(chatBridge.messages.last?.role.rawValue ?? "<nil>")")
logScrollState("streamingChange")
let wasAtBottom = isAtBottom
// Only react when streaming ends — the settled list doesn't change at start.
guard old && !new else {
Expand Down Expand Up @@ -361,6 +341,7 @@ struct MessageListView: View {
// MARK: - Settled Items

private func rebuildSettledItems() {
PerformanceDiagnostics.increment("chat.settled_rebuild.total")
let messages = settledOnlyMessages(from: chatBridge.messages)
var t = Transaction()
t.animation = nil
Expand All @@ -372,7 +353,7 @@ struct MessageListView: View {
let wasAtBottom = isAtBottom
rebuildSettledItems()
guard let last = chatBridge.messages.last else { return }
logScrollState("lastMessageChange", extra: "lastRole=\(last.role.rawValue) lastID=\(last.id.uuidString)")
logScrollState("lastMessageChange")
if last.role != .user {
requestScrollToBottomIfAtBottom(wasAtBottom)
}
Expand All @@ -399,9 +380,11 @@ struct MessageListView: View {
// churn. If a request with the same animation intent is already in
// flight, let it land instead of rebuilding it; only a differing intent
// (e.g. the non-animated stream-end re-assert) supersedes it.
Self.scrollRequestCount += 1
let coalesced = scrollRequestTask != nil && scrollToBottomAnimated == animated
Self.log.info("[ScrollRequest] #\(Self.scrollRequestCount, privacy: .public) \(Self.diagTimestamp(), privacy: .public) animated=\(animated, privacy: .public) coalesced=\(coalesced, privacy: .public) streaming=\(chatBridge.isStreaming, privacy: .public)")
PerformanceDiagnostics.increment("chat.scroll_request.total")
if coalesced { PerformanceDiagnostics.increment("chat.scroll_request.coalesced") }
if chatBridge.isStreaming { PerformanceDiagnostics.increment("chat.scroll_request.streaming") }
if animated { PerformanceDiagnostics.increment("chat.scroll_request.animated") }
if scrollRequestTask != nil, scrollToBottomAnimated == animated {
return
}
Expand Down Expand Up @@ -537,7 +520,7 @@ struct MessageListView: View {
/// drag — our own `.animating` scroll is not user-driven.
private func settleAtBottom(proxy: ScrollViewProxy, reason: String) {
settleScrollTask?.cancel()
Self.log.info("[ScrollSettle] reason=\(reason, privacy: .public) sid=\(windowState.currentSessionId ?? "<nil>", privacy: .public) settled=\(settledItems.count)")
PerformanceDiagnostics.increment("chat.scroll_settle.\(reason)")
settleScrollTask = Task { @MainActor in
for _ in 0..<12 {
guard !Task.isCancelled, !isUserDrivenScroll else { return }
Expand All @@ -554,12 +537,12 @@ struct MessageListView: View {
settleScrollTask?.cancel()
pinToTopTask?.cancel()
canReleasePinnedTurnByScroll = false
logScrollState("pinTop.scheduled", extra: "target=\(id.uuidString) animated=\(animated)")
logScrollState("pinTop.scheduled")
pinToTopTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(16))
if animated {
guard !Task.isCancelled else { return }
logScrollState("pinTop.animated", extra: "target=\(id.uuidString)")
logScrollState("pinTop.animated")
withAnimation(.easeInOut(duration: Self.pinToTopAnimationSeconds)) {
proxy.scrollTo(id, anchor: .top)
}
Expand All @@ -568,7 +551,7 @@ struct MessageListView: View {
for attempt in 0..<8 {
guard !Task.isCancelled else { return }
if attempt == 0 || attempt == 7 {
logScrollState("pinTop.settle", extra: "target=\(id.uuidString) attempt=\(attempt)")
logScrollState("pinTop.settle")
}
var transaction = Transaction()
transaction.animation = nil
Expand All @@ -579,7 +562,7 @@ struct MessageListView: View {
}
guard !Task.isCancelled, isPinningLatestTurnToTop else { return }
canReleasePinnedTurnByScroll = true
logScrollState("pinTop.armedRelease", extra: "target=\(id.uuidString)")
logScrollState("pinTop.armedRelease")
}
}

Expand Down Expand Up @@ -611,7 +594,7 @@ struct MessageListView: View {
transaction.animation = nil
withTransaction(transaction) { latestUserMinY = value }
updateActiveTurnMaxMeasuredHeight()
logScrollState("latestUserMinY.updated", extra: "value=\(value)")
logScrollState("latestUserMinY.updated")
}

/// `minY` of the tail spacer — its distance from the user message is the
Expand All @@ -628,7 +611,7 @@ struct MessageListView: View {
}
}
updateActiveTurnMaxMeasuredHeight()
logScrollState("tailSpacerMinY.updated", extra: "value=\(value) old=\(oldValue)")
logScrollState("tailSpacerMinY.updated")
}

private var rawActiveTurnMeasuredHeight: CGFloat {
Expand All @@ -652,15 +635,8 @@ struct MessageListView: View {
}
}

private func logScrollState(_ label: String, extra: String = "") {
let active = activeTurnUserMessageID?.uuidString ?? "<nil>"
let rawTurnHeight = rawActiveTurnMeasuredHeight
let latestTurnHeight = max(activeTurnMaxMeasuredHeight, rawTurnHeight)
let extraSpacer = pinTailSpacerExtraHeight
let suffix = extra.isEmpty ? "" : " \(extra)"
Self.log.info(
"[ScrollPin] \(label, privacy: .public) sid=\(windowState.currentSessionId ?? "<nil>", privacy: .public) active=\(active, privacy: .public) pinning=\(isPinningLatestTurnToTop) releaseArmed=\(canReleasePinnedTurnByScroll) userDriven=\(isUserDrivenScroll) directUser=\(isDirectUserScroll) stream=\(chatBridge.isStreaming) scrollH=\(Double(scrollViewHeight), privacy: .public) userY=\(Double(latestUserMinY), privacy: .public) tailY=\(Double(tailSpacerMinY), privacy: .public) rawTurnH=\(Double(rawTurnHeight), privacy: .public) turnH=\(Double(latestTurnHeight), privacy: .public) minTail=\(Double(minTailSpacer), privacy: .public) extraSpacer=\(Double(extraSpacer), privacy: .public) pendingReduce=\(Double(pendingIndicatorSpacerReduction), privacy: .public)\(suffix, privacy: .public)"
)
private func logScrollState(_ label: String) {
PerformanceDiagnostics.increment("chat.scroll_state.\(label)")
}
}

Expand Down
30 changes: 27 additions & 3 deletions Packages/Sources/RxCodeCore/Models/StreamEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,24 @@ public struct SystemEvent: Sendable {
public let tools: [String]?
public let model: String?
public let claudeCodeVersion: String?

public init(subtype: String, sessionId: String?, tools: [String]?, model: String?, claudeCodeVersion: String?) {
/// Background-task id for `task_started` / `task_updated` / `task_notification`
/// system events. Recent Claude Code runs long tasks (background shells,
/// subagents) as "backend agents" that outlive the turn that spawned them.
public let taskId: String?
/// Normalized status for a background-task event: the top-level `status`
/// (task_notification) or `patch.status` (task_updated). Nil for
/// `task_started` (which has no status and means "running").
public let taskStatus: String?

public init(subtype: String, sessionId: String?, tools: [String]?, model: String?,
claudeCodeVersion: String?, taskId: String? = nil, taskStatus: String? = nil) {
self.subtype = subtype
self.sessionId = sessionId
self.tools = tools
self.model = model
self.claudeCodeVersion = claudeCodeVersion
self.taskId = taskId
self.taskStatus = taskStatus
}
}

Expand Down Expand Up @@ -134,16 +145,29 @@ public struct ResultEvent: Sendable {
public let totalTurns: Int?
public let usage: UsageInfo?
public let contextWindow: ContextWindowInfo?
/// `origin.kind` from the CLI's `result` message. Recent Claude Code emits
/// autonomous follow-up results for background/"backend agent" work tagged
/// `origin.kind == "task-notification"`. A user-driven turn's own result has
/// no origin (or a non-task-notification kind). See `isTaskNotification`.
public let originKind: String?

/// True when this result is an autonomous background follow-up rather than
/// the end of the user's turn. Such results must NOT tear down the turn
/// (keep streaming, keep the process alive). Mirrors `claude-agent-acp`'s
/// `message.origin?.kind === "task-notification"` gate.
public var isTaskNotification: Bool { originKind == "task-notification" }

public init(durationMs: Double?, totalCostUsd: Double?, sessionId: String, isError: Bool,
totalTurns: Int?, usage: UsageInfo?, contextWindow: ContextWindowInfo?) {
totalTurns: Int?, usage: UsageInfo?, contextWindow: ContextWindowInfo?,
originKind: String? = nil) {
self.durationMs = durationMs
self.totalCostUsd = totalCostUsd
self.sessionId = sessionId
self.isError = isError
self.totalTurns = totalTurns
self.usage = usage
self.contextWindow = contextWindow
self.originKind = originKind
}
}

Expand Down
Loading
Loading