Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34344 CLI/cmux.swift
17606 Sources/AppDelegate.swift
16021 Sources/ContentView.swift
16038 Sources/ContentView.swift
14100 Sources/TerminalController.swift
12695 Sources/Workspace.swift
12144 cmuxTests/AppDelegateShortcutRoutingTests.swift
Expand Down
2 changes: 1 addition & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2029,7 +2029,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
auth.start()
ensureMobileWorkspaceListObserver(for: tabManager)
MobileTerminalRenderObserver.shared.start()
agentChatTranscriptService.start { TerminalController.shared.adoptDetectedAgentSessions(workspaceID: $0) }
agentChatTranscriptService.start { TerminalController.shared.adoptDetectedAgentSession(titleChange: $0) }
installMobileHostSettingsObserver()
scheduleGhosttyCrashBreadcrumbIfNeeded(notificationStore: notificationStore)
startPaneMemoryGuardrailIfNeeded(notificationStore: notificationStore)
Expand Down
23 changes: 20 additions & 3 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12112,7 +12112,6 @@ struct VerticalTabsSidebar: View {
notificationStore: notificationStore,
tab: tab,
index: index,
isActive: tabManager.selectedTabId == tab.id,
workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace(
at: index,
workspaceCount: renderContext.workspaceCount
Expand Down Expand Up @@ -12914,7 +12913,6 @@ struct TabItemView: View, Equatable {
nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool {
lhs.tab === rhs.tab &&
lhs.index == rhs.index &&
lhs.isActive == rhs.isActive &&
lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit &&
lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol &&
lhs.canCloseWorkspace == rhs.canCloseWorkspace &&
Expand Down Expand Up @@ -12943,7 +12941,6 @@ struct TabItemView: View, Equatable {
@Environment(\.colorScheme) private var colorScheme
let tab: Tab
let index: Int
let isActive: Bool
let workspaceShortcutDigit: Int?
let workspaceShortcutModifierSymbol: String
let canCloseWorkspace: Bool
Expand Down Expand Up @@ -12987,6 +12984,8 @@ struct TabItemView: View, Equatable {
let onContextMenuAppear: () -> Void
let onContextMenuDisappear: () -> Void
@State private var workspaceSnapshotStorage: SidebarWorkspaceSnapshotBuilder.Snapshot?
// Row-local selection projection: selectedTabId changes update only rows whose boolean flips.
@State private var observedIsActive: Bool?
@StateObject private var contextMenuState = SidebarTabItemContextMenuState()
@State private var rowInteractionState = SidebarWorkspaceRowInteractionState()
@State private var rowHeight: CGFloat = 1
Expand Down Expand Up @@ -13047,6 +13046,10 @@ struct TabItemView: View, Equatable {
settings.activeTabIndicatorStyle
}

private var isActive: Bool {
observedIsActive ?? (tabManager.selectedTabId == tab.id)
}

private var sidebarSelectionColorHex: String? {
settings.selectionColorHex
}
Expand Down Expand Up @@ -13697,8 +13700,17 @@ struct TabItemView: View, Equatable {
)
}
.onAppear {
updateObservedActiveState(tabManager.selectedTabId == tab.id)
refreshWorkspaceSnapshot(force: true)
}
.onReceive(
tabManager.selectedTabIdPublisher
.map { $0 == tab.id }
.removeDuplicates()
.receive(on: RunLoop.main)
) { isSelected in
updateObservedActiveState(isSelected)
}
.task(id: workspaceFinderDirectoryOpenRequest) {
guard let request = workspaceFinderDirectoryOpenRequest else { return }
await WorkspaceFinderDirectoryOpener.openInFinder(request.directoryURL)
Expand Down Expand Up @@ -13782,6 +13794,11 @@ struct TabItemView: View, Equatable {
}
}

private func updateObservedActiveState(_ isActive: Bool) {
guard observedIsActive != isActive else { return }
observedIsActive = isActive
}

private func refreshWorkspaceSnapshot(force: Bool = false) {
let nextSnapshot = makeWorkspaceSnapshot()
let decision = SidebarWorkspaceSnapshotRefreshPolicy.decision(
Expand Down
78 changes: 76 additions & 2 deletions Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Foundation
@MainActor
final class AgentChatSessionRegistry {
private var records: [String: AgentChatSessionRecord] = [:]
private var liveSessionIDBySurfaceID: [String: String] = [:]
private let hookStore: AgentChatHookSessionStore

/// Called after a record mutation with the previous value (nil for a
Expand Down Expand Up @@ -59,6 +60,27 @@ final class AgentChatSessionRegistry {
records[sessionID]
}

/// The current live session bound to a terminal surface, if any.
///
/// - Parameter surfaceID: Terminal surface UUID string.
/// - Returns: A non-ended record bound to the surface, or `nil`.
func liveSession(surfaceID: String) -> AgentChatSessionRecord? {
while let sessionID = liveSessionIDBySurfaceID[surfaceID] {
guard let record = records[sessionID],
record.surfaceID == surfaceID,
record.state != .ended else {
liveSessionIDBySurfaceID.removeValue(forKey: surfaceID)
return nil
}
if let pid = record.pid, processIsDead(pid) {
update(sessionID: sessionID) { $0.state = .ended }
continue
}
return record
}
return nil
}
Comment thread
austinywang marked this conversation as resolved.

/// Every session id the registry already tracks. Title-detected adoption
/// passes this to the transcript resolver so a second hook-bypassed claude
/// in the same directory resolves to a *different* (unclaimed) transcript
Expand Down Expand Up @@ -98,6 +120,7 @@ final class AgentChatSessionRegistry {
var record = previous
mutate(&record)
records[sessionID] = record
updateLiveSessionIndex(previous: previous, current: record)
onRecordChanged?(record, previous)
}

Expand Down Expand Up @@ -127,7 +150,7 @@ final class AgentChatSessionRegistry {
for entry in hookStore.entries(agentSource: source) {
guard records[entry.sessionID] == nil else { continue }
let alive = entry.pid.map { kill(pid_t($0), 0) == 0 } ?? false
records[entry.sessionID] = AgentChatSessionRecord(
let record = AgentChatSessionRecord(
sessionID: entry.sessionID,
agentKind: kind,
workspaceID: entry.workspaceID,
Expand All @@ -139,6 +162,8 @@ final class AgentChatSessionRegistry {
title: nil,
pid: entry.pid
)
records[entry.sessionID] = record
updateLiveSessionIndex(previous: nil, current: record)
}
}
}
Expand Down Expand Up @@ -168,7 +193,7 @@ final class AgentChatSessionRegistry {
at timestamp: Date
) -> AgentChatSessionRecord {
if let existing = records[sessionID] { return existing }
if let bound = records.values.first(where: { $0.surfaceID == surfaceID && $0.state != .ended }) {
if let bound = liveSession(surfaceID: surfaceID) {
return bound
}
let record = AgentChatSessionRecord(
Expand All @@ -184,6 +209,7 @@ final class AgentChatSessionRegistry {
pid: nil
)
records[sessionID] = record
updateLiveSessionIndex(previous: nil, current: record)
onRecordChanged?(record, nil)
return record
}
Expand Down Expand Up @@ -249,10 +275,58 @@ final class AgentChatSessionRegistry {
let previous = records[sessionID]
record.state = Self.nextState(previous: record.state, event: event)
records[sessionID] = record
updateLiveSessionIndex(previous: previous, current: record)
onRecordChanged?(record, previous)
return record
}

private func updateLiveSessionIndex(
previous: AgentChatSessionRecord?,
current: AgentChatSessionRecord
) {
let previousSurfaceID = Self.liveSurfaceID(previous)
let currentSurfaceID = Self.liveSurfaceID(current)
if let previousSurfaceID,
previousSurfaceID != currentSurfaceID,
liveSessionIDBySurfaceID[previousSurfaceID] == previous?.sessionID {
liveSessionIDBySurfaceID.removeValue(forKey: previousSurfaceID)
rebuildLiveSessionIndex(surfaceID: previousSurfaceID)
}
guard let currentSurfaceID else { return }
guard let indexedSessionID = liveSessionIDBySurfaceID[currentSurfaceID],
let indexed = records[indexedSessionID],
indexed.surfaceID == currentSurfaceID,
indexed.state != .ended else {
liveSessionIDBySurfaceID[currentSurfaceID] = current.sessionID
return
}
if indexed.sessionID == current.sessionID || current.lastActivityAt >= indexed.lastActivityAt {
liveSessionIDBySurfaceID[currentSurfaceID] = current.sessionID
}
}

private func rebuildLiveSessionIndex(surfaceID: String?) {
guard let surfaceID else { return }
if let newest = records.values
.filter({ $0.surfaceID == surfaceID && $0.state != .ended })
.max(by: { $0.lastActivityAt < $1.lastActivityAt }) {
liveSessionIDBySurfaceID[surfaceID] = newest.sessionID
} else {
liveSessionIDBySurfaceID.removeValue(forKey: surfaceID)
}
}

private static func liveSurfaceID(_ record: AgentChatSessionRecord?) -> String? {
guard let record, record.state != .ended else {
return nil
}
return record.surfaceID
}

private func processIsDead(_ pid: Int) -> Bool {
kill(pid_t(pid), 0) != 0 && errno == ESRCH
}

/// Strips an agent-name prefix from prefixed workstream ids
/// (`claude-<uuid>`); raw hook ids pass through.
private static func normalizedSessionID(_ id: String, source: String) -> String {
Expand Down
Loading
Loading