Add team tasks: several agents in one worktree sharing a board

This commit is contained in:
Ian Gulliver
2026-10-18 08:48:57 -07:00
parent 7f62df19d2
commit 6bcde13b2f
31 changed files with 1392 additions and 439 deletions
+70
View File
@@ -0,0 +1,70 @@
import ByreAPI
import SwiftUI
struct BoardView: View {
@Environment(Store.self) private var store
var body: some View {
let taskId = store.selectedTaskId
let posts = store.transcript.filter { $0.kind == "board.posted" }
VStack(spacing: 0) {
HStack(spacing: 10) {
Text("Board").font(Fonts.font(13, weight: 700))
Text(taskId.map { store.team($0).map(\.member).joined(separator: ", ") } ?? "")
.foregroundStyle(Palette.muted)
.lineLimit(1)
.truncationMode(.tail)
}
.modifier(HeaderBar())
if posts.isEmpty {
EmptyText(text: taskId == nil ? "No task selected." : "No posts yet. Post from the Transcript tab's composer.")
} else {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(posts, id: \.seq) { ev in
PostView(ev: ev, taskId: taskId)
.id(ev.seq)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.onAppear {
proxy.scrollTo(posts.last?.seq, anchor: .bottom)
}
.onChange(of: posts.count) {
proxy.scrollTo(posts.last?.seq, anchor: .bottom)
}
}
}
}
.frame(maxHeight: .infinity, alignment: .top)
}
}
private struct PostView: View {
let ev: ByreEvent
let taskId: String?
var body: some View {
let p = ev.payload.value
let member = str(p, "member") ?? ""
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(member.isEmpty ? ev.actor : member).font(Fonts.font(12, weight: 700))
if !member.isEmpty {
Text(str(p, "model") ?? "").foregroundStyle(Palette.muted)
}
WhenText(when: ev.createdAt).foregroundStyle(Palette.muted)
}
.font(Fonts.font(11))
MarkdownView(blocks: parseMarkdown(str(p, "text") ?? ""), ctx: MDContext(taskId: taskId))
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(member.isEmpty ? Palette.accentTint : Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
}
}
+56 -5
View File
@@ -13,6 +13,8 @@ final class ChatModel {
let kind: ChatKind
let store: Store
let builder: TranscriptBuilder
let member: String?
@ObservationIgnored private var members: [String: String] = [:]
private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)?
private(set) var fetching = false
private(set) var older: [ByreEvent]?
@@ -30,6 +32,7 @@ final class ChatModel {
self.kind = kind
self.store = store
let taskId: String? = if case .task(let id) = kind { id } else { nil }
member = taskId.flatMap { store.pickedMember($0) }
let session: () -> AgentSession? = { [weak store] in
switch kind {
case .lead: store?.currentLead()
@@ -82,8 +85,26 @@ final class ChatModel {
}
}
var team: Bool {
member != nil
}
var placeholder: String {
kind == .lead ? "Message your lead. Paste or drop images to attach them." : "Message the agent. Paste or drop images to attach them."
if team {
return "Post to the team's board; every agent of the team gets it."
}
return kind == .lead ? "Message your lead. Paste or drop images to attach them." : "Message the agent. Paste or drop images to attach them."
}
private func shows(_ ev: ByreEvent) -> Bool {
guard let member, let sid = ev.agentSessionId else {
return true
}
if ev.kind == "session.created" {
members[sid] = str(ev.payload.value, "member") ?? ""
}
let owner = members[sid] ?? store.sessions[taskId ?? ""]?.first { $0.id == sid }?.member ?? ""
return owner.isEmpty || owner == member
}
var empty: String {
@@ -122,7 +143,9 @@ final class ChatModel {
self?.appended(events)
})
offs.append(feed.streamed.add { [weak self] ev in
self?.builder.appendDelta(ev)
if self?.shows(ev) == true {
self?.builder.appendDelta(ev)
}
})
activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in
self?.markShown()
@@ -144,7 +167,8 @@ final class ChatModel {
store.drafts?.flush()
}
private func appended(_ events: [ByreEvent]) {
private func appended(_ all: [ByreEvent]) {
let events = all.filter(shows)
if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
unmergedTimer?.cancel()
unmergedTimer = Task { [weak self] in
@@ -254,6 +278,13 @@ final class ChatModel {
case .lead:
_ = try await api.messageLead(body: .json(.init(text: text, images: images, model: sessionId != nil ? model : firstModel))).accepted
case .task(let id):
if team {
if images != nil {
throw ApiError(status: 0, message: "The board takes text only; drop the images to post.")
}
_ = try await api.postToBoard(path: .init(taskId: id ?? ""), body: .json(.init(text: text))).noContent
return
}
if let sessionId {
_ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model))).accepted
return
@@ -346,8 +377,11 @@ private struct ChatHead: View {
}
} else {
let sess = store.currentSession(task.id)
let busy = sess?.state == .running
let busy = store.taskBusy(task.id)
header {
if model.team {
MemberPicker(taskId: task.id, picked: model.member)
}
CompactButton(sessionId: sess?.id, state: sess?.state, usage: sess.flatMap { model.builder.context[$0.id] })
SyncButton(task: task, busy: busy)
Spacer(minLength: 12)
@@ -372,6 +406,23 @@ private struct ChatHead: View {
}
}
private struct MemberPicker: View {
let taskId: String
let picked: String?
@Environment(Store.self) private var store
var body: some View {
HStack(spacing: 4) {
ForEach(store.team(taskId), id: \.id) { s in
PaneTabButton(label: s.state == .running ? "\(s.member) ●" : s.member, active: s.member == picked) {
store.pickedMembers[taskId] = s.member
}
.help("Show \(s.member)'s transcript (\(s.model.isEmpty ? s.vendor.rawValue : s.model), \(s.state.rawValue))")
}
}
}
}
private struct SyncButton: View {
let task: ByreTask
let busy: Bool
@@ -782,7 +833,7 @@ struct DetailsBox<Summary: View, Content: View>: View {
}
}
private struct WhenText: View {
struct WhenText: View {
let when: Date
var body: some View {
+13 -11
View File
@@ -324,18 +324,20 @@ struct ComposerView: View {
Thumbnails(key: draftKey)
editor
HStack(spacing: 8) {
Picker("", selection: Binding(get: { selected }, set: { v in
pick = v
modelChoices[modelKey] = v
})) {
ForEach(options, id: \.self) { m in
Text(m).tag(m)
if !chat.team {
Picker("", selection: Binding(get: { selected }, set: { v in
pick = v
modelChoices[modelKey] = v
})) {
ForEach(options, id: \.self) { m in
Text(m).tag(m)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Model for the next turn")
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Model for the next turn")
Spacer()
Button("Interrupt") {
if let id = live?.id {
@@ -458,7 +460,7 @@ struct ComposerView: View {
let imgs = images.take(draftKey)
text = ""
let sent = drafts.startSend(draftKey, value, imgs.count)
let cancel = chat.builder.showPending(value, imgs)
let cancel = chat.team ? {} : chat.builder.showPending(value, imgs)
Task {
do {
let ids = try await uploadImages(store.api, imgs)
+1 -1
View File
@@ -242,7 +242,7 @@ struct InlineRenderer {
s.color = taskPalette[task.color]
render(inner, s, &out)
append(run(" ", style), &out)
let state = store.currentSession(task.id)?.state
let state = store.taskBusy(task.id) ? .running : store.currentSession(task.id)?.state
var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true, tip: state?.rawValue ?? "no session")
append(run(state == .running ? "●" : "○", dot), &out)
dot.color = Palette.muted
+1 -1
View File
@@ -76,7 +76,7 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async
guard let task = store.findTask(taskId), task.status == .active else {
return
}
if store.currentSession(taskId)?.state == .running {
if store.taskBusy(taskId) {
overlays.showAlert("Agent is mid-turn; merge when it's idle")
return
}
+6 -1
View File
@@ -23,6 +23,7 @@ struct TabBadgeView: View {
let tabLabels: [TaskTab: String] = [
.transcript: "Transcript",
.board: "Board",
.changes: "Changes",
.files: "Files",
.terminal: "Terminal",
@@ -221,7 +222,9 @@ private struct TaskPane: View {
switch store.focus.tab {
case .transcript:
ChatView(kind: .task(store.selectedTaskId))
.id(store.selectedTaskId ?? "")
.id("\(store.selectedTaskId ?? "")/\(store.selectedTaskId.flatMap { store.pickedMember($0) } ?? "")")
case .board:
BoardView()
case .changes:
ChangesView()
case .files:
@@ -388,11 +391,13 @@ private struct TabStrip: View {
let findings = latest.map(findingCount) ?? 0
let hasReviews = transcript.contains(where: isReviewEvent)
let workflow = store.selectedTaskId.flatMap { store.findTask($0) }?.workflowRun != nil
let team = store.selectedTaskId.map { !store.team($0).isEmpty } ?? false
let order = (first + taskTabs.filter { !first.contains($0) }).filter { t in
if t == tab {
return true
}
switch t {
case .board: return team
case .inbox: return waiting > 0
case .reviews: return hasReviews
case .workflow: return workflow
+53 -4
View File
@@ -2,7 +2,7 @@ import Foundation
import Observation
import SwiftData
let taskTabs: [TaskTab] = [.transcript, .changes, .files, .terminal, .inbox, .reviews, .workflow, .project]
let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .terminal, .inbox, .reviews, .workflow, .project]
private let historyPage = 1000
private let leadPage = 50
@@ -46,6 +46,7 @@ final class Store {
var summaryReads: [String: Int64] = [:]
private(set) var leadMore = false
var todoBusy: [String: String] = [:]
var pickedMembers: [String: String] = [:]
private(set) var drafts: Drafts?
let images = ImageDrafts()
let events = Listeners<ByreEvent>()
@@ -158,7 +159,32 @@ final class Store {
}
func currentSession(_ taskId: String) -> AgentSession? {
sessions[taskId]?.last(where: { $0.state != .completed }) ?? sessions[taskId]?.last
let list = (sessions[taskId] ?? []).filter { $0.member == (pickedMember(taskId) ?? "") }
return list.last(where: { $0.state != .completed }) ?? list.last
}
func team(_ taskId: String) -> [AgentSession] {
let list = sessions[taskId] ?? []
var names: [String] = []
for s in list where !s.member.isEmpty && !names.contains(s.member) {
names.append(s.member)
}
return names.compactMap { name in
let mine = list.filter { $0.member == name }
return mine.last(where: { $0.state != .completed }) ?? mine.last
}
}
func pickedMember(_ taskId: String) -> String? {
let names = team(taskId).map(\.member)
if let picked = pickedMembers[taskId], names.contains(picked) {
return picked
}
return names.first
}
func taskBusy(_ taskId: String) -> Bool {
(sessions[taskId] ?? []).contains { $0.state == .running }
}
func taskState(_ task: ByreTask) -> TaskState {
@@ -457,8 +483,31 @@ final class Store {
}
func openSummary(_ task: ByreTask) {
jump = (task.id, task.summarySeq)
focusTask(task.id, .transcript)
let seq = task.summarySeq
if let ev = taskFeeds[task.id]?.events.first(where: { $0.seq == seq }) {
pickMember(task.id, ev)
jump = (task.id, seq)
focusTask(task.id, .transcript)
return
}
Task {
do {
let evs = try await api.client.listTaskEvents(path: .init(taskId: task.id), query: .init(after: seq - 1, limit: 1)).ok.body.json
if let ev = evs.first(where: { $0.seq == seq }) {
pickMember(task.id, ev)
}
} catch {
log.error("load summary event: \(errorText(error), privacy: .public)")
}
jump = (task.id, seq)
focusTask(task.id, .transcript)
}
}
private func pickMember(_ taskId: String, _ ev: ByreEvent) {
if let member = str(ev.payload.value, "member"), !member.isEmpty {
pickedMembers[taskId] = member
}
}
func selectTask(_ taskId: String?) {