Add team tasks: several agents in one worktree sharing a board
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
Reference in New Issue
Block a user