diff --git a/Sources/Halos/Models/LazuliEvent.swift b/Sources/Halos/Models/LazuliEvent.swift index 7c61944..df2eb23 100644 --- a/Sources/Halos/Models/LazuliEvent.swift +++ b/Sources/Halos/Models/LazuliEvent.swift @@ -35,6 +35,41 @@ public enum MessageStreamState: String, Sendable { case aborted } +public enum QueuedComposerMessageState: String, Sendable { + case queued + case editing + case steering + case followUp + case failed +} + +public enum QueuedComposerMoveDirection: Sendable { + case up + case down +} + +public struct QueuedComposerMessage: Identifiable, Equatable, Sendable { + public let id: String + public let text: String + public let createdAt: Date + public let order: Int + public let state: QueuedComposerMessageState + + public init( + id: String, + text: String, + createdAt: Date, + order: Int, + state: QueuedComposerMessageState + ) { + self.id = id + self.text = text + self.createdAt = createdAt + self.order = order + self.state = state + } +} + public enum VeyraApprovalDecision: String, Sendable { case approve case deny diff --git a/Sources/Halos/Stores/MissionControlStore.swift b/Sources/Halos/Stores/MissionControlStore.swift index 2f3a80d..67200c3 100644 --- a/Sources/Halos/Stores/MissionControlStore.swift +++ b/Sources/Halos/Stores/MissionControlStore.swift @@ -331,6 +331,7 @@ public final class MissionControlStore: ObservableObject { @Published public private(set) var slashCommandPanelState: SlashCommandPanelState? @Published public private(set) var halosSessions: [HalosSessionSummary] = [] @Published public private(set) var currentSessionKey = MissionControlStore.halosSessionKey + @Published public private(set) var queuedComposerMessages: [QueuedComposerMessage] = [] @Published public var isCodeSessionBrowserPresented = true @Published public var selectedPage: HalosPage = .code @Published public var selectedAutomationID: String? @@ -369,6 +370,9 @@ public final class MissionControlStore: ObservableObject { private var didSelectInitialHalosSession = false private var localSessionTitleOverrides: [String: LocalSessionTitleOverride] = [:] private var deletedSessionKeys: Set = [] + private var queuedComposerMessagesBySessionKey: [String: [QueuedComposerMessage]] = [:] + private var deferredQueuedMessageIDs: Set = [] + private var gatewayRequestHandlerForTesting: ((String, [String: Any], @escaping (Bool, Any?) -> Void) -> Void)? public init( openClawConfigURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".openclaw/openclaw.json"), @@ -418,6 +422,10 @@ public final class MissionControlStore: ObservableObject { responseMarkerStatus.isActive } + public var isComposerQueueingActive: Bool { + activeRunID != nil || responseMarkerStatus.isActive + } + public func sendDraft() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } @@ -432,6 +440,77 @@ public final class MissionControlStore: ObservableObject { return } draft = "" + if isComposerQueueingActive { + enqueueComposerMessage(text) + return + } + sendTextAsNewRun(text) + } + + public func steerQueuedComposerMessage(_ id: String) { + guard let item = queuedComposerMessage(id: id) else { return } + guard isComposerQueueingActive else { + removeQueuedComposerMessage(id) + sendTextAsNewRun(item.text) + return + } + updateQueuedComposerMessage(id, state: .steering) + sendGatewayRequest( + method: "chat.send", + params: [ + "sessionKey": sessionKey, + "message": item.text, + "deliver": false, + "idempotencyKey": "queued-\(id)", + ] + ) { [weak self] ok, _ in + guard let self else { return } + if ok { + self.removeQueuedComposerMessage(id) + } else { + self.updateQueuedComposerMessage(id, state: .failed) + } + } + } + + public func editQueuedComposerMessage(_ id: String) { + guard let item = queuedComposerMessage(id: id) else { return } + updateQueuedComposerMessage(id, state: .editing) + draft = item.text + removeQueuedComposerMessage(id) + } + + public func sendQueuedComposerMessageAsFollowUp(_ id: String) { + guard let item = queuedComposerMessage(id: id) else { return } + if isComposerQueueingActive { + deferredQueuedMessageIDs.insert(id) + updateQueuedComposerMessage(id, state: .followUp) + } else { + removeQueuedComposerMessage(id) + sendTextAsNewRun(item.text) + } + } + + public func deleteQueuedComposerMessage(_ id: String) { + removeQueuedComposerMessage(id) + } + + public func moveQueuedComposerMessage(_ id: String, direction: QueuedComposerMoveDirection) { + var items = queuedComposerMessagesBySessionKey[sessionKey] ?? [] + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + let targetIndex: Int + switch direction { + case .up: + targetIndex = max(items.startIndex, index - 1) + case .down: + targetIndex = min(items.index(before: items.endIndex), index + 1) + } + guard targetIndex != index else { return } + items.swapAt(index, targetIndex) + setQueuedComposerMessages(items) + } + + private func sendTextAsNewRun(_ text: String) { if isCodeSessionBrowserPresented { startFreshHalosSessionForComposer(initialText: text) } @@ -479,6 +558,74 @@ public final class MissionControlStore: ObservableObject { } } + private func enqueueComposerMessage(_ text: String) { + updateCurrentSessionTitle(with: text) + let nextOrder = ((queuedComposerMessagesBySessionKey[sessionKey] ?? []).map(\.order).max() ?? -1) + 1 + let item = QueuedComposerMessage( + id: UUID().uuidString, + text: text, + createdAt: Date(), + order: nextOrder, + state: .queued + ) + var items = queuedComposerMessagesBySessionKey[sessionKey] ?? [] + items.append(item) + setQueuedComposerMessages(items) + } + + private func queuedComposerMessage(id: String) -> QueuedComposerMessage? { + queuedComposerMessagesBySessionKey[sessionKey]?.first { $0.id == id } + } + + private func updateQueuedComposerMessage(_ id: String, state: QueuedComposerMessageState) { + var items = queuedComposerMessagesBySessionKey[sessionKey] ?? [] + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + let item = items[index] + items[index] = QueuedComposerMessage( + id: item.id, + text: item.text, + createdAt: item.createdAt, + order: item.order, + state: state + ) + setQueuedComposerMessages(items) + } + + private func removeQueuedComposerMessage(_ id: String) { + deferredQueuedMessageIDs.remove(id) + var items = queuedComposerMessagesBySessionKey[sessionKey] ?? [] + items.removeAll { $0.id == id } + setQueuedComposerMessages(items) + } + + private func clearQueuedComposerMessages() { + deferredQueuedMessageIDs.subtract(queuedComposerMessages.map(\.id)) + queuedComposerMessagesBySessionKey[sessionKey] = [] + queuedComposerMessages = [] + } + + private func setQueuedComposerMessages(_ items: [QueuedComposerMessage]) { + let normalized = items.enumerated().map { index, item in + QueuedComposerMessage( + id: item.id, + text: item.text, + createdAt: item.createdAt, + order: index, + state: item.state + ) + } + queuedComposerMessagesBySessionKey[sessionKey] = normalized + queuedComposerMessages = normalized + } + + private func drainDeferredQueuedMessageIfPossible() { + guard !isComposerQueueingActive else { return } + guard gatewayConnection == .connected || gatewayRequestHandlerForTesting != nil else { return } + guard let item = queuedComposerMessages.first(where: { deferredQueuedMessageIDs.contains($0.id) }) else { return } + removeQueuedComposerMessage(item.id) + sendTextAsNewRun(item.text) + } + public func requestStop() { if lazuliLifecycle != .idle { lazuliTask?.send(.string(#"{"type":"stop"}"#)) { _ in } @@ -687,6 +834,7 @@ public final class MissionControlStore: ObservableObject { localSessionTitleOverrides.removeValue(forKey: key) if sessionKey == key { + clearQueuedComposerMessages() messages = [] isCodeSessionBrowserPresented = true if let nextSession = remainingSessions.first { @@ -703,6 +851,7 @@ public final class MissionControlStore: ObservableObject { let suffix = UUID().uuidString.split(separator: "-").first.map(String.init) ?? "\(Int(Date().timeIntervalSince1970))" let key = "\(Self.halosSessionKey):\(suffix.lowercased())" let label = "Halos \(DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short))" + clearQueuedComposerMessages() resetTranscriptState(for: key) sendGatewayRequest( method: "sessions.create", @@ -723,6 +872,7 @@ public final class MissionControlStore: ObservableObject { let suffix = UUID().uuidString.split(separator: "-").first.map(String.init) ?? "\(Int(Date().timeIntervalSince1970))" let key = "\(Self.halosSessionKey):\(suffix.lowercased())" let preview = Self.sessionTitlePreview(from: initialText) + clearQueuedComposerMessages() resetTranscriptState(for: key) localSessionTitleOverrides[key] = LocalSessionTitleOverride(preview: preview, updatedAt: Date()) halosSessions = sortHalosSessions(applyLocalSessionTitleOverrides(to: [currentSessionSummary] + halosSessions)) @@ -737,6 +887,7 @@ public final class MissionControlStore: ObservableObject { } private func clearLocalTranscript(command: SlashCommand) { + clearQueuedComposerMessages() messages = [] activeRunID = nil assistantMessageIDsByRunID.removeAll() @@ -774,6 +925,7 @@ public final class MissionControlStore: ObservableObject { private func switchHalosSession(_ key: String, loadsHistory: Bool) { guard isHalosSessionKey(key) else { return } + clearQueuedComposerMessages() resetTranscriptState(for: key) ensureHalosSession(loadsHistory: loadsHistory) refreshHalosSessions() @@ -794,6 +946,7 @@ public final class MissionControlStore: ObservableObject { activeSlashCommandRunID = nil slashCommandPanelState = nil messages = [] + queuedComposerMessages = queuedComposerMessagesBySessionKey[key] ?? [] finishResponseRun(runID: key) } @@ -826,6 +979,20 @@ public final class MissionControlStore: ObservableObject { handleGatewayDisconnect() } + func beginResponseRunForTesting(runID: String = "test-active-run") { + runStartedAtByRunID[runID] = Date() + activateResponseRun(runID: runID, resetTokens: true) + upsertRunStatus(runID: runID, title: thinkingWord(for: runID), body: "Processing your message.") + } + + func finishResponseRunForTesting(runID: String = "test-active-run") { + finishResponseRun(runID: runID) + } + + func setGatewayRequestHandlerForTesting(_ handler: ((String, [String: Any], @escaping (Bool, Any?) -> Void) -> Void)?) { + gatewayRequestHandlerForTesting = handler + } + private func loadAutomations() { guard let data = try? Data(contentsOf: cronJobsURL), @@ -1790,6 +1957,10 @@ public final class MissionControlStore: ObservableObject { idPrefix: String = "req", completion: @escaping (Bool, Any?) -> Void ) { + if let gatewayRequestHandlerForTesting { + gatewayRequestHandlerForTesting(method, params, completion) + return + } guard let gatewayTask else { completion(false, nil) return @@ -2562,6 +2733,7 @@ public final class MissionControlStore: ObservableObject { } responseStatusTask?.cancel() responseStatusTask = nil + drainDeferredQueuedMessageIfPossible() } private func markResponseTokensVisible(runID: String) { diff --git a/Sources/Halos/Views/HalosControlWindowView.swift b/Sources/Halos/Views/HalosControlWindowView.swift index 3c5a3fc..005b78a 100644 --- a/Sources/Halos/Views/HalosControlWindowView.swift +++ b/Sources/Halos/Views/HalosControlWindowView.swift @@ -867,12 +867,18 @@ private struct CodePageView: View { draft: $store.draft, commands: store.slashCommands, commandPanelState: store.slashCommandPanelState, + queuedMessages: store.queuedComposerMessages, sessions: store.halosSessions, currentSessionKey: store.currentSessionKey, isSendEnabled: !store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, runCommand: store.runSlashCommand, runCommandOption: store.submitSlashCommandOption, dismissCommandPanel: store.dismissSlashCommandPanel, + steerQueuedMessage: store.steerQueuedComposerMessage, + editQueuedMessage: store.editQueuedComposerMessage, + sendQueuedMessageAsFollowUp: store.sendQueuedComposerMessageAsFollowUp, + deleteQueuedMessage: store.deleteQueuedComposerMessage, + moveQueuedMessage: store.moveQueuedComposerMessage, refreshSessions: store.refreshHalosSessions, createSession: store.createHalosSession, switchSession: store.switchHalosSession, @@ -880,7 +886,7 @@ private struct CodePageView: View { attachProviders: attachProviders, attachments: $composerAttachments, isPageDropTargeted: isChatDropTargeted, - placeholder: composerPlaceholder, + placeholder: store.isComposerQueueingActive ? "Ask for follow-up changes" : composerPlaceholder, send: store.sendDraft ) } @@ -2057,12 +2063,18 @@ private struct ComposerView: View { @Binding var draft: String let commands: [SlashCommand] let commandPanelState: SlashCommandPanelState? + let queuedMessages: [QueuedComposerMessage] let sessions: [HalosSessionSummary] let currentSessionKey: String let isSendEnabled: Bool let runCommand: (SlashCommand) -> Void let runCommandOption: (String) -> Void let dismissCommandPanel: () -> Void + let steerQueuedMessage: (String) -> Void + let editQueuedMessage: (String) -> Void + let sendQueuedMessageAsFollowUp: (String) -> Void + let deleteQueuedMessage: (String) -> Void + let moveQueuedMessage: (String, QueuedComposerMoveDirection) -> Void let refreshSessions: () -> Void let createSession: () -> Void let switchSession: (String) -> Void @@ -2107,6 +2119,18 @@ private struct ComposerView: View { .transition(.opacity.combined(with: .move(edge: .bottom))) } + if !queuedMessages.isEmpty { + QueuedComposerDeckView( + messages: queuedMessages, + steer: steerQueuedMessage, + edit: editQueuedMessage, + sendAsFollowUp: sendQueuedMessageAsFollowUp, + delete: deleteQueuedMessage, + move: moveQueuedMessage + ) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + ZStack(alignment: .topTrailing) { VStack(alignment: .leading, spacing: 7) { if !attachments.isEmpty { @@ -2188,6 +2212,7 @@ private struct ComposerView: View { .animation(.smooth(duration: 0.16), value: shouldShowFileSearch) .animation(.smooth(duration: 0.16), value: shouldShowSessionsPanel) .animation(.smooth(duration: 0.16), value: commandPanelState) + .animation(.smooth(duration: 0.18), value: queuedMessages) .animation(.smooth(duration: 0.20), value: attachments) .task(id: fileMentionQuery) { await refreshFileResults() @@ -2331,6 +2356,140 @@ private struct ComposerView: View { } } +private struct QueuedComposerDeckView: View { + let messages: [QueuedComposerMessage] + let steer: (String) -> Void + let edit: (String) -> Void + let sendAsFollowUp: (String) -> Void + let delete: (String) -> Void + let move: (String, QueuedComposerMoveDirection) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + ForEach(messages) { message in + QueuedComposerCardView( + message: message, + canMoveUp: message.id != messages.first?.id, + canMoveDown: message.id != messages.last?.id, + steer: { steer(message.id) }, + edit: { edit(message.id) }, + sendAsFollowUp: { sendAsFollowUp(message.id) }, + delete: { delete(message.id) }, + moveUp: { move(message.id, .up) }, + moveDown: { move(message.id, .down) } + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct QueuedComposerCardView: View { + let message: QueuedComposerMessage + let canMoveUp: Bool + let canMoveDown: Bool + let steer: () -> Void + let edit: () -> Void + let sendAsFollowUp: () -> Void + let delete: () -> Void + let moveUp: () -> Void + let moveDown: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "arrow.up.arrow.down") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText.opacity(0.72)) + .frame(width: 18, height: 22) + .help("Move queued message") + + Text(message.text) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(message.state == .failed ? Color.red.opacity(0.9) : HalosTheme.secondaryText) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + + if message.state == .followUp { + Text("Follow-up") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText) + } else if message.state == .failed { + Text("Failed") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.red.opacity(0.9)) + } + + Button(action: steer) { + Label(message.state == .steering ? "Steering" : "Steer", systemImage: "arrow.turn.down.right") + .font(.system(size: 11.5, weight: .semibold)) + .labelStyle(.titleAndIcon) + .foregroundStyle(message.state == .steering ? HalosTheme.tertiaryText : HalosTheme.secondaryText) + } + .buttonStyle(.plain) + .disabled(message.state == .steering || message.state == .followUp) + .help("Steer Huma after the current tool finishes") + + Button(action: delete) { + Image(systemName: "trash") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(HalosTheme.tertiaryText) + .frame(width: 20, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Delete queued message") + + Menu { + Button { + edit() + } label: { + Label("Edit message", systemImage: "pencil") + } + + Button { + sendAsFollowUp() + } label: { + Label("Turn off queueing", systemImage: "arrow.turn.down.left") + } + + Divider() + + Button { + moveUp() + } label: { + Label("Move up", systemImage: "arrow.up") + } + .disabled(!canMoveUp) + + Button { + moveDown() + } label: { + Label("Move down", systemImage: "arrow.down") + } + .disabled(!canMoveDown) + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(HalosTheme.tertiaryText) + .frame(width: 24, height: 22) + .background(Color.white.opacity(0.045), in: Circle()) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background(HalosTheme.rowFill.opacity(0.76), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(HalosTheme.faintSeparator.opacity(0.78), lineWidth: 1) + } + .shadow(color: Color.black.opacity(0.18), radius: 10, x: 0, y: 8) + } +} + private struct ComposerAttachmentItem: Identifiable, Equatable { let id = UUID() let url: URL diff --git a/Tests/HalosUITests/GatewayStreamingTests.swift b/Tests/HalosUITests/GatewayStreamingTests.swift index 8bcf737..efa44db 100644 --- a/Tests/HalosUITests/GatewayStreamingTests.swift +++ b/Tests/HalosUITests/GatewayStreamingTests.swift @@ -615,6 +615,181 @@ final class GatewayStreamingTests: XCTestCase { XCTAssertEqual(store.messages.first(where: { $0.kind == .user })?.body, "This should start the new conversation") } + func testBusyComposerEnterCreatesQueuedMessageWithoutSending() { + let store = makeStore() + var sentMethods: [String] = [] + store.setGatewayRequestHandlerForTesting { method, _, completion in + sentMethods.append(method) + completion(true, ["ok": true]) + } + store.beginResponseRunForTesting(runID: "active-run") + + store.draft = "Change the plan after this tool." + store.sendDraft() + + XCTAssertEqual(store.draft, "") + XCTAssertEqual(store.queuedComposerMessages.map(\.text), ["Change the plan after this tool."]) + XCTAssertEqual(store.queuedComposerMessages.first?.state, .queued) + XCTAssertTrue(sentMethods.isEmpty) + XCTAssertTrue(store.messages.contains(where: { $0.kind == .system && $0.runId == "active-run" })) + XCTAssertFalse(store.messages.contains(where: { $0.kind == .user && $0.body == "Change the plan after this tool." })) + } + + func testIdleComposerEnterStillSendsNormally() { + let store = makeStore() + var sentMessages: [String] = [] + store.setGatewayRequestHandlerForTesting { method, params, completion in + if method == "chat.send", let message = params["message"] as? String { + sentMessages.append(message) + } + completion(true, ["ok": true]) + } + + store.draft = "Send this now." + store.sendDraft() + + XCTAssertEqual(sentMessages, ["Send this now."]) + XCTAssertTrue(store.queuedComposerMessages.isEmpty) + XCTAssertTrue(store.messages.contains(where: { $0.kind == .user && $0.body == "Send this now." })) + } + + func testMultipleQueuedMessagesPreserveAndMoveOrder() { + let store = makeStore() + store.beginResponseRunForTesting(runID: "active-run") + + store.draft = "First queued" + store.sendDraft() + store.draft = "Second queued" + store.sendDraft() + + XCTAssertEqual(store.queuedComposerMessages.map(\.text), ["First queued", "Second queued"]) + + let secondID = store.queuedComposerMessages[1].id + store.moveQueuedComposerMessage(secondID, direction: .up) + + XCTAssertEqual(store.queuedComposerMessages.map(\.text), ["Second queued", "First queued"]) + } + + func testSteerQueuedMessageSendsOnlySelectedCardAndRemovesIt() { + let store = makeStore() + var sentMessages: [String] = [] + store.setGatewayRequestHandlerForTesting { method, params, completion in + if method == "chat.send", let message = params["message"] as? String { + sentMessages.append(message) + } + completion(true, ["ok": true]) + } + store.beginResponseRunForTesting(runID: "active-run") + store.draft = "First queued" + store.sendDraft() + store.draft = "Second queued" + store.sendDraft() + + let secondID = store.queuedComposerMessages[1].id + store.steerQueuedComposerMessage(secondID) + + XCTAssertEqual(sentMessages, ["Second queued"]) + XCTAssertEqual(store.queuedComposerMessages.map(\.text), ["First queued"]) + } + + func testFailedSteerKeepsQueuedMessageRetryable() { + let store = makeStore() + store.setGatewayRequestHandlerForTesting { _, _, completion in + completion(false, nil) + } + store.beginResponseRunForTesting(runID: "active-run") + store.draft = "Retry this steer." + store.sendDraft() + + let id = store.queuedComposerMessages[0].id + store.steerQueuedComposerMessage(id) + + XCTAssertEqual(store.queuedComposerMessages.count, 1) + XCTAssertEqual(store.queuedComposerMessages.first?.state, .failed) + XCTAssertEqual(store.queuedComposerMessages.first?.text, "Retry this steer.") + } + + func testEditQueuedMessageMovesTextBackToComposer() { + let store = makeStore() + store.beginResponseRunForTesting(runID: "active-run") + store.draft = "Edit this." + store.sendDraft() + + let id = store.queuedComposerMessages[0].id + store.editQueuedComposerMessage(id) + + XCTAssertEqual(store.draft, "Edit this.") + XCTAssertTrue(store.queuedComposerMessages.isEmpty) + } + + func testDeleteQueuedMessageRemovesIt() { + let store = makeStore() + store.beginResponseRunForTesting(runID: "active-run") + store.draft = "Delete this." + store.sendDraft() + + let id = store.queuedComposerMessages[0].id + store.deleteQueuedComposerMessage(id) + + XCTAssertTrue(store.queuedComposerMessages.isEmpty) + } + + func testQueuedFollowUpSendsAfterActiveRunFinishes() { + let store = makeStore() + var sentMessages: [String] = [] + store.setGatewayRequestHandlerForTesting { method, params, completion in + if method == "chat.send", let message = params["message"] as? String { + sentMessages.append(message) + } + completion(true, ["ok": true]) + } + store.beginResponseRunForTesting(runID: "active-run") + store.draft = "Send after idle." + store.sendDraft() + + let id = store.queuedComposerMessages[0].id + store.sendQueuedComposerMessageAsFollowUp(id) + + XCTAssertEqual(store.queuedComposerMessages.first?.state, .followUp) + XCTAssertTrue(sentMessages.isEmpty) + + store.finishResponseRunForTesting(runID: "active-run") + + XCTAssertEqual(sentMessages, ["Send after idle."]) + XCTAssertTrue(store.queuedComposerMessages.isEmpty) + XCTAssertTrue(store.messages.contains(where: { $0.kind == .user && $0.body == "Send after idle." })) + } + + func testNewAndSessionSwitchClearQueuedMessages() { + let store = makeStore() + store.beginResponseRunForTesting(runID: "active-run") + store.draft = "Clear me." + store.sendDraft() + + store.runSlashCommand(SlashCommand(command: "new", description: "Start a fresh chat session")) + + XCTAssertTrue(store.queuedComposerMessages.isEmpty) + + store.beginResponseRunForTesting(runID: "active-run-2") + store.draft = "Clear me too." + store.sendDraft() + store.loadHalosSessionsForTesting(payload: [ + "sessions": [ + [ + "key": "\(MissionControlStore.halosSessionKey):other", + "label": "Other", + "status": "idle", + "lastUserMessagePreview": "Other", + "updatedAt": Date().timeIntervalSince1970 * 1000, + ], + ] + ]) + + store.switchHalosSession("\(MissionControlStore.halosSessionKey):other") + + XCTAssertTrue(store.queuedComposerMessages.isEmpty) + } + func testDeleteHalosSessionRemovesItLocallyAndSelectsNext() { let store = makeStore() let first = "\(MissionControlStore.halosSessionKey):first"