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
10 changes: 5 additions & 5 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11897,7 +11897,7 @@ struct SidebarWorkspaceSnapshotBuilder {
let remoteConnectionStatusText: String
let remoteStateHelpText: String
let copyableSidebarSSHError: String?
let latestSubmittedMessage: String?
let latestConversationMessage: String?
let metadataEntries: [SidebarStatusEntry]
let metadataBlocks: [SidebarMetadataBlock]
let latestLog: SidebarLogEntry?
Expand Down Expand Up @@ -12234,12 +12234,12 @@ private struct TabItemView: View, Equatable {
let finderDirectoryPath = WorkspaceFinderDirectoryResolver.path(for: tab)
let finderDirectoryCacheKey = WorkspaceFinderDirectoryCacheKey(path: finderDirectoryPath)
let latestNotificationSubtitle = latestNotificationText
let submittedMessageSubtitle = !settings.hidesAllDetails && settings.iMessageModeEnabled
? workspaceSnapshot.latestSubmittedMessage?
let conversationMessageSubtitle = !settings.hidesAllDetails && settings.iMessageModeEnabled
? workspaceSnapshot.latestConversationMessage?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nilIfEmpty
: nil
let effectiveSubtitle = latestNotificationSubtitle ?? submittedMessageSubtitle
let effectiveSubtitle = latestNotificationSubtitle ?? conversationMessageSubtitle
let detailVisibility = visibleAuxiliaryDetails

VStack(alignment: .leading, spacing: 4) {
Expand Down Expand Up @@ -13240,7 +13240,7 @@ private struct TabItemView: View, Equatable {
remoteConnectionStatusText: remoteConnectionStatusText,
remoteStateHelpText: remoteStateHelpText,
copyableSidebarSSHError: copyableSidebarSSHError,
latestSubmittedMessage: tab.latestSubmittedMessage,
latestConversationMessage: tab.latestConversationMessage,
metadataEntries: detailVisibility.showsMetadata ? tab.sidebarStatusEntriesInDisplayOrder() : [],
metadataBlocks: detailVisibility.showsMetadata ? tab.sidebarMetadataBlocksInDisplayOrder() : [],
latestLog: detailVisibility.showsLog ? tab.logEntries.last : nil,
Expand Down
2 changes: 1 addition & 1 deletion Sources/Search/GlobalSearchDocuments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ enum GlobalSearchDocuments {
kind = .browser
case .markdown:
kind = .markdown
case .terminal, .filePreview:
case .terminal, .filePreview, .rightSidebarTool:
kind = .title
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot {
remoteConnectionStatusText: remoteConnectionStatusText,
remoteStateHelpText: remoteStateHelpText,
copyableSidebarSSHError: copyableSidebarSSHError,
latestSubmittedMessage: latestSubmittedMessage,
latestConversationMessage: latestConversationMessage,
metadataEntries: metadataEntries,
metadataBlocks: metadataBlocks,
latestLog: latestLog,
Expand Down
15 changes: 14 additions & 1 deletion Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,19 @@ enum WorkspacePlacementSettings {
return placement
}

static func effectivePlacement(
placementOverride: NewWorkspacePlacement?,
defaults: UserDefaults = .standard
) -> NewWorkspacePlacement {
if let placementOverride {
return placementOverride
}
if IMessageModeSettings.isEnabled(defaults: defaults) {
return .top
}
return current(defaults: defaults)
}

static func insertionIndex(
placement: NewWorkspacePlacement,
selectedIndex: Int?,
Expand Down Expand Up @@ -3806,7 +3819,7 @@ class TabManager: ObservableObject {
snapshot: WorkspaceCreationSnapshot,
placementOverride: NewWorkspacePlacement? = nil
) -> Int {
let placement = placementOverride ?? WorkspacePlacementSettings.current()
let placement = WorkspacePlacementSettings.effectivePlacement(placementOverride: placementOverride)
let liveTabs = orderedLiveWorkspaceCreationTabs(from: snapshot) ?? snapshot.tabs
let pinnedCount = liveTabs.reduce(into: 0) { partial, tab in
if tab.isPinned {
Expand Down
41 changes: 29 additions & 12 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4580,7 +4580,7 @@ class TerminalController {
iMessageModeEnabled: iMessageModeEnabled
)
if iMessageModeEnabled {
preview = tabManager.tabs.first(where: { $0.id == workspaceId })?.latestSubmittedMessage
preview = tabManager.tabs.first(where: { $0.id == workspaceId })?.latestConversationMessage
}
}

Expand Down Expand Up @@ -8413,7 +8413,7 @@ class TerminalController {
}

CmuxEventBus.shared.publishWorkstreamEvent(event, phase: "received")
v2ApplyPromptSubmitSideEffects(for: event)
v2ApplyIMessageModeSideEffects(for: event)

let result = FeedCoordinator.shared.ingestBlocking(
event: event,
Expand All @@ -8427,21 +8427,38 @@ class TerminalController {
return .ok(FeedSocketEncoding.payload(for: result))
}

private nonisolated func v2ApplyPromptSubmitSideEffects(for event: WorkstreamEvent) {
guard event.hookEventName == .userPromptSubmit,
private nonisolated func v2ApplyIMessageModeSideEffects(for event: WorkstreamEvent) {
guard event.hookEventName == .userPromptSubmit || event.hookEventName == .stop || event.hookEventName == .subagentStop,
let rawWorkspaceId = event.workspaceId?.trimmingCharacters(in: .whitespacesAndNewlines),
!rawWorkspaceId.isEmpty
else { return }
Comment thread
lawrencecchen marked this conversation as resolved.

let iMessageModeEnabled = IMessageModeSettings.isEnabled()
v2MainSync {
guard let workspaceId = v2UUIDAny(rawWorkspaceId) else { return }
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return }
_ = tabManager.handlePromptSubmit(
workspaceId: workspaceId,
message: event.submittedPromptMessage,
iMessageModeEnabled: iMessageModeEnabled
)
switch event.hookEventName {
case .userPromptSubmit:
v2MainSync {
guard let workspaceId = v2UUIDAny(rawWorkspaceId) else { return }
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return }
_ = tabManager.handlePromptSubmit(
workspaceId: workspaceId,
message: event.submittedPromptMessage,
iMessageModeEnabled: iMessageModeEnabled
)
}
case .stop, .subagentStop:
let assistantFinalMessage = event.assistantFinalMessage
Task { @MainActor [weak self, rawWorkspaceId, assistantFinalMessage, iMessageModeEnabled] in
guard let self,
let workspaceId = self.v2UUIDAny(rawWorkspaceId) else { return }
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return }
_ = tabManager.handleAssistantFinalMessage(
workspaceId: workspaceId,
message: assistantFinalMessage,
iMessageModeEnabled: iMessageModeEnabled
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Async stop handling races with synchronous prompt handling

Low Severity

The .stop/.subagentStop branch dispatches sidebar updates via a fire-and-forget Task { @MainActor }, while .userPromptSubmit uses the synchronous v2MainSync. Because the Task is not awaited and ingestBlocking proceeds immediately, a prompt-submit event arriving shortly after a stop event can have its synchronous v2MainSync handler execute before the stop event's Task, causing moveTabToTop calls to run in reverse chronological order. This could make the sidebar workspace ordering briefly inconsistent with the actual event timeline.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 705d7ec. Configure here.

default:
break
}
}

Expand Down
19 changes: 12 additions & 7 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7225,7 +7225,7 @@ final class Workspace: Identifiable, ObservableObject {
nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
@Published var metadataBlocks: [String: SidebarMetadataBlock] = [:]
@Published private(set) var latestSubmittedMessage: String?
@Published private(set) var latestConversationMessage: String?
@Published var logEntries: [SidebarLogEntry] = []
@Published var progress: SidebarProgressState?
@Published var gitBranch: SidebarGitBranchState?
Expand Down Expand Up @@ -7314,7 +7314,7 @@ final class Workspace: Identifiable, ObservableObject {
sidebarObservationSignal($isPinned),
sidebarObservationSignal($customColor),
sidebarObservationSignal($terminalScrollBarHidden),
sidebarObservationSignal($latestSubmittedMessage),
sidebarObservationSignal($latestConversationMessage),
]

return Publishers.MergeMany(publishers).eraseToAnyPublisher()
Expand Down Expand Up @@ -8774,7 +8774,7 @@ final class Workspace: Identifiable, ObservableObject {
agentPIDPanelIdsByKey.removeAll()
agentPIDKeysByPanelId.removeAll()
agentListeningPorts.removeAll()
latestSubmittedMessage = nil
latestConversationMessage = nil
logEntries.removeAll()
progress = nil
gitBranch = nil
Expand Down Expand Up @@ -9084,13 +9084,18 @@ final class Workspace: Identifiable, ObservableObject {
}

@discardableResult
func recordSubmittedMessage(_ message: String?) -> Bool {
guard let preview = Self.submittedMessagePreview(from: message) else { return false }
guard latestSubmittedMessage != preview else { return false }
latestSubmittedMessage = preview
func recordConversationMessage(_ message: String?) -> Bool {
guard let preview = Self.conversationMessagePreview(from: message) else { return false }
guard latestConversationMessage != preview else { return false }
latestConversationMessage = preview
return true
}

@discardableResult
func recordSubmittedMessage(_ message: String?) -> Bool {
recordConversationMessage(message)
}

var isRemoteWorkspace: Bool {
remoteConfiguration != nil
}
Expand Down
72 changes: 61 additions & 11 deletions Sources/WorkspacePromptSubmit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,30 @@ extension WorkstreamEvent {
var submittedPromptMessage: String? {
guard hookEventName == .userPromptSubmit else { return nil }
let contextMessage = context?.lastUserMessage.flatMap(Self.normalizedPromptText)
return Self.promptText(fromJSON: toolInputJSON)
return Self.messageText(fromJSON: toolInputJSON, keys: Self.promptMessageKeys)
?? contextMessage
?? Self.promptText(fromJSON: extraFieldsJSON)
?? Self.messageText(fromJSON: extraFieldsJSON, keys: Self.promptMessageKeys)
}

private static func promptText(fromJSON jsonString: String?) -> String? {
var assistantFinalMessage: String? {
guard hookEventName == .stop || hookEventName == .subagentStop else { return nil }
let contextMessage = context?.assistantPreamble.flatMap(Self.normalizedPromptText)
return contextMessage
?? Self.messageText(fromJSON: extraFieldsJSON, keys: Self.assistantMessageKeys)
?? Self.messageText(fromJSON: toolInputJSON, keys: Self.assistantMessageKeys)
}

private static let promptMessageKeys = ["prompt", "text", "message", "body"]
private static let assistantMessageKeys = [
"last_assistant_message",
"lastAssistantMessage",
"assistantPreamble",
"assistant_preamble",
"last_agent_message",
"lastAgentMessage",
]

private static func messageText(fromJSON jsonString: String?, keys: [String]) -> String? {
guard let jsonString else { return nil }
guard let data = jsonString.data(using: .utf8),
let value = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
Expand All @@ -34,23 +52,23 @@ extension WorkstreamEvent {
return normalizedPromptText(string)
}
guard let dict = value as? [String: Any] else { return nil }
return promptText(from: dict)
return messageText(from: dict, keys: keys)
}

private static func promptText(from dict: [String: Any]) -> String? {
if let direct = firstPromptString(in: dict, keys: ["prompt", "text", "message", "body"]) {
private static func messageText(from dict: [String: Any], keys: [String]) -> String? {
if let direct = firstMessageString(in: dict, keys: keys) {
return direct
}
for key in ["notification", "data"] {
if let nested = dict[key] as? [String: Any],
let nestedPrompt = firstPromptString(in: nested, keys: ["prompt", "text", "message", "body"]) {
return nestedPrompt
let nestedMessage = firstMessageString(in: nested, keys: keys) {
return nestedMessage
}
}
return nil
}

private static func firstPromptString(in dict: [String: Any], keys: [String]) -> String? {
private static func firstMessageString(in dict: [String: Any], keys: [String]) -> String? {
for key in keys {
guard let value = dict[key] as? String,
let normalized = normalizedPromptText(value) else { continue }
Expand All @@ -74,6 +92,34 @@ extension TabManager {
workspaceId: UUID,
message: String?,
iMessageModeEnabled: Bool = IMessageModeSettings.isEnabled()
) -> (messageRecorded: Bool, reordered: Bool, index: Int)? {
handleConversationMessage(
workspaceId: workspaceId,
message: message,
iMessageModeEnabled: iMessageModeEnabled,
reorderWithoutMessage: true
)
}

@discardableResult
func handleAssistantFinalMessage(
workspaceId: UUID,
message: String?,
iMessageModeEnabled: Bool = IMessageModeSettings.isEnabled()
) -> (messageRecorded: Bool, reordered: Bool, index: Int)? {
handleConversationMessage(
workspaceId: workspaceId,
message: message,
iMessageModeEnabled: iMessageModeEnabled,
reorderWithoutMessage: false
)
}

private func handleConversationMessage(
workspaceId: UUID,
message: String?,
iMessageModeEnabled: Bool,
reorderWithoutMessage: Bool
) -> (messageRecorded: Bool, reordered: Bool, index: Int)? {
guard let originalIndex = tabs.firstIndex(where: { $0.id == workspaceId }) else {
return nil
Expand All @@ -83,15 +129,19 @@ extension TabManager {
}

let workspace = tabs[originalIndex]
let messageRecorded = workspace.recordSubmittedMessage(message)
let hasMessage = Workspace.conversationMessagePreview(from: message) != nil
let messageRecorded = workspace.recordConversationMessage(message)
guard messageRecorded || reorderWithoutMessage || hasMessage else {
return (messageRecorded, false, originalIndex)
}
moveTabToTop(workspaceId)
let newIndex = tabs.firstIndex(where: { $0.id == workspaceId }) ?? originalIndex
return (messageRecorded, newIndex != originalIndex, newIndex)
}
}

extension Workspace {
static func submittedMessagePreview(from message: String?, maxLength: Int = 240) -> String? {
static func conversationMessagePreview(from message: String?, maxLength: Int = 240) -> String? {
guard let message else { return nil }
let collapsed = message
.split(whereSeparator: { $0.isWhitespace })
Expand Down
10 changes: 5 additions & 5 deletions cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase {
isPinned: false,
customColorHex: nil,
remoteConnectionStatusText: "Connected",
latestSubmittedMessage: "old message",
latestConversationMessage: "old message",
listeningPorts: [3000]
)
let next = Self.snapshot(
title: "lmao",
isPinned: true,
customColorHex: nil,
remoteConnectionStatusText: "Disconnected",
latestSubmittedMessage: "new message",
latestConversationMessage: "new message",
listeningPorts: [3000, 4000]
)

Expand All @@ -39,7 +39,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase {
XCTAssertEqual(decision.workspaceSnapshotStorage, expectedDisplayed)
XCTAssertTrue(decision.workspaceSnapshotStorage?.isPinned == true)
XCTAssertEqual(decision.workspaceSnapshotStorage?.remoteConnectionStatusText, "Connected")
XCTAssertEqual(decision.workspaceSnapshotStorage?.latestSubmittedMessage, "old message")
XCTAssertEqual(decision.workspaceSnapshotStorage?.latestConversationMessage, "old message")
XCTAssertEqual(decision.workspaceSnapshotStorage?.listeningPorts, [3000])
XCTAssertEqual(decision.pendingWorkspaceSnapshot, next)
XCTAssertTrue(decision.hasDeferredWorkspaceObservationInvalidation)
Expand Down Expand Up @@ -94,7 +94,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase {
isPinned: Bool = false,
customColorHex: String? = nil,
remoteConnectionStatusText: String = "Disconnected",
latestSubmittedMessage: String? = nil,
latestConversationMessage: String? = nil,
listeningPorts: [Int] = []
) -> SidebarWorkspaceSnapshotBuilder.Snapshot {
SidebarWorkspaceSnapshotBuilder.Snapshot(
Expand All @@ -107,7 +107,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase {
remoteConnectionStatusText: remoteConnectionStatusText,
remoteStateHelpText: "",
copyableSidebarSSHError: nil,
latestSubmittedMessage: latestSubmittedMessage,
latestConversationMessage: latestConversationMessage,
metadataEntries: [],
metadataBlocks: [],
latestLog: nil,
Expand Down
Loading
Loading