diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 20a7825966b6..162ee0bda99c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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? @@ -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) { @@ -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, diff --git a/Sources/Search/GlobalSearchDocuments.swift b/Sources/Search/GlobalSearchDocuments.swift index 11120bb19475..baa92275e4fc 100644 --- a/Sources/Search/GlobalSearchDocuments.swift +++ b/Sources/Search/GlobalSearchDocuments.swift @@ -34,7 +34,7 @@ enum GlobalSearchDocuments { kind = .browser case .markdown: kind = .markdown - case .terminal, .filePreview: + case .terminal, .filePreview, .rightSidebarTool: kind = .title } diff --git a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift index 19052f3fdfe8..9122a7921b3c 100644 --- a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift +++ b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift @@ -27,7 +27,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { remoteConnectionStatusText: remoteConnectionStatusText, remoteStateHelpText: remoteStateHelpText, copyableSidebarSSHError: copyableSidebarSSHError, - latestSubmittedMessage: latestSubmittedMessage, + latestConversationMessage: latestConversationMessage, metadataEntries: metadataEntries, metadataBlocks: metadataBlocks, latestLog: latestLog, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 370967316e7e..d1d2b01b1eb0 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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?, @@ -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 { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0fa87db0d7c5..023bf254d1e1 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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 } } @@ -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, @@ -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 } 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 + ) + } + default: + break } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7afafc0c254c..f01b0128da38 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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? @@ -7314,7 +7314,7 @@ final class Workspace: Identifiable, ObservableObject { sidebarObservationSignal($isPinned), sidebarObservationSignal($customColor), sidebarObservationSignal($terminalScrollBarHidden), - sidebarObservationSignal($latestSubmittedMessage), + sidebarObservationSignal($latestConversationMessage), ] return Publishers.MergeMany(publishers).eraseToAnyPublisher() @@ -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 @@ -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 } diff --git a/Sources/WorkspacePromptSubmit.swift b/Sources/WorkspacePromptSubmit.swift index dc8bb3467f4c..2ab2eb8690bc 100644 --- a/Sources/WorkspacePromptSubmit.swift +++ b/Sources/WorkspacePromptSubmit.swift @@ -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]) @@ -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 } @@ -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 @@ -83,7 +129,11 @@ 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) @@ -91,7 +141,7 @@ extension TabManager { } 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 }) diff --git a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift index fe920cc70345..4fd2ec28721a 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift @@ -15,7 +15,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { isPinned: false, customColorHex: nil, remoteConnectionStatusText: "Connected", - latestSubmittedMessage: "old message", + latestConversationMessage: "old message", listeningPorts: [3000] ) let next = Self.snapshot( @@ -23,7 +23,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { isPinned: true, customColorHex: nil, remoteConnectionStatusText: "Disconnected", - latestSubmittedMessage: "new message", + latestConversationMessage: "new message", listeningPorts: [3000, 4000] ) @@ -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) @@ -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( @@ -107,7 +107,7 @@ final class SidebarWorkspaceSnapshotRefreshPolicyTests: XCTestCase { remoteConnectionStatusText: remoteConnectionStatusText, remoteStateHelpText: "", copyableSidebarSSHError: nil, - latestSubmittedMessage: latestSubmittedMessage, + latestConversationMessage: latestConversationMessage, metadataEntries: [], metadataBlocks: [], latestLog: nil, diff --git a/cmuxTests/WorkspacePromptSubmitTests.swift b/cmuxTests/WorkspacePromptSubmitTests.swift index d3d30c7b08d5..4a4d034c17b7 100644 --- a/cmuxTests/WorkspacePromptSubmitTests.swift +++ b/cmuxTests/WorkspacePromptSubmitTests.swift @@ -29,7 +29,7 @@ final class WorkspacePromptSubmitTests: XCTestCase { XCTAssertEqual(outcome.index, 0) XCTAssertEqual(manager.tabs.map(\.id), [third.id, first.id, second.id]) XCTAssertEqual(manager.selectedTabId, second.id) - XCTAssertEqual(third.latestSubmittedMessage, "implement this now") + XCTAssertEqual(third.latestConversationMessage, "implement this now") } func testPromptSubmitDoesNothingWhenIMessageModeDisabled() throws { @@ -50,7 +50,74 @@ final class WorkspacePromptSubmitTests: XCTestCase { XCTAssertFalse(outcome.reordered) XCTAssertEqual(outcome.index, 2) XCTAssertEqual(manager.tabs.map(\.id), [first.id, second.id, third.id]) - XCTAssertNil(third.latestSubmittedMessage) + XCTAssertNil(third.latestConversationMessage) + } + + func testAssistantFinalMessageRecordsMessageAndMovesWorkspaceToTopWhenIMessageModeEnabled() throws { + let manager = TabManager() + let pinned = manager.tabs[0] + manager.setPinned(pinned, pinned: true) + let second = manager.addWorkspace(select: false, placementOverride: .end) + let third = manager.addWorkspace(select: false, placementOverride: .end) + manager.selectWorkspace(second) + + let outcome = try XCTUnwrap( + manager.handleAssistantFinalMessage( + workspaceId: third.id, + message: " final\n\nresponse ", + iMessageModeEnabled: true + ) + ) + + XCTAssertTrue(outcome.messageRecorded) + XCTAssertTrue(outcome.reordered) + XCTAssertEqual(outcome.index, 1) + XCTAssertEqual(manager.tabs.map(\.id), [pinned.id, third.id, second.id]) + XCTAssertEqual(manager.selectedTabId, second.id) + XCTAssertEqual(third.latestConversationMessage, "final response") + } + + func testAssistantFinalMessageMovesWorkspaceWhenPreviewMatchesExistingMessage() throws { + let manager = TabManager() + let pinned = manager.tabs[0] + manager.setPinned(pinned, pinned: true) + let second = manager.addWorkspace(select: false, placementOverride: .end) + let third = manager.addWorkspace(select: false, placementOverride: .end) + XCTAssertTrue(third.recordConversationMessage("Done.")) + + let outcome = try XCTUnwrap( + manager.handleAssistantFinalMessage( + workspaceId: third.id, + message: "Done.", + iMessageModeEnabled: true + ) + ) + + XCTAssertFalse(outcome.messageRecorded) + XCTAssertTrue(outcome.reordered) + XCTAssertEqual(outcome.index, 1) + XCTAssertEqual(manager.tabs.map(\.id), [pinned.id, third.id, second.id]) + XCTAssertEqual(third.latestConversationMessage, "Done.") + } + + func testBlankAssistantFinalMessageDoesNotMoveWorkspace() throws { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace(select: false, placementOverride: .end) + + let outcome = try XCTUnwrap( + manager.handleAssistantFinalMessage( + workspaceId: second.id, + message: " \n ", + iMessageModeEnabled: true + ) + ) + + XCTAssertFalse(outcome.messageRecorded) + XCTAssertFalse(outcome.reordered) + XCTAssertEqual(outcome.index, 1) + XCTAssertEqual(manager.tabs.map(\.id), [first.id, second.id]) + XCTAssertNil(second.latestConversationMessage) } func testFeedPromptSubmitEventExtractsToolInputMessage() throws { @@ -78,7 +145,7 @@ final class WorkspacePromptSubmitTests: XCTestCase { XCTAssertTrue(outcome.messageRecorded) XCTAssertTrue(outcome.reordered) XCTAssertEqual(manager.tabs.map(\.id), [second.id, first.id]) - XCTAssertEqual(second.latestSubmittedMessage, "shipped from feed path") + XCTAssertEqual(second.latestConversationMessage, "shipped from feed path") } func testFeedPromptSubmitEventFallsBackToContextMessage() { @@ -106,12 +173,36 @@ final class WorkspacePromptSubmitTests: XCTestCase { XCTAssertEqual(event.submittedPromptMessage, "from extra fields") } + func testFeedStopEventExtractsAssistantFinalMessageFromContext() { + let event = WorkstreamEvent( + sessionId: "agent-session", + hookEventName: .stop, + source: "codex", + workspaceId: UUID().uuidString, + context: WorkstreamContext(assistantPreamble: " finished\n\nthis ") + ) + + XCTAssertEqual(event.assistantFinalMessage, "finished this") + } + + func testFeedStopEventExtractsAssistantFinalMessageFromExtraFields() { + let event = WorkstreamEvent( + sessionId: "agent-session", + hookEventName: .stop, + source: "codex", + workspaceId: UUID().uuidString, + extraFieldsJSON: #"{"last_assistant_message":" done\nfrom extra fields "}"# + ) + + XCTAssertEqual(event.assistantFinalMessage, "done from extra fields") + } + func testBlankSubmittedMessageDoesNotClearRecordedPreview() { let workspace = Workspace() XCTAssertTrue(workspace.recordSubmittedMessage("keep this preview")) XCTAssertFalse(workspace.recordSubmittedMessage(" \n ")) - XCTAssertEqual(workspace.latestSubmittedMessage, "keep this preview") + XCTAssertEqual(workspace.latestConversationMessage, "keep this preview") } func testIMessageModeUsesManagedSettingsKey() throws { diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index faaf5fd636ad..10cbda1c2305 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2538,6 +2538,44 @@ final class WorkspaceCreationPlacementTests: XCTestCase { XCTAssertEqual(insertedIndex, baselineCount) } + func testAddWorkspaceInIMessageModeInsertsAtTopOfUnpinnedSegment() { + let defaults = UserDefaults.standard + let placementKey = WorkspacePlacementSettings.placementKey + let iMessageModeKey = IMessageModeSettings.key + let previousPlacement = defaults.object(forKey: placementKey) + let previousIMessageMode = defaults.object(forKey: iMessageModeKey) + defer { + if let previousPlacement { + defaults.set(previousPlacement, forKey: placementKey) + } else { + defaults.removeObject(forKey: placementKey) + } + if let previousIMessageMode { + defaults.set(previousIMessageMode, forKey: iMessageModeKey) + } else { + defaults.removeObject(forKey: iMessageModeKey) + } + } + + defaults.set(NewWorkspacePlacement.end.rawValue, forKey: placementKey) + defaults.set(true, forKey: iMessageModeKey) + + let manager = TabManager() + guard let pinned = manager.tabs.first else { + XCTFail("Expected initial workspace") + return + } + manager.setPinned(pinned, pinned: true) + let second = manager.addWorkspace(select: false, placementOverride: .end) + let third = manager.addWorkspace(select: false, placementOverride: .end) + manager.selectWorkspace(third) + + let inserted = manager.addWorkspace() + + XCTAssertEqual(manager.tabs.map(\.id), [pinned.id, inserted.id, second.id, third.id]) + XCTAssertEqual(manager.selectedTabId, inserted.id) + } + func testAddWorkspaceAfterCurrentOverrideAppendsAfterLastSelectedWorkspace() { let manager = TabManager() guard !manager.tabs.isEmpty else {