diff --git a/AGENTS.md b/AGENTS.md index 884eaaa..c8a659f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,3 +199,6 @@ fix the ui theme ## Activity — 2026-05-24 - (no commits) + +## Activity — 2026-05-25 +- Fixed open ticket batch #130-#133: Work board repository selection now persists per scene and seeds create-issue sheets; create issue dismisses based on a successful created item; launched sessions auto-open the terminal; tmux `EXITED:` output now drives completed/failed state; companion-backed session details expose an interactive terminal tab when a tmux session is available. diff --git a/AgentBoard/DesktopRootView.swift b/AgentBoard/DesktopRootView.swift index 9c0ad1b..8438e57 100644 --- a/AgentBoard/DesktopRootView.swift +++ b/AgentBoard/DesktopRootView.swift @@ -8,6 +8,7 @@ struct DesktopRootView: View { @State private var isTerminalExpanded = false @State private var isChatInspectorPresented = true @State private var isPresentingQuickLaunch = false + @State private var observedSessionIDs: Set = [] var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { @@ -56,6 +57,12 @@ struct DesktopRootView: View { QuickLaunchSheet() .environment(appModel) } + .onAppear { + observedSessionIDs = Set(appModel.sessionLauncher.activeSessions.map(\.id)) + } + .onChange(of: appModel.sessionLauncher.activeSessions.map(\.id)) { _, sessionIDs in + presentNewestSessionTerminal(from: sessionIDs) + } } private var tabSelection: Binding { @@ -115,4 +122,17 @@ struct DesktopRootView: View { } return destination } + + private func presentNewestSessionTerminal(from sessionIDs: [String]) { + let latestIDs = Set(sessionIDs) + defer { observedSessionIDs = latestIDs } + + guard let newID = sessionIDs.last(where: { !observedSessionIDs.contains($0) }), + let session = appModel.sessionLauncher.activeSessions.first(where: { $0.id == newID }) else { + return + } + + activeSessionTerminal = session + isTerminalExpanded = false + } } diff --git a/AgentBoardCore/Services/SessionLauncher.swift b/AgentBoardCore/Services/SessionLauncher.swift index db704e1..e2fb369 100644 --- a/AgentBoardCore/Services/SessionLauncher.swift +++ b/AgentBoardCore/Services/SessionLauncher.swift @@ -256,6 +256,11 @@ public final class SessionLauncher { // MARK: - Tmux pass-throughs (used by SessionTerminalView) public func checkSession(_ session: ActiveSession) async -> ActiveSession.SessionStatus { + if let output = await tmux.capturePane(name: session.sessionName), + let exitCode = Self.launchExitCode(in: output) { + return exitCode == 0 ? .completed : .failed + } + do { let alive = try await tmux.hasSession(name: session.sessionName) return alive ? .running : .completed @@ -265,6 +270,20 @@ public final class SessionLauncher { } } + private static func launchExitCode(in output: String) -> Int? { + for line in output.split(whereSeparator: \.isNewline).reversed() { + let trimmed = String(line).trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("EXITED:") else { continue } + + let codeText = trimmed + .dropFirst("EXITED:".count) + .trimmingCharacters(in: .whitespacesAndNewlines) + return Int(codeText) + } + + return nil + } + public func capturePane(sessionName: String) async -> String? { await tmux.capturePane(name: sessionName) } diff --git a/AgentBoardCore/Stores/WorkStore.swift b/AgentBoardCore/Stores/WorkStore.swift index 28c384a..41b0e21 100644 --- a/AgentBoardCore/Stores/WorkStore.swift +++ b/AgentBoardCore/Stores/WorkStore.swift @@ -135,6 +135,7 @@ public final class WorkStore { } } + @discardableResult public func createIssue( repository: ConfiguredRepository, title: String, @@ -142,12 +143,12 @@ public final class WorkStore { labels: [String] = [], assignees: [String] = [], milestone: Int? = nil - ) async { + ) async -> WorkItem? { errorMessage = nil statusMessage = nil guard settingsStore.isGitHubConfigured else { errorMessage = "Connect GitHub before creating issues." - return + return nil } await configureServiceIfNeeded() do { @@ -166,9 +167,11 @@ public final class WorkStore { try cache.replaceWorkItems(items) errorMessage = nil statusMessage = "Created \(item.issueReference)." + return item } catch { logger.error("Failed to create issue: \(error.localizedDescription, privacy: .public)") errorMessage = error.localizedDescription + return nil } } diff --git a/AgentBoardUI/Screens/CreateIssueSheet.swift b/AgentBoardUI/Screens/CreateIssueSheet.swift index 909ab77..cc934f7 100644 --- a/AgentBoardUI/Screens/CreateIssueSheet.swift +++ b/AgentBoardUI/Screens/CreateIssueSheet.swift @@ -5,6 +5,8 @@ struct CreateIssueSheet: View { @Environment(AgentBoardAppModel.self) private var appModel @Environment(\.dismiss) private var dismiss + private let initialRepository: ConfiguredRepository? + @State private var selectedRepository: ConfiguredRepository? @State private var title = "" @State private var issueBody = "" @@ -17,6 +19,11 @@ struct CreateIssueSheet: View { @State private var showAttachmentPicker = false @State private var pendingAttachments: [ChatAttachment] = [] + init(initialRepository: ConfiguredRepository? = nil) { + self.initialRepository = initialRepository + _selectedRepository = State(initialValue: initialRepository) + } + var body: some View { NavigationStack { ZStack { @@ -193,6 +200,12 @@ struct CreateIssueSheet: View { pendingAttachments.append(attachment) } } + .onAppear { + normalizeSelectedRepository() + } + .onChange(of: appModel.settingsStore.repositories) { + normalizeSelectedRepository() + } .accessibilityIdentifier("screen_create_issue") } @@ -224,7 +237,7 @@ struct CreateIssueSheet: View { } Task { - await appModel.workStore.createIssue( + let created = await appModel.workStore.createIssue( repository: repo, title: title.trimmingCharacters(in: .whitespacesAndNewlines), body: bodyText, @@ -233,7 +246,7 @@ struct CreateIssueSheet: View { milestone: Int(milestoneText.trimmingCharacters(in: .whitespacesAndNewlines)) ) isCreating = false - if appModel.workStore.errorMessage == nil { + if created != nil { dismiss() } } @@ -241,6 +254,23 @@ struct CreateIssueSheet: View { // MARK: - Helpers + private func normalizeSelectedRepository() { + let repositories = appModel.settingsStore.repositories + if let selectedRepository, + repositories.contains(selectedRepository) { + return + } + + if let initialRepository, + repositories.contains(initialRepository) { + selectedRepository = initialRepository + } else if repositories.count == 1 { + selectedRepository = repositories.first + } else { + selectedRepository = nil + } + } + /// Inline row: label on the left, picker rendered as a tightly-hugged /// capsule chip on the right. No full-width recessed background, so the /// row no longer reads as an empty text field. diff --git a/AgentBoardUI/Screens/SessionDetailSheet.swift b/AgentBoardUI/Screens/SessionDetailSheet.swift index 57c3727..c05b90b 100644 --- a/AgentBoardUI/Screens/SessionDetailSheet.swift +++ b/AgentBoardUI/Screens/SessionDetailSheet.swift @@ -23,6 +23,9 @@ struct SessionDetailSheet: View { Picker("Mode", selection: $selectedTab) { Text("Overview").tag(0) Text("Output Logs").tag(1) + if session.tmuxSession != nil { + Text("Terminal").tag(2) + } } .pickerStyle(.segmented) .padding(.horizontal, 24) @@ -31,6 +34,8 @@ struct SessionDetailSheet: View { if selectedTab == 0 { overviewTab + } else if selectedTab == 2 { + terminalTab } else { logsTab } @@ -204,4 +209,36 @@ struct SessionDetailSheet: View { } isRefreshing = false } + + @ViewBuilder + private var terminalTab: some View { + if let tmuxSession = session.tmuxSession { + #if os(macOS) && canImport(SwiftTerm) + let attach = SessionLauncher.attachCommand(for: tmuxSession) + EmbeddedTerminalView( + executable: attach.executable, + arguments: attach.arguments, + environment: nil, + onProcessExit: { _ in } + ) + .id(tmuxSession) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(red: 0.06, green: 0.06, blue: 0.08)) + .padding(.horizontal, 24) + .padding(.bottom, 24) + .accessibilityIdentifier("session_detail_terminal_embedded") + #else + terminalUnavailableView + #endif + } else { + terminalUnavailableView + } + } + + private var terminalUnavailableView: some View { + Text("No terminal available") + .foregroundStyle(NeuPalette.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } } diff --git a/AgentBoardUI/Screens/WorkScreen.swift b/AgentBoardUI/Screens/WorkScreen.swift index c6d06db..bd3772c 100644 --- a/AgentBoardUI/Screens/WorkScreen.swift +++ b/AgentBoardUI/Screens/WorkScreen.swift @@ -18,7 +18,7 @@ struct WorkScreen: View { @State private var layoutMode: WorkLayoutMode = .board @State private var selectedItem: WorkItem? @State private var isPresentingCreate = false - @State private var selectedRepo: String = "all" + @SceneStorage("work.selectedRepository") private var selectedRepo: String = "all" private var isCompact: Bool { #if os(macOS) @@ -79,9 +79,16 @@ struct WorkScreen: View { .environment(appModel) } .sheet(isPresented: $isPresentingCreate) { - CreateIssueSheet() + CreateIssueSheet(initialRepository: selectedCreateRepository) .environment(appModel) } + .onChange(of: appModel.settingsStore.repositories) { _, repositories in + guard selectedRepo != "all", + !repositories.contains(where: { $0.fullName == selectedRepo }) else { + return + } + selectedRepo = "all" + } .accessibilityIdentifier("screen_work") } @@ -91,6 +98,16 @@ struct WorkScreen: View { return base.filter { $0.repository.fullName == selectedRepo } } + private var selectedCreateRepository: ConfiguredRepository? { + if selectedRepo == "all" { + return appModel.settingsStore.repositories.count == 1 + ? appModel.settingsStore.repositories.first + : nil + } + + return appModel.settingsStore.repositories.first { $0.fullName == selectedRepo } + } + private var groupedFilteredItems: [(state: WorkState, items: [WorkItem])] { // Show Ready, In Progress, Review, Done columns (skip Blocked) [.ready, .inProgress, .review, .done].map { state in