// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import AgentKit import Foundation import TermindApplication import TermindDomain import TermindInfrastructure import TermindPresentation import TermindRemoteInfrastructure import Testing import struct TermindDomain.Host @testable import Termind @Suite @MainActor struct WindowStateTests { @Test func showHistoryLeavesLivePanesRunningAndAlwaysShowsConnections() throws { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let session = try #require(workspace.focusedPane.sshSession) session.status = .connected let window = WindowModel(workspaces: [workspace]) window.showsSidebar = false window.showConnectionHistory() window.showConnectionHistory() #expect(window.selectedDestination == .vault) #expect(window.showsSidebar && window.sidebarMode == .history) #expect(window.workspaces.first === workspace) #expect(session.status == .connected) } @Test func recentClosedReopensASpecificPaneWithoutConsumingTheNewestItem() throws { let first = Workspace(pane: Pane(.empty)) let second = Workspace(pane: Pane(.empty)) let window = WindowModel(workspaces: [first, second]) let app = AppModel(hosts: HostLibraryModel()) app.registerWindow(window) window.close(first.id) window.close(second.id) #expect(app.closedWorkspaceRecords.flatMap(\.items).map(\.id) == [second.id, first.id]) app.reopenClosedRecord(first.id, in: window) #expect(window.selectedWorkspace === first) #expect(app.closedWorkspaceRecords.flatMap(\.items).map(\.id) == [second.id]) app.reopenClosedRecord(first.id, in: window) #expect(window.workspaces.count == 1) app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) #expect(window.selectedWorkspace === second) #expect(app.closedWorkspaceRecords.isEmpty) } @Test(arguments: [WindowDestination.vault, .metrics, .settings, .assistant]) func showAssistantWorksOutsideWorkspacesWithoutReplacingItsNavigation(_ destination: WindowDestination) { let window = WindowModel() window.showAssistantArchive() window.selectedDestination = destination window.showsSidebar = false window.showAssistant() window.showAssistant() #expect(window.selectedDestination == .assistant) #expect(window.showsSidebar) #expect(window.sidebarMode == .assistant) #expect(window.isAssistantArchivePresented) #expect(window.assistantLibrary.conversations.isEmpty) } @Test func showAssistantKeepsTheCurrentWorkspaceAndConversation() { let window = WindowModel() let conversation = window.assistantLibrary.createConversation() let workspace = Workspace(pane: Pane(.empty)) window.open(workspace) window.openAssistantInSidebar(conversation.id) window.showsSidebar = false window.sidebarMode = .navigation window.showAssistant() window.showAssistant() #expect(window.selectedWorkspace === workspace) #expect(window.showsSidebar && window.sidebarMode == .assistant) #expect(window.assistantLibrary.selectedConversation === conversation) } @Test func sshAndTelnetPanesUseDistinctProtocolSymbols() { let host = Host( name: "Console", host: "console.example.invalid", telnetEnabled: true) #expect(Pane.sshSession(host: host).symbol == MenuSymbol.ssh) #expect(Pane.telnetSession(host: host).symbol == MenuSymbol.telnet) #expect(MenuSymbol.ssh != MenuSymbol.telnet) } @Test func assistantManualOrderMovesAcrossPinnedBoundaryAndStaysStableAfterActivity() throws { let first = ConversationModel(updatedAt: Date(timeIntervalSinceReferenceDate: 10)) let second = ConversationModel(updatedAt: Date(timeIntervalSinceReferenceDate: 20)) let pinned = ConversationModel(updatedAt: Date(timeIntervalSinceReferenceDate: 30)) pinned.isPinned = true let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [first, second, pinned] )) let updates = try #require( library.orderUpdates( moving: first.id, toPinned: true, before: pinned.id )) library.applyOrder(updates) #expect(library.pinnedConversations.map(\.id) == [first.id, pinned.id]) #expect(library.activeConversations.map(\.id) == [second.id]) first.updatedAt = Date(timeIntervalSinceReferenceDate: 100) #expect(library.pinnedConversations.map(\.id) == [first.id, pinned.id]) } @Test func newWindowStartsOnWelcome() { let window = WindowModel() #expect(window.selectedDestination == .vault) #expect(window.vaultDestination == .welcome) } @Test func numericWorkspaceSelectionExcludesVaultAndChats() { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let window = WindowModel(workspaces: [workspace]) window.select(index: 0) #expect(window.selectedDestination == .workspace(workspace.id)) window.selectVault() window.select(index: 1) #expect(window.selectedDestination == .vault) window.selectVault() window.closeSelected() #expect(window.workspaces.map(\.id) == [workspace.id]) } @Test func closingTheLastWorkspaceKeepsTheGlobalSidebarMode() { let workspace = Workspace.sshSession( host: Host(name: "Server", host: "server.example") ) let window = WindowModel(workspaces: [workspace]) window.sidebarMode = .history window.close(workspace.id) window.reconcileSidebarMode() #expect(window.workspaces.isEmpty) #expect(window.selectedDestination == .vault) #expect(window.sidebarMode == .history) } @Test(arguments: [false, true]) func closingWorkspacesPromotesTheVisibleSidebarConversationFullScreen(closeAll: Bool) { let workspace = Workspace.sshSession( host: Host(name: "Server", host: "server.example") ) let conversation = ConversationModel() let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [conversation] )) let window = WindowModel(workspaces: [workspace], assistantLibrary: library) window.sidebarMode = .assistant window.openAssistantInSidebar(conversation.id) if closeAll { window.closeAll() } else { window.close(workspace.id) } #expect(window.workspaces.isEmpty) #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.selectedConversation === conversation) #expect(window.sidebarMode == .assistant) } @Test func closingTheLastWorkspacePromotesTheVisibleSidebarArchiveFullScreen() { let workspace = Workspace.sshSession( host: Host(name: "Server", host: "server.example") ) let window = WindowModel(workspaces: [workspace]) window.sidebarMode = .assistant window.showAssistantArchiveInSidebar() window.close(workspace.id) #expect(window.workspaces.isEmpty) #expect(window.selectedDestination == .assistant) #expect(window.isAssistantArchivePresented) #expect(window.assistantLibrary.selectedConversation == nil) } @Test func assistantFeatureOwnsConversationLifecycleOutsideWorkspaces() { let window = WindowModel() let first = window.assistantLibrary.createConversation() let second = window.assistantLibrary.createConversation() window.selectAssistant(second.id) #expect(window.selectedDestination == .assistant) #expect(window.workspaces.isEmpty) #expect(window.assistantLibrary.conversations.map(\.id) == [second.id, first.id]) window.assistantLibrary.delete(second.id) #expect(window.assistantLibrary.selectedConversationID == first.id) } @Test func assistantLibrarySeparatesPinnedActiveAndArchivedByRecentActivity() { let old = ConversationModel(updatedAt: Date(timeIntervalSince1970: 1)) let recent = ConversationModel(updatedAt: Date(timeIntervalSince1970: 3)) let pinned = ConversationModel(updatedAt: Date(timeIntervalSince1970: 2)) pinned.isPinned = true let archived = ConversationModel(updatedAt: Date(timeIntervalSince1970: 4)) archived.archivedAt = .now let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [old, recent, pinned, archived] )) #expect(library.pinnedConversations.map(\.id) == [pinned.id]) #expect(library.activeConversations.map(\.id) == [recent.id, old.id]) #expect(library.archivedConversations.map(\.id) == [archived.id]) } @Test func archivingClearsPinAndUnarchivingReturnsTheConversationToChats() { let conversation = ConversationModel() conversation.isPinned = true let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [conversation] )) let window = WindowModel(assistantLibrary: library) window.setAssistantArchived(conversation.id, archived: true) #expect(conversation.isArchived) #expect(!conversation.isPinned) #expect(library.archivedConversations.map(\.id) == [conversation.id]) window.setAssistantArchived(conversation.id, archived: false) #expect(!conversation.isArchived) #expect(library.activeConversations.map(\.id) == [conversation.id]) } @Test func archivingTheOpenConversationReturnsMainAndSidebarSurfacesToNewChat() { let conversation = ConversationModel() let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [conversation] )) let window = WindowModel(assistantLibrary: library) window.selectAssistant(conversation.id) window.openAssistantInSidebar(conversation.id) window.setAssistantArchived(conversation.id, archived: true) #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.selectedConversation == nil) #expect(window.sidebarAssistantConversation == nil) #expect(!window.isAssistantArchivePresented) } @Test func archiveIsASeparateWindowDestinationAndConversationNavigationLeavesIt() { let conversation = ConversationModel() conversation.archivedAt = .now let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [conversation] )) let window = WindowModel(assistantLibrary: library) window.showAssistantArchive() #expect(window.isAssistantArchivePresented) #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.selectedConversation == nil) window.selectAssistant(conversation.id) #expect(!window.isAssistantArchivePresented) #expect(window.assistantLibrary.selectedConversation === conversation) window.showAssistantArchiveInSidebar() #expect(window.isAssistantArchivePresented) #expect(window.sidebarAssistantConversation == nil) window.openAssistantInSidebar(conversation.id) #expect(!window.isAssistantArchivePresented) #expect(window.sidebarAssistantConversation === conversation) } @Test func openingConversationFullScreenKeepsAssistantSidebarSelected() { let conversation = ConversationModel() let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [conversation] )) let window = WindowModel(assistantLibrary: library) window.openAssistantFullScreen(conversation.id) #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.selectedConversation === conversation) #expect(window.sidebarMode == .assistant) #expect(window.sidebarAssistantConversation === conversation) } @Test func openingRecentHostKeepsTheGlobalHistoryMode() throws { let host = Host(name: "Recent", host: "recent.example") let app = AppModel(hosts: HostLibraryModel(hosts: [host])) let window = WindowModel() window.sidebarMode = .history let workspace = try #require(app.open(host, as: .sshSession, in: window)) #expect(window.selectedDestination == .workspace(workspace.id)) #expect(window.sidebarMode == .history) window.selectVault() window.select(workspace.id) #expect(window.sidebarMode == .history) } @Test func forkTitlesUseTheLowestFreeWorkspaceStyleNumber() { let source = ConversationModel() source.customTitle = "Question" let store = AssistantConversationStore(conversations: [source]) #expect(store.nextForkTitle(for: source) == "Question (1)") let firstFork = ConversationModel() firstFork.customTitle = "Question (1)" store.insert(firstFork) #expect(store.nextForkTitle(for: source) == "Question (2)") #expect(store.nextForkTitle(for: firstFork) == "Question (2)") store.remove(firstFork.id) #expect(store.nextForkTitle(for: source) == "Question (1)") } @Test(arguments: [AgentRunState.idle, .running, .waitingForApproval], [false, true]) func failedMetadataSaveKeepsConversationStateAndReportsTheError( state: AgentRunState, compacting: Bool ) async { let repository = FailingConversationOperationRepository() let hosts = HostLibraryModel() let dependencies = AppDependencies( hostRepository: InMemoryHostRepository(), outboxRunner: nil, agentRunRepository: repository ) let app = AppModel(hosts: hosts, dependencies: dependencies) let window = WindowModel() app.registerWindow(window) let conversation = window.assistantLibrary.createConversation() conversation.handle(.runState(state)) if compacting { conversation.beginCompaction() } window.toggleAssistantPin(conversation.id) for _ in 0..<100 { if window.assistantOperationError != nil { break } await Task.yield() } #expect(!conversation.isPinned) #expect(window.assistantOperationError == "Storage unavailable") #expect(conversation.runState == state) #expect(conversation.isCompacting == compacting) } @Test func failedCompactionSaveDoesNotChangeTheInMemoryContext() async throws { let repository = FailingConversationOperationRepository() let hosts = HostLibraryModel() let app = AppModel( hosts: hosts, dependencies: AppDependencies( hostRepository: InMemoryHostRepository(), outboxRunner: nil, agentRunRepository: repository ) ) let conversation = ConversationModel() // Both turns are sized in tokens, because that is the unit the cut is // chosen in. A last turn worth less than `keepRecentTokens` never // satisfies the budget, so the search walks past it to the start of the // transcript and `compactionSpan` returns nil with nothing to compact. let keepRecentTokens = 2 conversation.appendUserTurn("old " + filler(tokens: 1_000)) conversation.appendUserTurn("recent " + filler(tokens: keepRecentTokens)) let span = try #require( conversation.compactionSpan(keepRecentTokens: keepRecentTokens) ) let record = try #require( conversation.prepareCompaction( summary: "summary", cut: span.cut )) let transcript = conversation.transcript let estimate = conversation.contextEstimate await #expect(throws: (any Error).self) { try await app.persistAndApplyCompaction(record, to: conversation, cut: span.cut) } #expect(conversation.transcript == transcript) #expect(conversation.contextEstimate == estimate) #expect(conversation.compactions.isEmpty) } /// Each window used to build its own `ConversationModel` for the same /// conversation id, so a reply streaming in one window was invisible in the /// other and both could start a run against the same stored history. @Test func twoWindowsShareOneInstanceOfEachConversationButNotTheSelection() { let shared = AssistantConversationStore() let first = WindowModel() let second = WindowModel() first.assistantLibrary.adopt(shared) second.assistantLibrary.adopt(shared) let conversation = first.assistantLibrary.createConversation() let other = first.assistantLibrary.createConversation() #expect(second.assistantLibrary.conversations.map(\.id) == [other.id, conversation.id]) #expect(second.assistantLibrary.conversation(id: conversation.id) === conversation) second.selectAssistant(conversation.id) #expect(second.assistantLibrary.selectedConversationID == conversation.id) #expect(first.assistantLibrary.selectedConversationID == other.id) // A delete in one window empties the other's stale selection too. first.assistantLibrary.delete(conversation.id) second.assistantLibrary.delete(conversation.id) #expect(second.assistantLibrary.conversations.map(\.id) == [other.id]) #expect(second.assistantLibrary.selectedConversationID == other.id) } @Test func aWindowThatAdoptsTheStoreLateKeepsWhatItAlreadyHad() { let window = WindowModel() let early = window.assistantLibrary.createConversation() let shared = AssistantConversationStore() window.assistantLibrary.adopt(shared) #expect(shared.conversations.map(\.id) == [early.id]) #expect(window.assistantLibrary.selectedConversationID == early.id) } @Test func selectingAssistantDoesNotCreateAConversation() { let window = WindowModel() window.activateSidebarMode(.assistant) #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.conversations.isEmpty) #expect(window.assistantLibrary.selectedConversation == nil) } @Test func assistantAndNavigatorModeRailRoundTripsToThePreviousVaultRoute() { let conversation = ConversationModel() let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [conversation] )) let window = WindowModel(assistantLibrary: library) window.selectAssistant(conversation.id) window.openVaultWork(.hostEditor(nil), on: .hosts) // The Assistant mode can already be selected after navigation elsewhere; // pressing its selected button must still activate the Assistant route. window.sidebarMode = .assistant window.activateSidebarMode(.assistant) #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.selectedConversation === conversation) window.activateSidebarMode(.navigation) #expect(window.selectedDestination == .vault) #expect(window.vaultDestination == .hosts) #expect(window.vaultWorkRoute == .hostEditor(nil)) } @Test func assistantModeRailStaysCompactInWorkspaceAndNavigatorReturnsThereFromFullScreen() { let workspace = Workspace.sshSession( host: Host(name: "Server", host: "server.example") ) let window = WindowModel(workspaces: [workspace]) window.activateSidebarMode(.assistant) #expect(window.sidebarMode == .assistant) #expect(window.selectedDestination == .workspace(workspace.id)) let conversation = window.assistantLibrary.createConversation() window.openAssistantFullScreen(conversation.id) window.activateSidebarMode(.navigation) #expect(window.sidebarMode == .navigation) #expect(window.selectedDestination == .workspace(workspace.id)) #expect(window.sidebarAssistantConversation === conversation) } @Test func navigatorFallsBackToVaultWhenTheAssistantReturnWorkspaceWasClosed() { let workspace = Workspace.sshSession( host: Host(name: "Server", host: "server.example") ) let window = WindowModel(workspaces: [workspace]) window.openAssistantFullScreen() window.close(workspace.id) window.activateSidebarMode(.navigation) #expect(window.workspaces.isEmpty) #expect(window.selectedDestination == .vault) } @Test func nonNavigationToolModesAndDirectModeAssignmentDoNotNavigate() { let window = WindowModel() window.openPage(.hosts) window.sidebarMode = .assistant #expect(window.selectedDestination == .vault) window.activateSidebarMode(.snippets) #expect(window.selectedDestination == .vault) window.activateSidebarMode(.history) #expect(window.selectedDestination == .vault) } @Test func enteringAssistantKeepsTheVolatileConversationSelection() { let conversation = ConversationModel() let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [conversation] )) let window = WindowModel(assistantLibrary: library) window.selectAssistant(conversation.id) window.selectVault() window.activateSidebarMode(.assistant) #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.selectedConversation === conversation) let draft = window.openNewAssistantChat() #expect(window.assistantLibrary.selectedConversation == nil) #expect(draft === window.assistantLibrary.draftConversation) window.selectVault() window.activateSidebarMode(.assistant) #expect(window.assistantLibrary.selectedConversation == nil) #expect(draft === window.assistantLibrary.draftConversation) } @Test func enteringAssistantKeepsTheVolatileArchiveDestination() { let conversation = ConversationModel() let library = AssistantLibraryModel( store: AssistantConversationStore( conversations: [conversation] )) let window = WindowModel(assistantLibrary: library) window.selectAssistant(conversation.id) window.showAssistantArchive() window.selectVault() window.activateSidebarMode(.assistant) #expect(window.isAssistantArchivePresented) #expect(window.assistantLibrary.selectedConversation == nil) } @Test func assistantStartsOnNewChatWhenNoVolatileSelectionExists() { let previousID = UUID() let library = AssistantLibraryModel() library.restore([ StoredAgentConversation( id: previousID, messages: [AgentTranscriptMessage(role: .user, text: "Previous question")], lastRunState: .completed ) ]) let window = WindowModel(assistantLibrary: library) window.selectAssistant() #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.selectedConversation == nil) #expect(window.assistantLibrary.draftConversation.isEmpty) #expect(window.assistantLibrary.conversations.map(\.id) == [previousID]) } @Test func newChatJoinsHistoryOnlyAfterItsFirstSend() async { let app = AppModel(hosts: HostLibraryModel()) let window = WindowModel() let draft = window.openNewAssistantChat() #expect(window.assistantLibrary.selectedConversation == nil) #expect(window.assistantLibrary.conversations.isEmpty) draft.composer.draft = "Hello from a new chat" let started = app.sendNewAgentDraft(in: window.assistantLibrary) #expect(started === draft) #expect(window.assistantLibrary.selectedConversation === draft) #expect(window.assistantLibrary.conversations.map(\.id) == [draft.id]) #expect(window.assistantLibrary.draftConversation !== draft) await started?.stopAndWait() } @Test func sidebarDraftCreatesConversationOnlyOnFirstValidSend() async { let app = AppModel(hosts: HostLibraryModel()) let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example")) let window = WindowModel(workspaces: [workspace]) #expect(app.sendNewAgentConversation(window.sidebar.assistantDraft, in: window.assistantLibrary) == nil) #expect(window.assistantLibrary.conversations.isEmpty) window.sidebar.assistantDraft.composer.draft = " Explain this failure. " let conversation = app.sendNewAgentConversation( window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext) if let conversation { window.assistantSidebarConversationDidStart(conversation) } #expect(conversation?.messages.map(\.text) == ["Explain this failure."]) #expect(window.assistantLibrary.conversations.count == 1) #expect(window.assistantLibrary.conversations.first?.id == conversation?.id) #expect(window.sidebarAssistantConversation?.id == conversation?.id) #expect(window.selectedDestination == .workspace(workspace.id)) #expect(window.sidebar.assistantDraft.isEmpty) await conversation?.stopAndWait() } @Test func sidebarConversationNavigationSharesSelectionAndDeletionReturnsHome() { let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example")) let conversation = ConversationModel() conversation.composer.draft = "Hello" let library = AssistantLibraryModel() conversation.sendDraft() library.insertAndSelect(conversation) let window = WindowModel(workspaces: [workspace], assistantLibrary: library) window.openAssistantInSidebar(conversation.id) #expect(window.sidebarAssistantConversation?.id == conversation.id) #expect(window.assistantLibrary.selectedConversation === conversation) #expect(window.selectedDestination == .workspace(workspace.id)) window.closeAssistantInSidebar() #expect(window.sidebarAssistantConversation == nil) #expect(window.assistantLibrary.selectedConversation == nil) window.openAssistantInSidebar(conversation.id) window.deleteAssistantConversation(conversation.id) #expect(window.sidebarAssistantConversation == nil) #expect(window.assistantLibrary.conversations.isEmpty) } @Test func assistantSidebarShowsFocusedWorkspaceContext() { let host = Host(name: "Files", host: "files.example") let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv/app") workspace.focusedPane.sftpChannel?.homeResolution = .unavailable let window = WindowModel(workspaces: [workspace]) window.hostStore = HostLibraryModel(hosts: [host]) #expect(window.sidebar.sessionContext.chips.map(\.kind) == [.host, .directory]) #expect(window.sidebar.sessionContext.chips.map(\.label) == ["Files", "/srv/app"]) // The id the host tools require, taken from the focused pane rather than // left for the model to guess from the name. #expect(window.sidebar.sessionContext.hostID == workspace.focusedPane.hostID) #expect(window.sidebar.sessionContext.hostID == host.id) #expect(window.sidebar.sessionContext.paneID == nil) } @Test func terminalContextFollowsPaneFocusWithoutConfusingHostAndPaneIDs() { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let first = workspace.focusedPane let window = WindowModel(workspaces: [workspace]) window.hostStore = HostLibraryModel(hosts: [host]) let submitted = window.sidebar.sessionContext #expect(submitted.paneID == first.id) #expect(submitted.hostID == host.id) workspace.splitFocusedPane(axis: .horizontal, with: .sshSession(host: host)) #expect(window.sidebar.sessionContext.paneID == workspace.focusedPane.id) #expect(window.sidebar.sessionContext.paneID != submitted.paneID) #expect(window.sidebar.sessionContext.hostID == submitted.hostID) #expect(submitted.modelContext?.contains("pane_id: \(first.id.uuidString)") == true) workspace.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: host)) #expect(window.sidebar.sessionContext.paneID == nil) workspace.focus(first.id) #expect(window.sidebar.sessionContext.paneID == first.id) window.selectAssistant() window.refreshSessionContext() #expect(window.sidebar.sessionContext == .none) } @Test func focusedSFTPPathChangesUpdateSessionContext() { let host = Host(name: "Files", host: "files.example") let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv") let sftpChannel = workspace.focusedPane.sftpChannel! sftpChannel.homeResolution = .unavailable let window = WindowModel(workspaces: [workspace]) window.hostStore = HostLibraryModel(hosts: [host]) sftpChannel.navigate(to: "/srv/releases") #expect(window.sidebar.sessionContext.chips.map(\.kind) == [.host, .directory]) #expect(window.sidebar.sessionContext.chips.map(\.label) == ["Files", "/srv/releases"]) sftpChannel.goBack() #expect(window.sidebar.sessionContext.chips.map(\.label) == ["Files", "/srv"]) } @Test func backgroundSFTPPathDoesNotReplaceFocusedPaneContext() { let host = Host(name: "Files", host: "files.example") let workspace = Workspace.sftpChannel(host: host, initialPath: "/focused") let focusedPane = workspace.focusedPane focusedPane.sftpChannel?.homeResolution = .unavailable workspace.splitFocusedPane( axis: .horizontal, with: .sftpChannel(host: host, initialPath: "/background") ) let backgroundSession = workspace.focusedPane.sftpChannel! workspace.focus(focusedPane.id) let window = WindowModel(workspaces: [workspace]) window.hostStore = HostLibraryModel(hosts: [host]) backgroundSession.navigate(to: "/background/changed") #expect(window.sidebar.sessionContext.chips.map(\.label) == ["Files", "/focused"]) } /// The reported leak: session context must belong to the sidebar, so a /// conversation promoted out of it arrives in the Assistant destination carrying /// nothing about a host it can no longer see. @Test func sendingFromTheSidebarDoesNotAttachSessionContextToTheConversation() async { let app = AppModel(hosts: HostLibraryModel()) let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv/app") workspace.focusedPane.sftpChannel?.homeResolution = .unavailable let window = WindowModel(workspaces: [workspace]) window.hostStore = HostLibraryModel(hosts: [host]) window.sidebar.assistantDraft.composer.draft = "Why is this slow?" #expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"]) let conversation = app.sendNewAgentConversation( window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext) if let conversation { window.assistantSidebarConversationDidStart(conversation) } #expect(conversation?.composer.attachments.isEmpty == true) #expect(conversation?.transcript.first?.contextSnapshot?.sessionContext?.contains("/srv/app") == true) #expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"]) await conversation?.stopAndWait() } /// Leaving every Workspace used to freeze the last host and directory on /// screen, because the refresh returned early instead of clearing. @Test func leavingEveryWorkspaceClearsSessionContext() { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv/app") let window = WindowModel(workspaces: [workspace]) window.hostStore = HostLibraryModel(hosts: [host]) #expect(!window.sidebar.sessionContext.isEmpty) window.selectVault() window.refreshSessionContext() #expect(window.sidebar.sessionContext == .none) } /// The Ask Assistant path attaches the failure log, promotes the draft, and /// the log goes with the message it belongs to rather than sitting in the /// draft as text the user could half-delete. @Test func promotingASidebarDraftSendsItsAttachmentWithTheFirstTurn() async { let app = AppModel(hosts: HostLibraryModel()) let conversation = ConversationModel() conversation.attach( ContextChip(kind: .output, label: "Connection error", content: "ssh: refused") ) conversation.composer.draft = "Explain this" #expect(app.sendNewAgentConversation(conversation, in: AssistantLibraryModel()) === conversation) #expect(conversation.messages.first?.text == "Explain this") #expect(conversation.transcript.first?.text.contains("ssh: refused") == true) #expect(conversation.composer.attachments.isEmpty, "the attachment went with the turn") #expect(conversation.transcript.first?.contextSnapshot != nil) await conversation.stopAndWait() } @Test func sshFailureAssistantDraftKeepsTheExistingPromptAndLastFourThousandLogCharacters() throws { let conversation = ConversationModel() let retainedLog = String(repeating: "x", count: 3_995) + "-tail" let log = "discarded-prefix" + retainedLog let message = "Connection refused" SSHFailureAssistantDraft.populate( conversation, message: message, log: log ) let attachment = try #require(conversation.composer.attachments.first) #expect(conversation.composer.attachments.count == 1) #expect(attachment.kind == .output) #expect(attachment.label == String(localized: "Connection Log")) #expect(attachment.content == retainedLog) #expect(attachment.content.count == 4_000) #expect( conversation.composer.draft == String(localized: "SSH connection failed: \(message). What should I check?") ) } /// Opening a conversation in the sidebar used to stamp the focused pane's /// host onto it, which is how that host followed the conversation into the /// Assistant destination. @Test func openingASidebarConversationLeavesItUnmarkedByTheFocusedPane() { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let conversation = ConversationModel() conversation.composer.draft = "Explain this" let library = AssistantLibraryModel() conversation.sendDraft() library.insertAndSelect(conversation) conversation.attach( ContextChip(kind: .output, label: "Connection error", content: "ssh: refused") ) let window = WindowModel(workspaces: [workspace], assistantLibrary: library) window.hostStore = HostLibraryModel(hosts: [host]) window.openAssistantInSidebar(conversation.id) #expect(conversation.composer.attachments.map(\.label) == ["Connection error"]) #expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server"]) } @Test func focusedSSHSystemInfoUpdatesSessionContextAndSFTPClearsIt() { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let sshPane = workspace.focusedPane let sshSession = sshPane.sshSession! let window = WindowModel(workspaces: [workspace]) window.hostStore = HostLibraryModel(hosts: [host]) let info = RemoteSystemInfo( distributionID: "ubuntu", distributionVersion: "24.04", kernelName: "Linux", kernelRelease: "6.8.0", architecture: "x86_64" ) sshSession.receiveShellIntegrationEvent( .available(shell: "bash", systemInfo: info, homeDirectory: "/home/test") ) #expect(window.sidebar.sessionContext.systemInfo == info) // OS facts reach the model but are not a pill: they are disclosed by the // Shell Integration setting, not by the composer. #expect(window.sidebar.sessionContext.chips.map(\.kind) == [.host]) workspace.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: host)) #expect(window.sidebar.sessionContext.systemInfo == nil) workspace.focus(sshPane.id) #expect(window.sidebar.sessionContext.systemInfo == info) sshSession.receiveShellIntegrationEvent( .available(shell: "bash", systemInfo: info, homeDirectory: "/home/test") ) sshSession.disconnect() #expect(window.sidebar.sessionContext.systemInfo == nil) } @Test func vaultRoutesNeverBecomeWorkspaceTabs() { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let window = WindowModel(workspaces: [workspace]) window.openPage(.hosts) #expect(window.selectedDestination == .vault) #expect(window.vaultDestination == .hosts) #expect(window.workspaces.map(\.id) == [workspace.id]) window.select(workspace.id) #expect(window.selectedDestination == .workspace(workspace.id)) } @Test(arguments: [true, false]) func draggingAWorkspacePreservesSelectionAndTemporarilyDisplaysItsMergeTarget(dragSelected: Bool) { let first = Workspace.sshSession(host: Host(name: "First", host: "first.example")) let second = Workspace.sshSession(host: Host(name: "Second", host: "second.example")) let window = WindowModel(workspaces: [first, second]) window.select(first.id) window.dragging = .workspace(dragSelected ? first.id : second.id) #expect(window.displayedWorkspace?.id == (dragSelected ? second.id : first.id)) #expect(window.selectedWorkspace?.id == first.id) #expect(window.selectedWorkspaceID == first.id) window.dragging = nil #expect(window.displayedWorkspace?.id == first.id) #expect(window.selectedWorkspaceID == first.id) } @Test func draggingSelectedWorkspaceWithoutMergeNeighbourKeepsItsSelectionDisplayed() { let workspace = Workspace.sshSession(host: Host(name: "Only", host: "only.example")) let window = WindowModel(workspaces: [workspace]) window.select(workspace.id) window.dragging = .workspace(workspace.id) #expect(window.displayedWorkspace?.id == workspace.id) #expect(window.selectedWorkspaceID == workspace.id) } @Test func droppingNestedPaneOnNavigatorDetachesItIntoSelectedWorkspace() throws { let workspace = Workspace.sshSession( host: Host(name: "First", host: "first.example") ) let firstPane = workspace.focusedPane workspace.splitFocusedPane( axis: .horizontal, with: .sshSession(host: Host(name: "Second", host: "second.example")) ) let detachedPane = workspace.focusedPane workspace.splitFocusedPane( axis: .vertical, with: .sshSession(host: Host(name: "Third", host: "third.example")) ) let thirdPane = workspace.focusedPane let window = WindowModel(workspaces: [workspace]) let detachedWorkspace = try #require(window.detachPane(detachedPane.id, at: 0)) #expect(window.workspaces.map(\.id) == [detachedWorkspace.id, workspace.id]) #expect(detachedWorkspace.panes.map(\.id) == [detachedPane.id]) #expect(workspace.panes.map(\.id) == [firstPane.id, thirdPane.id]) #expect(window.selectedWorkspace === detachedWorkspace) } /// The inverse of a merge, in one move. Merging is a single gesture, so /// undoing it must be too — and the last pane cannot be dragged out at all, /// because a pane alone in its Workspace has nothing to detach from. @Test func separatingPanesUndoesAMergeKeepingLayoutOrder() throws { let workspace = Workspace.sshSession( host: Host(name: "First", host: "first.example") ) let first = workspace.focusedPane workspace.splitFocusedPane( axis: .horizontal, with: .sshSession(host: Host(name: "Second", host: "second.example")) ) let second = workspace.focusedPane workspace.splitFocusedPane( axis: .vertical, with: .sshSession(host: Host(name: "Third", host: "third.example")) ) let third = workspace.focusedPane let neighbour = Workspace.sshSession( host: Host(name: "Other", host: "other.example") ) let window = WindowModel(workspaces: [workspace, neighbour]) let laidOut = workspace.panes.map(\.id) window.separatePanes(in: workspace.id) // One Workspace per pane, in the order the panes were laid out, all // before the Workspace that already followed them. #expect(window.workspaces.count == 4) #expect(window.workspaces.flatMap { $0.panes.map(\.id) } == laidOut + [neighbour.panes[0].id]) #expect(window.workspaces.allSatisfy { $0.panes.count == 1 }) // The row that was right-clicked is still there, and still selected. #expect(window.workspaces.first?.id == workspace.id) #expect(window.selectedWorkspaceID == workspace.id) #expect(Set([first.id, second.id, third.id]) == Set(laidOut)) } /// Nothing to take apart is not an error, and must not reorder anything. @Test func separatingPanesLeavesASingleePaneWorkspaceAlone() { let workspace = Workspace.sshSession(host: Host(name: "Only", host: "only.example")) let window = WindowModel(workspaces: [workspace]) window.separatePanes(in: workspace.id) #expect(window.workspaces.map(\.id) == [workspace.id]) #expect(workspace.panes.count == 1) } @Test func hostEditorIsVaultNavigationAndCloseWorkspaceCannotCloseHiddenWork() { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let window = WindowModel(workspaces: [workspace]) window.openHostEditor(host: host) #expect(window.selectedDestination == .vault) #expect(window.vaultDestination == .hosts) #expect(window.vaultWorkRoute == .hostEditor(host.id)) window.closeSelected() #expect(window.workspaces.map(\.id) == [workspace.id]) } @Test func newHostEditorCarriesOnlyCreationGroupContext() { let window = WindowModel() let groupID = UUID() window.openHostEditor(host: nil, groupID: groupID) #expect(window.vaultWorkRoute == .hostEditor(nil, groupID: groupID)) let host = Host(name: "Server", host: "server.example", groupID: UUID()) window.openHostEditor(host: host, groupID: groupID) #expect(window.vaultWorkRoute == .hostEditor(host.id, groupID: nil)) } @Test(arguments: [false, true]) func tailscaleDetailsFollowEligibleHostsAndCloseForAnOrdinaryHost(_ isManaged: Bool) { let first = Host(name: "First", host: "first.example") var second = Host(name: "Second", host: "second.example") if isManaged { second.tailscale = TailscaleHostReference(groupID: UUID(), stableNodeID: "second") } let window = WindowModel() window.openVaultWork(.tailscaleDetails(first.id), on: .hosts) window.updateTailscaleDetails(for: second, hasPeer: !isManaged) #expect(window.vaultWorkRoute == .tailscaleDetails(second.id)) window.updateTailscaleDetails(for: first, hasPeer: false) #expect(window.vaultWorkRoute == nil) #expect(window.vaultDestination == .hosts) window.updateTailscaleDetails(for: second, hasPeer: true) #expect(window.vaultWorkRoute == nil) } @Test func clearingTailscaleSelectionClosesDetailsWithoutAffectingOtherEditors() { let host = Host(name: "Server", host: "server.example") let window = WindowModel() window.openVaultWork(.tailscaleDetails(host.id), on: .hosts) window.updateTailscaleDetails(for: nil, hasPeer: false) #expect(window.vaultWorkRoute == nil) window.openHostEditor(host: host) window.updateTailscaleDetails(for: nil, hasPeer: false) #expect(window.vaultWorkRoute == .hostEditor(host.id)) } @Test func deletingTheDisplayedTailscaleHostClosesDetails() { let host = Host(name: "Server", host: "server.example") let window = WindowModel() window.openVaultWork(.tailscaleDetails(host.id), on: .hosts) window.closeWorkspaces(for: UUID()) #expect(window.vaultWorkRoute == .tailscaleDetails(host.id)) window.closeWorkspaces(for: host.id) #expect(window.vaultWorkRoute == nil) } @Test func changingVaultPageClosesRightColumnWork() { let window = WindowModel(vaultDestination: .hosts) window.openVaultWork( .snippetEditor(snippetID: nil, packageID: nil), on: .snippets ) #expect(window.vaultDestination == .snippets) #expect(window.vaultWorkRoute == .snippetEditor(snippetID: nil, packageID: nil)) window.openPage(.keychain) #expect(window.vaultDestination == .keychain) #expect(window.vaultWorkRoute == nil) } @Test func windowsKeepPresentationAndVaultSelectionIsolated() { let first = WindowModel(vaultDestination: .hosts) let second = WindowModel(vaultDestination: .hosts) let host = UUID() first.hostChooser = .connect first.selectVaultItem(.host(host), on: .hosts) #expect(first.hostChooser == .connect) #expect(second.hostChooser == nil) #expect(second.vaultSelection(for: .hosts).isEmpty) } @Test func windowPresentationRouteIsMutuallyExclusive() { let state = WindowPresentationState() state.hostChooser = .connect #expect(state.hostChooser == .connect) state.isQuickOpenPresented = true #expect(state.hostChooser == nil) #expect(state.isQuickOpenPresented) state.hostChooser = .batchStartup #expect(state.hostChooser == .batchStartup) #expect(!state.isQuickOpenPresented) state.hostChooser = nil #expect(state.route == nil) } @Test func featureRoutesReplaceRatherThanStack() { let window = WindowModel(vaultDestination: .hosts) let groupID = UUID() window.openVaultWork( .hostGroupEditor(groupID: nil, parentID: nil), on: .hosts ) window.openVaultWork( .hostGroupEditor(groupID: groupID, parentID: nil), on: .hosts ) #expect( window.vaultWorkRoute == .hostGroupEditor(groupID: groupID, parentID: nil) ) let snippets = SnippetsPageState() let batch = SnippetBatch(snippets: []) snippets.confirmation = .executeDestructive(batch) snippets.confirmation = .deleteSnippets(batch) guard case .deleteSnippets = snippets.confirmation else { Issue.record("Expected one active snippet confirmation route") return } } @Test func protectedVaultDeletionRequestsRemainPendingUntilConfirmed() { let host = Host(name: "Protected", host: "protected.example.invalid") let hosts = HostsPageState() hosts.requestDeletion(of: [host]) guard case .deleteHosts(let pendingHosts) = hosts.confirmation else { Issue.record("Expected a pending Host deletion confirmation") return } #expect(pendingHosts.map(\.id) == [host.id]) let identity = Identity(name: "Deploy", username: "deploy") let keychain = KeychainPageState() keychain.requestDeletion(of: [.identity(identity)]) #expect(keychain.deleting == [.identity(identity)]) } @Test func sessionsMixSSHAndSFTPInWorkspaceAndPaneOrder() { let firstHost = Host(name: "First", host: "first.example") let secondHost = Host(name: "Second", host: "second.example") let mixed = Workspace.sshSession(host: firstHost) let sshPane = mixed.focusedPane mixed.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: firstHost)) let sftpPane = mixed.focusedPane let secondSSH = Workspace.sshSession(host: secondHost) let window = WindowModel(workspaces: [mixed, secondSSH]) #expect(window.sessionPanes.map(\.pane.id) == [sshPane.id, sftpPane.id, secondSSH.focusedPane.id]) #expect(window.sshSessionPanes.map(\.pane.id) == [sshPane.id, secondSSH.focusedPane.id]) #expect(window.sftpChannelPanes.map(\.pane.id) == [sftpPane.id]) #expect(window.sessionPanes.map(\.pane.symbol) == ["apple.terminal", "folder", "apple.terminal"]) } @Test func sftpTitlesUseHostNameWithoutPrefixAndFallBackToSFTP() { let host = Host(name: "Uploads", host: "uploads.example") let store = HostLibraryModel(hosts: [host]) let pane = Pane.sftpChannel(host: host) let missing = Pane(.sftpChannel(SFTPChannelModel(hostID: UUID(), initialPath: "/"))) #expect(pane.title(in: store) == "Uploads") #expect(pane.sidebarTitle(in: store) == "Uploads") #expect(missing.title(in: store) == "SFTP") #expect(missing.sidebarTitle(in: store) == "SFTP") } @Test func selectingAndClosingUnifiedSessionPanesKeepsWorkspaceStateConsistent() { let host = Host(name: "Host", host: "host.example") let workspace = Workspace.sshSession(host: host) let sshPane = workspace.focusedPane workspace.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: host)) let sftpPane = workspace.focusedPane let window = WindowModel(workspaces: [workspace]) workspace.focus(sshPane.id) window.select(workspace.id) workspace.focus(sftpPane.id) window.closePane(sftpPane.id) #expect(window.sessionPanes.map(\.pane.id) == [sshPane.id]) #expect(workspace.focusedPaneID == sshPane.id) window.closePane(sshPane.id) #expect(window.sessionPanes.isEmpty) #expect(window.workspaces.isEmpty) } @Test func appRoutesHostsToSSHAndSFTPChannelsForWorkspaces() throws { let first = Host(name: "First", host: "first.example") let second = Host(name: "Second", host: "second.example") let store = HostLibraryModel(hosts: [first, second]) let app = AppModel(hosts: store) let window = WindowModel() let sshTab = try #require(app.open(first, as: .sshSession, in: window)) let sftpTab = try #require(app.open(second, as: .sftpChannel, in: window)) #expect(sshTab.focusedPane.kind == .sshSession) #expect(sftpTab.focusedPane.kind == .sftpChannel) app.open([first, second], as: .sshSession, layout: .separateWorkspaces, in: window) #expect(window.selectedWorkspaceID == window.workspaces[2].id) #expect(window.workspaces.suffix(2).map(\.focusedPane.kind) == [.sshSession, .sshSession]) app.open([first, second], as: .sftpChannel, layout: .oneWorkspace, in: window) #expect(window.selectedWorkspace?.panes.map(\.kind) == [.sftpChannel, .sftpChannel]) } @Test func reopeningAClosedTerminalCreatesAFreshConnectingSession() throws { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let pane = workspace.focusedPane pane.customTitle = "Logs" let closedSession = try #require(pane.sshSession) let window = WindowModel(workspaces: [workspace]) let app = AppModel(hosts: HostLibraryModel(hosts: [host])) app.registerWindow(window) window.close(workspace.id) #expect(closedSession.status == .disconnected) #expect(!app.closedWorkspaceRecords.isEmpty) app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) let reopenedPane = try #require(window.selectedWorkspace?.focusedPane) let reopenedSession = try #require(reopenedPane.sshSession) #expect(reopenedSession !== closedSession) #expect(reopenedSession.hostID == host.id) #expect(reopenedSession.status == .connecting) #expect(reopenedPane.customTitle == "Logs") #expect(window.selectedWorkspace === workspace) #expect(app.closedWorkspaceRecords.isEmpty) } @Test(arguments: [false, true]) func reopeningAClosedSplitPaneKeepsTheSurvivingSessionRunning(closeSelected: Bool) throws { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let survivor = workspace.focusedPane let survivorSession = try #require(survivor.sshSession) survivorSession.status = .connected let closedPane = Pane.sshSession(host: host) closedPane.customTitle = "Logs" let closedSession = try #require(closedPane.sshSession) closedSession.status = .connected workspace.splitFocusedPane(axis: .vertical, with: closedPane) workspace.broadcastsInput = true let window = WindowModel(workspaces: [workspace]) let app = AppModel(hosts: HostLibraryModel(hosts: [host])) app.registerWindow(window) if closeSelected { window.closeSelected() } else { window.closePane(closedPane.id) } #expect(!app.closedWorkspaceRecords.isEmpty) #expect(closedSession.status == .disconnected) #expect(workspace.panes.map(\.id) == [survivor.id]) app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) let reopened = try #require(window.selectedWorkspace) let reopenedSession = try #require(reopened.focusedPane.sshSession) #expect(window.workspaces.count == 2) #expect(reopened !== workspace) #expect(reopened.panes.map(\.id) == [closedPane.id]) #expect(reopened.focusedPane.customTitle == "Logs") #expect(reopenedSession !== closedSession) #expect(reopenedSession.hostID == host.id) #expect(reopenedSession.status == .connecting) #expect(reopenedSession.inputRouter === reopened) #expect(survivor.sshSession === survivorSession) #expect(survivorSession.status == .connected) #expect(survivorSession.inputRouter === workspace) #expect(!workspace.broadcastsInput && !reopened.broadcastsInput) #expect(app.closedWorkspaceRecords.isEmpty) } @Test func closedPanesAndWorkspacesReopenInReverseCloseOrder() throws { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let first = workspace.focusedPane let second = Pane.sftpChannel(host: host, initialPath: "/var/log") workspace.splitFocusedPane(axis: .horizontal, with: second) let other = Workspace.sshSession(host: host) let window = WindowModel(workspaces: [workspace, other]) let app = AppModel(hosts: HostLibraryModel(hosts: [host])) app.registerWindow(window) window.closePane(second.id) window.close(other.id) window.closePane(first.id) #expect(window.workspaces.isEmpty) app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) #expect(window.selectedWorkspace === workspace) #expect(workspace.panes.map(\.id) == [first.id]) app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) #expect(window.selectedWorkspace === other) app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) let reopened = try #require(window.selectedWorkspace) #expect(reopened.panes.map(\.id) == [second.id]) #expect(reopened.focusedPane.sftpChannel?.path == "/var/log") #expect(window.workspaces.count == 3) #expect(app.closedWorkspaceRecords.isEmpty) } @Test func individuallyClosedPanesShareTheFifteenItemReopenLimit() throws { let survivor = Pane(.empty) let workspace = Workspace(pane: survivor) let window = WindowModel(workspaces: [workspace]) let app = AppModel(hosts: HostLibraryModel()) app.registerWindow(window) let closedPanes = (0..<16).map { _ in Pane(.empty) } for pane in closedPanes { workspace.splitFocusedPane(axis: .horizontal, with: pane) window.closePane(pane.id) } var reopenedIDs: [Pane.ID] = [] while !app.closedWorkspaceRecords.isEmpty { app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) if let pane = window.selectedWorkspace?.focusedPane { reopenedIDs.append(pane.id) } } #expect(reopenedIDs == closedPanes.suffix(15).reversed().map(\.id)) #expect(workspace.panes.map(\.id) == [survivor.id]) window.closePane(reopenedIDs[0]) #expect(!app.closedWorkspaceRecords.isEmpty) window.closeForWindow() #expect(app.closedWorkspaceRecords.first?.isWindow == true) } @Test func terminalPathOpensSFTPInANewWorkspaceOrTheCurrentWorkspace() { var host = Host(name: "Files", host: "files.example") host.sftpChannelInitialPath = "/configured" let sshWorkspace = Workspace.sshSession(host: host) let window = WindowModel(workspaces: [sshWorkspace]) let newTab = window.openSFTPChannel( host: host, initialPath: "/var/log/", placement: .newWorkspace ) #expect(window.workspaces.count == 2) #expect(window.selectedWorkspace === newTab) #expect(newTab.focusedPane.sftpChannel?.path == "/var/log") #expect(newTab.focusedPane.sftpChannel?.initialPath == "/var/log") #expect(host.sftpChannelInitialPath == "/configured") window.select(sshWorkspace.id) let result = window.openSFTPChannel( host: host, initialPath: "/srv/app", placement: .currentWorkspace ) #expect(result === sshWorkspace) #expect(window.workspaces.count == 2) #expect(sshWorkspace.panes.map(\.kind) == [.sshSession, .sftpChannel]) #expect(sshWorkspace.focusedPane.sftpChannel?.path == "/srv/app") } @Test func sftpPathOpensSSHInANewWorkspaceOrTheCurrentWorkspace() throws { let host = Host(name: "Shell", host: "shell.example") let sftpWorkspace = Workspace.sftpChannel(host: host, initialPath: "/configured") let window = WindowModel(workspaces: [sftpWorkspace]) let newTab = window.openSSHSession( host: host, initialPath: "/var/log", placement: .newWorkspace ) #expect(window.workspaces.count == 2) #expect(window.selectedWorkspace === newTab) #expect(newTab.focusedPane.sshSession?.hostID == host.id) window.select(sftpWorkspace.id) let result = window.openSSHSession( host: host, initialPath: "/srv/app", placement: .currentWorkspace ) #expect(result === sftpWorkspace) #expect(window.workspaces.count == 2) #expect(sftpWorkspace.panes.map(\.kind) == [.sftpChannel, .sshSession]) #expect(sftpWorkspace.focusedPane.sshSession?.hostID == host.id) } @Test func sftpCompanionUsesTheHostDefaultWithoutChangingExplicitSSH() throws { for transport in [HostTerminalProtocol.ssh, .mosh, .eternalTerminal] { var host = Host(name: "Shell", host: "shell.example") host.terminalTransports.moshEnabled = true host.terminalTransports.eternalTerminalEnabled = true host.terminalTransports.defaultTransport = transport let channel = SFTPChannelModel.demo(hostID: host.id, path: "/srv/app") let workspace = Workspace(pane: Pane(.sftpChannel(channel))) let window = WindowModel(workspaces: [workspace]) let library = HostLibraryModel(hosts: [host]) window.hostStore = library #expect(window.canOpenCompanionSession(from: channel.id)) let companion = try #require( window.openCompanionSession(from: channel.id, placement: .newWorkspace)) #expect(companion.focusedPane.sshSession?.transport == transport) #expect(window.openSSHSession(host: host).focusedPane.sshSession?.transport == .ssh) window.select(workspace.id) let split = try #require( window.openCompanionSession(from: channel.id, placement: .currentWorkspace)) #expect(split === workspace) #expect(split.focusedPane.sshSession?.transport == transport) host.sshEnabled = false host.telnetEnabled = true #expect(library.saveHost(host)) #expect(!window.canOpenCompanionSession(from: channel.id)) #expect(window.openCompanionSession(from: channel.id, placement: .newWorkspace) == nil) } } @Test func sftpWithoutAnOverrideKeepsTheHostsConfiguredInitialPath() { var host = Host(name: "Files", host: "files.example") host.sftpChannelInitialPath = "/configured" let window = WindowModel() let workspace = window.openSFTPChannel(host: host) #expect(workspace.focusedPane.sftpChannel?.path == "/configured") #expect(workspace.focusedPane.sftpChannel?.initialPath == "/configured") } @Test func duplicateDestinationsPreservePaneAndWorkspaceScope() { let host = Host(name: "Host", host: "host.example") let original = Workspace.sshSession(host: host) let window = WindowModel(workspaces: [original]) window.duplicateSelected(axis: .horizontal) #expect(window.workspaces.count == 1) #expect(original.panes.count == 2) let focusedPane = original.focusedPane window.duplicatePaneInNewWorkspace() #expect(window.workspaces.count == 2) #expect(window.selectedWorkspace?.panes.count == 1) #expect(window.selectedWorkspace?.focusedPane.kind == focusedPane.kind) window.select(original.id) window.duplicateSelectedInNewWorkspace() #expect(window.workspaces.count == 3) #expect(window.selectedWorkspace?.panes.map(\.kind) == original.panes.map(\.kind)) } @Test func normalRemoteExitClosesOnlyItsSplitPane() throws { let firstHost = Host(name: "First", host: "first.example") let secondHost = Host(name: "Second", host: "second.example") let workspace = Workspace.sshSession(host: firstHost) let firstPane = workspace.focusedPane let firstSSHSession = firstPane.sshSession! workspace.splitFocusedPane(axis: .horizontal, with: .sshSession(host: secondHost)) let secondPane = workspace.focusedPane workspace.focus(firstPane.id) workspace.broadcastsInput = true let window = WindowModel(workspaces: [workspace]) let app = AppModel(hosts: HostLibraryModel(hosts: [firstHost, secondHost])) app.registerWindow(window) firstSSHSession.handleRemoteExit(.success(0)) #expect(window.workspaces.count == 1) #expect(workspace.panes.map(\.id) == [secondPane.id]) #expect(workspace.layout.pane(firstPane.id) == nil) #expect(workspace.focusedPaneID == secondPane.id) #expect(!workspace.broadcastsInput) #expect(firstSSHSession.inputRouter == nil) #expect(!app.closedWorkspaceRecords.isEmpty) app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) #expect(window.selectedWorkspace?.focusedPane.id == firstPane.id) #expect(window.selectedWorkspace?.focusedPane.sshSession !== firstSSHSession) #expect(app.closedWorkspaceRecords.isEmpty) } @Test func resumableRemoteExitClosesOnlyItsSplitAndDoesNotTreatRecoveryAsExit() throws { for transport in [HostTerminalProtocol.mosh, .eternalTerminal] { let host = Host(name: "Server", host: "server.example") let pane = Pane.sshSession(host: host, transport: transport) let session = try #require(pane.sshSession) let workspace = Workspace(pane: pane) workspace.splitFocusedPane(axis: .horizontal, with: .sshSession(host: host)) let neighbor = workspace.focusedPane let window = WindowModel(workspaces: [workspace]) session.receiveTransportState(.recovering) session.receiveTransportState(.failed) #expect(workspace.panes.count == 2) session.receiveTransportState(.ended) #expect(window.workspaces.count == 1) #expect(workspace.panes.map(\.id) == [neighbor.id]) session.receiveTransportState(.connected) session.receiveTransportState(.ended) #expect(session.status == .disconnected) #expect(workspace.panes.map(\.id) == [neighbor.id]) } } @Test func normalRemoteExitClosesLastPaneAndSelectsNeighboringWorkspace() { let host = Host(name: "Host", host: "host.example") let sshSessionTab = Workspace.sshSession(host: host) let sshSession = sshSessionTab.focusedPane.sshSession! let neighbor = Workspace.sftpChannel(host: host) let window = WindowModel(workspaces: [sshSessionTab, neighbor]) window.select(sshSessionTab.id) sshSession.handleRemoteExit(.success(0)) #expect(window.workspaces.map(\.id) == [neighbor.id]) #expect(window.selectedWorkspaceID == neighbor.id) } @Test func transportFailureKeepsPaneOpenForReconnect() { let host = Host(name: "Host", host: "host.example") let workspace = Workspace.sshSession(host: host) let sshSession = workspace.focusedPane.sshSession! let window = WindowModel(workspaces: [workspace]) sshSession.handleRemoteExit(.failure(.disconnected)) #expect(window.workspaces.map(\.id) == [workspace.id]) #expect(workspace.focusedPane.sshSession === sshSession) guard case .failed = sshSession.status else { Issue.record("Expected transport failure to remain visible") return } } @Test func workspaceNamesUseTopLevelTitlesAcrossProtocolsAndHostIDs() { let first = Host(name: "Server", host: "first.example") let second = Host(name: "Server", host: "second.example", telnetEnabled: true) let store = HostLibraryModel(hosts: [first, second]) let window = WindowModel() window.hostStore = store let ssh = window.openSSHSession(host: first) let sftp = window.openSFTPChannel(host: second) let telnet = window.openTelnetSession(host: second) #expect([ssh, sftp, telnet].map { $0.title(in: store) } == ["Server", "Server (1)", "Server (2)"]) window.rename(sftp.id, to: "Logs") let next = window.openSSHSession(host: first) #expect(next.title(in: store) == "Server (3)") window.close(ssh.id) let reusedBase = window.openSFTPChannel(host: first) #expect(reusedBase.title(in: store) == "Server") } @Test func combinedPaneNamesAndOtherWindowsDoNotOccupyWorkspaceNames() { let host = Host(name: "Server 2", host: "server.example") let store = HostLibraryModel(hosts: [host]) let combined = Workspace.sshSession(host: host) combined.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: host)) let window = WindowModel(workspaces: [combined]) window.hostStore = store let first = window.openSSHSession(host: host) let second = window.openSFTPChannel(host: host) #expect(first.title(in: store) == "Server 2") #expect(second.title(in: store) == "Server 2 (1)") let otherWindow = WindowModel() otherWindow.hostStore = store #expect(otherWindow.openSSHSession(host: host).title(in: store) == "Server 2") } @Test func hostNamesThatEndInNumbersDoNotConflictWithParenthesizedSuffixes() { let server = Host(name: "Server", host: "server.example") let serverOne = Host(name: "Server 1", host: "server-one.example") let store = HostLibraryModel(hosts: [server, serverOne]) let window = WindowModel() window.hostStore = store #expect(window.openSSHSession(host: server).title(in: store) == "Server") #expect(window.openSSHSession(host: serverOne).title(in: store) == "Server 1") #expect(window.openSSHSession(host: server).title(in: store) == "Server (1)") #expect(window.openSSHSession(host: serverOne).title(in: store) == "Server 1 (1)") } @Test func disablingAutomaticWorkspaceNamesLeavesExistingNamesAndStopsAddingNumbers() { let host = Host(name: "Server", host: "server.example") let store = HostLibraryModel(hosts: [host]) let window = WindowModel() window.hostStore = store let first = window.openSSHSession(host: host) let second = window.openSSHSession(host: host) window.workspacePreferences = { WorkspacePreferences(automaticallyRenamesWorkspaces: false) } let third = window.openSFTPChannel(host: host) #expect([first, second, third].map { $0.title(in: store) } == ["Server", "Server (1)", "Server"]) } } private actor FailingConversationOperationRepository: AgentRunPersisting { private struct StorageFailure: LocalizedError { var errorDescription: String? { "Storage unavailable" } } func save(_ snapshot: AgentRunSnapshot) {} func record(_ event: AgentEvent, runID: UUID) {} func persistCompaction( _ record: AgentCompactionRecord, conversationID: UUID ) throws { throw StorageFailure() } func loadConversations() -> [StoredAgentConversation] { [] } func deleteConversation(_ id: UUID) {} func setTitle(_ title: String, conversationID: UUID) {} func setConversationMetadata( _ metadata: StoredAgentConversationMetadata, conversationID: UUID ) throws { throw StorageFailure() } func reorderConversations(_ updates: [StoredAgentConversationOrder]) throws { throw StorageFailure() } func truncateConversation( _ id: UUID, fromUserMessageID messageID: UUID, replacingWith message: AgentTranscriptMessage ) throws { throw StorageFailure() } func createConversation(_ conversation: StoredAgentConversation) throws { throw StorageFailure() } }