Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 20 additions & 0 deletions AgentBoard/DesktopRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []

var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
Expand Down Expand Up @@ -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<AppDestination?> {
Expand Down Expand Up @@ -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
}
}
19 changes: 19 additions & 0 deletions AgentBoardCore/Services/SessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
7 changes: 5 additions & 2 deletions AgentBoardCore/Stores/WorkStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,19 +135,20 @@ public final class WorkStore {
}
}

@discardableResult
public func createIssue(
repository: ConfiguredRepository,
title: String,
body: String,
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 {
Expand All @@ -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
}
}

Expand Down
34 changes: 32 additions & 2 deletions AgentBoardUI/Screens/CreateIssueSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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 {
Expand Down Expand Up @@ -193,6 +200,12 @@ struct CreateIssueSheet: View {
pendingAttachments.append(attachment)
}
}
.onAppear {
normalizeSelectedRepository()
}
.onChange(of: appModel.settingsStore.repositories) {
normalizeSelectedRepository()
}
.accessibilityIdentifier("screen_create_issue")
}

Expand Down Expand Up @@ -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,
Expand All @@ -233,14 +246,31 @@ struct CreateIssueSheet: View {
milestone: Int(milestoneText.trimmingCharacters(in: .whitespacesAndNewlines))
)
isCreating = false
if appModel.workStore.errorMessage == nil {
if created != nil {
dismiss()
}
}
}

// 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.
Expand Down
37 changes: 37 additions & 0 deletions AgentBoardUI/Screens/SessionDetailSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -31,6 +34,8 @@ struct SessionDetailSheet: View {

if selectedTab == 0 {
overviewTab
} else if selectedTab == 2 {
terminalTab
} else {
logsTab
}
Expand Down Expand Up @@ -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)
}
}
21 changes: 19 additions & 2 deletions AgentBoardUI/Screens/WorkScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
}
Comment on lines +85 to +91
.accessibilityIdentifier("screen_work")
}

Expand All @@ -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
Expand Down
Loading