Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ public final class ChatConversationStore {
/// non-blocking error surface. Cleared on the next success.
public private(set) var lastErrorDescription: String?

/// The row id of the latest optimistic outbound prompt, used by chat
/// surfaces to focus the just-sent message instead of leaving it buried
/// against the composer.
public private(set) var latestOutboundFocusRowID: String?

@ObservationIgnored private var messages: [ChatMessage] = []
@ObservationIgnored private var pending: [ChatPendingOutbound] = []
/// Live, not-yet-committed preview of the agent's in-progress prose for the
Expand Down Expand Up @@ -244,6 +249,7 @@ public final class ChatConversationStore {
createdAt: now(),
delivery: queueWhileBusy ? .queued : .sending
)
latestOutboundFocusRowID = ChatTranscriptRow.pendingOutboundRowID(for: item.id)
pending.append(item)
reproject()
guard !queueWhileBusy else { return }
Expand Down Expand Up @@ -638,6 +644,10 @@ public final class ChatConversationStore {
}
if let index {
let removed = pending.remove(at: index)
let removedRowID = ChatTranscriptRow.pendingOutboundRowID(for: removed.id)
if latestOutboundFocusRowID == removedRowID {
latestOutboundFocusRowID = ChatTranscriptRow.messageRowID(for: message.id)
}
Comment on lines +647 to +650

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the focus row on every authoritative removal path.

This only remaps the focus id for the pending→echo case. discard(pendingID:), .reset, and reconcileTerminalPending(against:) can still remove the focused row without clearing/remapping latestOutboundFocusRowID, which leaves the iOS transcript in a stale focused state with no valid target row. Please centralize the focus-row transition/clear logic and cover those paths in tests. As per path instructions, derived focus signals must be updated from authoritative state transitions and fail closed when the reliable signal disappears.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift`
around lines 647 - 650, The focus-row handling in ChatConversationStore is only
remapping the pending-to-echo case, so authoritative removals can leave
latestOutboundFocusRowID pointing at a deleted row. Centralize the focus
transition/clear logic around the relevant ChatConversationStore paths,
especially discard(pendingID:), reset, and reconcileTerminalPending(against:),
and ensure they clear or remap the focus when the target is removed. Add
coverage in tests for each authoritative removal path so the transcript fails
closed when the focused row disappears.

Source: Path instructions

if let counter = Self.pendingCounter(removed.id) {
maxReconciledCounter = max(maxReconciledCounter ?? counter, counter)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,19 @@ public enum ChatTranscriptRow: Identifiable, Sendable, Equatable {
case .unreadSeparator:
return "unread-separator"
case .message(let snapshot):
return "msg-\(snapshot.message.id)"
return Self.messageRowID(for: snapshot.message.id)
case .pendingOutbound(let pending):
return "pending-\(pending.id)"
return Self.pendingOutboundRowID(for: pending.id)
case .terminalCommand(let block):
return "term-\(block.id)"
}
}

public static func messageRowID(for messageID: String) -> String {
"msg-\(messageID)"
}

public static func pendingOutboundRowID(for pendingID: String) -> String {
"pending-\(pendingID)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ struct ChatConversationStoreTests {
}
)
#expect(Self.pendingItems(store.rows).first?.text == "gated prompt")
let focusedPendingID = Self.pendingItems(store.rows).first.map { ChatTranscriptRow.pendingOutbound($0).id }
#expect(store.latestOutboundFocusRowID == focusedPendingID)

await source.release()
await sendTask.value
Expand All @@ -434,6 +436,12 @@ struct ChatConversationStoreTests {
&& Self.userProseTexts(store.rows) == ["hello agent"]
}
)
let userMessageID = Self.snapshots(store.rows)
.first { $0.message.role == .user }?
.message
.id
#expect(userMessageID != nil)
#expect(store.latestOutboundFocusRowID == userMessageID.map(ChatTranscriptRow.messageRowID(for:)))
}

@Test("send failure marks the pending row failed; retry delivers it")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ public struct ChatScreen: View {
hasLoadedInitialHistory: store.hasLoadedInitialHistory,
initialLoadFailed: store.initialLoadFailed,
historyTruncatedAtHead: store.historyTruncatedAtHead,
outboundFocusRowID: store.latestOutboundFocusRowID,
actions: rowActions,
onReachTop: { Task { await store.loadOlder() } },
onRetryInitialLoad: { Task { await store.retryInitialLoad() } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public struct ChatTranscriptListView: View {
private let hasLoadedInitialHistory: Bool
private let initialLoadFailed: Bool
private let historyTruncatedAtHead: Bool
private let outboundFocusRowID: String?
private let actions: ChatRowActions
private let onReachTop: () -> Void
private let onRetryInitialLoad: () -> Void
Expand Down Expand Up @@ -55,6 +56,7 @@ public struct ChatTranscriptListView: View {
hasLoadedInitialHistory: Bool = true,
initialLoadFailed: Bool = false,
historyTruncatedAtHead: Bool = false,
outboundFocusRowID: String? = nil,
actions: ChatRowActions,
onReachTop: @escaping () -> Void,
onRetryInitialLoad: @escaping () -> Void = {}
Expand All @@ -66,6 +68,7 @@ public struct ChatTranscriptListView: View {
self.hasLoadedInitialHistory = hasLoadedInitialHistory
self.initialLoadFailed = initialLoadFailed
self.historyTruncatedAtHead = historyTruncatedAtHead
self.outboundFocusRowID = outboundFocusRowID
self.actions = actions
self.onReachTop = onReachTop
self.onRetryInitialLoad = onRetryInitialLoad
Expand All @@ -85,7 +88,8 @@ public struct ChatTranscriptListView: View {
onReachTop: onReachTop,
onRetryInitialLoad: onRetryInitialLoad,
isAtBottom: $isAtBottom,
scrollToBottomRequest: scrollToBottomRequest
scrollToBottomRequest: scrollToBottomRequest,
outboundFocusRowID: outboundFocusRowID
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottomTrailing) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ struct ChatTranscriptTableView: UIViewRepresentable {
let onRetryInitialLoad: () -> Void
@Binding var isAtBottom: Bool
let scrollToBottomRequest: Int
let outboundFocusRowID: String?

@Environment(\.chatTheme) private var theme
@Environment(\.chatMarkdownRenderer) private var markdownRenderer
Expand Down Expand Up @@ -72,7 +73,8 @@ struct ChatTranscriptTableView: UIViewRepresentable {
contentCache: contentCache
),
in: tableView,
scrollToBottomRequest: scrollToBottomRequest
scrollToBottomRequest: scrollToBottomRequest,
outboundFocusRowID: outboundFocusRowID
)
}

Expand All @@ -83,6 +85,8 @@ struct ChatTranscriptTableView: UIViewRepresentable {
private var agentState: ChatAgentState = .idle
private var topRequestKey: String?
private var lastScrollToBottomRequest = 0
private var lastOutboundFocusRowID: String?
private var activeOutboundFocusRowID: String?
private var isHandlingLayout = false
private weak var tableView: ChatTranscriptUITableView?
private var isAtBottom: Binding<Bool>
Expand Down Expand Up @@ -111,22 +115,38 @@ struct ChatTranscriptTableView: UIViewRepresentable {
fileprivate func update(
configuration: ChatTranscriptTableConfiguration,
in tableView: ChatTranscriptUITableView,
scrollToBottomRequest: Int
scrollToBottomRequest: Int,
outboundFocusRowID: String?
) {
self.configuration = configuration
let nextItems = configuration.makeItems()
let shouldScrollToBottom = scrollToBottomRequest != lastScrollToBottomRequest
let shouldFocusOutbound = outboundFocusRowID != nil
&& outboundFocusRowID != lastOutboundFocusRowID
lastScrollToBottomRequest = scrollToBottomRequest
if shouldScrollToBottom {
activeOutboundFocusRowID = nil
}
if shouldFocusOutbound {
lastOutboundFocusRowID = outboundFocusRowID
activeOutboundFocusRowID = outboundFocusRowID
Comment on lines +122 to +131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale focus jump when chat screen is re-entered

Coordinator.lastOutboundFocusRowID resets to nil every time the UIViewRepresentable is recreated (e.g., a NavigationStack pop/push back to the same chat). ChatConversationStore.latestOutboundFocusRowID is never cleared, so the first update call into the fresh coordinator evaluates shouldFocusOutbound = true and scrolls to the previously sent message — overriding normal scroll restoration and disorienting the user who expected to land where they left off.

A minimal fix is to treat a coordinator's initial call specially: on the very first update, set lastOutboundFocusRowID = outboundFocusRowID without acting on it (consuming the stale signal), or add a dedicated reset path in ChatConversationStore that clears latestOutboundFocusRowID when the surface attaches.

}
let nextItems = configuration.makeItems(
outboundFocusSpacerHeight: activeOutboundFocusRowID.map { _ in
outboundFocusSpacerHeight(in: tableView)
}
)
let shouldReload = nextItems != items
|| configuration.expandedIDs != expandedIDs
|| configuration.agentState != agentState
let shouldScrollToBottom = scrollToBottomRequest != lastScrollToBottomRequest
lastScrollToBottomRequest = scrollToBottomRequest
let wasAtBottom = isAtBottom.wrappedValue
|| distanceFromBottom(in: tableView) <= chatTranscriptAtBottomThreshold
let anchor = firstVisibleAnchor(in: tableView)

guard shouldReload else {
if shouldScrollToBottom {
scrollToBottom(in: tableView, animated: true)
} else if shouldFocusOutbound, let outboundFocusRowID {
focusOutbound(rowID: outboundFocusRowID, in: tableView, animated: true)
}
updateBottomState(from: tableView)
return
Expand All @@ -139,7 +159,9 @@ struct ChatTranscriptTableView: UIViewRepresentable {
tableView.reloadData()
tableView.layoutIfNeeded()

if shouldScrollToBottom || wasAtBottom {
if shouldFocusOutbound, let outboundFocusRowID {
focusOutbound(rowID: outboundFocusRowID, in: tableView, animated: false)
} else if shouldScrollToBottom || wasAtBottom {
scrollToBottom(in: tableView, animated: false)
} else if let anchor {
restore(anchor, in: tableView)
Expand Down Expand Up @@ -170,6 +192,34 @@ struct ChatTranscriptTableView: UIViewRepresentable {
return cell
}

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
guard items.indices.contains(indexPath.row) else {
return UITableView.automaticDimension
}
switch items[indexPath.row] {
case .bottomAnchor:
return 9
case .outboundFocusSpacer(let height):
return height
default:
return UITableView.automaticDimension
}
}

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard items.indices.contains(indexPath.row) else {
return tableView.estimatedRowHeight
}
switch items[indexPath.row] {
case .bottomAnchor:
return 9
case .outboundFocusSpacer(let height):
return height
default:
return tableView.estimatedRowHeight
}
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let tableView = scrollView as? UITableView else { return }
updateBottomState(from: tableView)
Expand Down Expand Up @@ -198,11 +248,16 @@ struct ChatTranscriptTableView: UIViewRepresentable {
defer { isHandlingLayout = false }

if tableView.isViewportInsetsExternallyDriven {
if let activeOutboundFocusRowID {
focusOutbound(rowID: activeOutboundFocusRowID, in: tableView, animated: false)
}
updateBottomState(from: tableView)
return
}

if boundsChanged, let oldViewport {
if let activeOutboundFocusRowID {
focusOutbound(rowID: activeOutboundFocusRowID, in: tableView, animated: false)
} else if boundsChanged, let oldViewport {
restoreKeyboardViewport(snapshot: oldViewport, in: tableView)
} else if isAtBottom.wrappedValue {
scrollToBottom(in: tableView, animated: false)
Expand Down Expand Up @@ -240,6 +295,30 @@ struct ChatTranscriptTableView: UIViewRepresentable {
setAtBottom(true)
}

private func focusOutbound(rowID: String, in tableView: UITableView, animated: Bool) {
guard let row = items.firstIndex(where: { $0.id == rowID }) else { return }
tableView.layoutIfNeeded()
let indexPath = IndexPath(row: row, section: 0)
let rect = tableView.rectForRow(at: indexPath)
let visibleTopPadding: CGFloat = 18
let targetY = clampedOffsetY(
rect.minY - tableView.adjustedContentInset.top - visibleTopPadding,
in: tableView
)
tableView.setContentOffset(CGPoint(x: tableView.contentOffset.x, y: targetY), animated: animated)
setAtBottom(false)
}

private func outboundFocusSpacerHeight(in tableView: UITableView) -> CGFloat {
let viewportHeight = max(
0,
tableView.bounds.height
- tableView.adjustedContentInset.top
- tableView.adjustedContentInset.bottom
)
return max(260, viewportHeight * 1.05)
}

private func requestOlderHistoryIfNeeded(in tableView: UITableView) {
guard let configuration, configuration.hasMoreHistory else {
topRequestKey = nil
Expand Down Expand Up @@ -340,7 +419,7 @@ private struct ChatTranscriptTableConfiguration {
let markdownRenderer: ChatMarkdownRenderer?
let contentCache: ChatContentCache?

func makeItems() -> [ChatTranscriptTableItem] {
func makeItems(outboundFocusSpacerHeight: CGFloat? = nil) -> [ChatTranscriptTableItem] {
var items: [ChatTranscriptTableItem] = []
if hasMoreHistory {
items.append(.loadingMore)
Expand All @@ -361,6 +440,9 @@ private struct ChatTranscriptTableConfiguration {
items.append(.typing)
}
items.append(.bottomAnchor)
if let outboundFocusSpacerHeight {
items.append(.outboundFocusSpacer(height: outboundFocusSpacerHeight))
}
return items
}

Expand Down Expand Up @@ -444,6 +526,9 @@ private struct ChatTranscriptTableConfiguration {
case .bottomAnchor:
Color.clear
.frame(height: 9)
case .outboundFocusSpacer(let height):
Color.clear
.frame(height: height)
}
}
}
Expand All @@ -457,6 +542,7 @@ private enum ChatTranscriptTableItem: Equatable {
case row(ChatTranscriptRow)
case typing
case bottomAnchor
case outboundFocusSpacer(height: CGFloat)

var id: String {
switch self {
Expand All @@ -476,6 +562,8 @@ private enum ChatTranscriptTableItem: Equatable {
return "typing"
case .bottomAnchor:
return "bottom-anchor"
case .outboundFocusSpacer:
return "outbound-focus-spacer"
}
}
}
Expand Down
Loading
Loading