Skip to content
Closed
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
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 Expand Up @@ -710,7 +720,11 @@ public final class ChatConversationStore {
$0.isReconcilable
&& $0.text.trimmingCharacters(in: .whitespacesAndNewlines) == command
}) {
pending.remove(at: index)
let removed = pending.remove(at: index)
let removedRowID = ChatTranscriptRow.pendingOutboundRowID(for: removed.id)
if latestOutboundFocusRowID == removedRowID {
latestOutboundFocusRowID = ChatTranscriptRow.terminalCommandRowID(for: block.id)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,23 @@ 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)"
return Self.terminalCommandRowID(for: block.id)
}
}

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

public static func pendingOutboundRowID(for pendingID: String) -> String {
"pending-\(pendingID)"
}

public static func terminalCommandRowID(for blockID: Int) -> String {
"term-\(blockID)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ struct ChatConversationStoreTests {
ChatSessionDescriptor(id: "session-1", agentKind: .claude, title: "Test")
}

private static func terminalDescriptor() -> ChatSessionDescriptor {
ChatSessionDescriptor(id: "session-1", agentKind: .claude, kind: .terminal, title: "Test")
}

private static func prose(
seq: Int,
role: ChatRole = .agent,
Expand Down Expand Up @@ -192,12 +196,13 @@ struct ChatConversationStoreTests {

private static func makeStore(
source: any ChatEventSource,
descriptor: ChatSessionDescriptor = descriptor(),
lastReadSeq: Int? = nil,
pageSize: Int = 10,
maxWindowCount: Int = 600
) -> ChatConversationStore {
ChatConversationStore(
descriptor: descriptor(),
descriptor: descriptor,
source: source,
lastReadSeq: lastReadSeq,
pageSize: pageSize,
Expand Down Expand Up @@ -412,6 +417,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 +441,40 @@ 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("terminal command echo transfers outbound focus to the real command row")
func terminalEchoReconcilesPendingFocus() async {
let source = SilentSendEventSource()
let store = Self.makeStore(source: source, descriptor: Self.terminalDescriptor())
let runTask = Task { await store.run() }
defer { runTask.cancel() }

#expect(await TestPoller.waitUntil { store.isConnected })
await store.send(text: "pwd")

#expect(await TestPoller.waitUntil { Self.pendingItems(store.rows).first?.delivery == .delivered })
#expect(store.latestOutboundFocusRowID == "pending-local-1")

await source.emit(.terminalBlocks([
TerminalCommandBlock(id: 7, command: "pwd", output: "/tmp\n", exitCode: 0, isRunning: false)
]))

#expect(
await TestPoller.waitUntil {
Self.pendingItems(store.rows).isEmpty
&& store.rows.contains(.terminalCommand(
TerminalCommandBlock(id: 7, command: "pwd", output: "/tmp\n", exitCode: 0, isRunning: false)
))
}
)
#expect(store.latestOutboundFocusRowID == ChatTranscriptRow.terminalCommandRowID(for: 7))
}

@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 @@ -326,6 +326,7 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
transcriptOverlayGeometry.composerBottomInset = overlayBottomInset
}
updateTranscriptViewportInsets(
topChromeInset: topChromeOverlayInset(),
adjustedBottomInset: overlayBottomInset,
composerOverlayBottomInset: overlayBottomInset
)
Expand Down Expand Up @@ -379,6 +380,15 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
return max(0, ceil(visibleComposerHeight + bottomSafeAreaUnderlap))
}

private func topChromeOverlayInset() -> CGFloat {
guard #available(iOS 26.0, *) else { return 0 }
let safeTop = view.window?.safeAreaInsets.top ?? view.safeAreaInsets.top
// iOS 26's floating navigation chrome is intentionally translucent and
// the chat underlaps it, but transcript focus still needs a usable top
// edge below the pills instead of the raw table origin.
return max(132, ceil(safeTop + 72))
}

private func updateConstraint(_ constraint: NSLayoutConstraint?, to constant: CGFloat) {
guard let constraint, abs(constraint.constant - constant) > 0.5 else { return }
constraint.constant = constant
Expand All @@ -405,13 +415,14 @@ final class ChatKeyboardTrackingViewController<Transcript: View, Composer: View>
}

private func updateTranscriptViewportInsets(
topChromeInset: CGFloat,
adjustedBottomInset: CGFloat,
composerOverlayBottomInset: CGFloat
) {
let tables = trackedTranscriptTables(in: transcriptHostingController.view)
for tableView in tables {
tableView.applyTranscriptViewportInsets(
topChromeInset: 0,
topChromeInset: topChromeInset,
adjustedBottomInset: adjustedBottomInset,
composerOverlayBottomInset: composerOverlayBottomInset
)
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
Loading
Loading