Show task status in the composer bar instead of floating pills

This commit is contained in:
Ian Gulliver
2026-10-20 08:38:01 -07:00
parent baa048a89e
commit 289c58d778
6 changed files with 146 additions and 103 deletions
+90 -68
View File
@@ -14,6 +14,17 @@ struct AttentionGroup: Identifiable {
let rows: [Int]
}
struct ChatStatus {
enum Tone {
case attention, working, merged, discarded
}
let label: String
let help: String
let tone: Tone
let group: AttentionGroup?
}
@MainActor
@Observable
final class ChatModel {
@@ -22,7 +33,7 @@ final class ChatModel {
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 scrollRequest: (seq: Int, id: Int?, anchor: UnitPoint)?
private(set) var fetching = false
private(set) var older: [ByreEvent]?
private(set) var hasUnmerged = false
@@ -328,12 +339,49 @@ final class ChatModel {
}
if !ready.isEmpty {
groups.append(discard
? AttentionGroup(id: "ready", label: "suggests discard", help: "Scroll to the agent's reasons to discard the task", rows: ready)
: AttentionGroup(id: "ready", label: "ready to merge", help: "Scroll to the agent's ready-for-merge overview", rows: ready))
? AttentionGroup(id: "ready", label: "Suggests discard", help: "Scroll to the agent's reasons to discard the task", rows: ready)
: AttentionGroup(id: "ready", label: "Ready to merge", help: "Scroll to the agent's ready-for-merge overview", rows: ready))
}
return groups
}
func status() -> ChatStatus? {
guard let taskId, let task = store.findTask(taskId) else {
return nil
}
let end = "; click to scroll to the end"
switch task.status {
case .merged:
let commit = task.mergeCommit ?? ""
return ChatStatus(label: "Merged\(commit.isEmpty ? "" : " as \(commit.prefix(7))")", help: "The task was merged\(end)", tone: .merged, group: nil)
case .discarded:
return ChatStatus(label: "Discarded", help: "The task was discarded\(end)", tone: .discarded, group: nil)
case .active:
break
}
let groups = attention()
let flagged = { (g: AttentionGroup) in
ChatStatus(label: g.label, help: "\(g.help); click again for the next", tone: .attention, group: g)
}
if let q = groups.first(where: { $0.id == "questions" }) {
return flagged(q)
}
if let busy = store.progress(task) {
return ChatStatus(label: busy.label, help: "\(busy.title)\(end)", tone: .working, group: nil)
}
return groups.first.map(flagged)
}
func showStatus(_ s: ChatStatus) {
if let g = s.group {
cycle(g)
return
}
release()
stuck = true
requestScroll(nil, .bottom)
}
var attentionRows: [Int] {
let ids = Set(attention().flatMap(\.rows))
return builder.root.rows.filter { ids.contains($0.id) }.map(\.id)
@@ -371,7 +419,7 @@ final class ChatModel {
requestScroll(id, .top)
}
private func requestScroll(_ id: Int, _ anchor: UnitPoint) {
private func requestScroll(_ id: Int?, _ anchor: UnitPoint) {
scrollRequest = ((scrollRequest?.seq ?? 0) + 1, id, anchor)
}
@@ -501,44 +549,30 @@ private struct ChatHead: View {
CompactButton(sessionId: live?.id, state: live?.state, usage: live.flatMap { model.builder.context[$0.id] })
}
case .task(let id):
if let id, let task = store.findTask(id) {
if task.status == .merged {
let commit = task.mergeCommit ?? ""
header(background: Palette.okTint) {
Text("✓").foregroundStyle(Palette.ok)
Text("Merged\(commit.isEmpty ? "" : " as \(commit.prefix(7))")")
.foregroundStyle(Palette.text)
}
} else if task.status != .active {
header {
Text(task.status.rawValue)
}
} else {
let sess = store.currentSession(task.id)
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)
MergeButton(task: task, busy: busy, unmerged: model.hasUnmerged)
DiscardButton(task: task, busy: busy)
if let id, let task = store.findTask(id), task.status == .active {
let sess = store.currentSession(task.id)
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)
MergeButton(task: task, busy: busy, unmerged: model.hasUnmerged)
DiscardButton(task: task, busy: busy)
}
}
}
}
private func header<Content: View>(background: Color = .clear, @ViewBuilder _ content: () -> Content) -> some View {
private func header<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
HStack(spacing: 8) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(background)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
@@ -846,64 +880,48 @@ private struct TranscriptScroll: View {
position.scrollTo(edge: .bottom)
}
.onChange(of: model.scrollRequest?.seq, initial: true) {
if let r = model.scrollRequest {
position.scrollTo(id: r.id, anchor: r.anchor)
guard let r = model.scrollRequest else {
return
}
if let id = r.id {
position.scrollTo(id: id, anchor: r.anchor)
} else {
position.scrollTo(edge: .bottom)
}
}
.overlay {
Rectangle().stroke(active ? accent : .clear, lineWidth: 1)
.animation(.easeInOut(duration: 0.3), value: active)
}
.overlay(alignment: .topTrailing) {
AttentionBar(model: model)
}
}
}
private struct AttentionBar: View {
let model: ChatModel
var body: some View {
let groups = model.attention()
if !groups.isEmpty {
VStack(alignment: .trailing, spacing: 6) {
ForEach(groups) { g in
AttentionButton(group: g) {
model.cycle(g)
}
}
}
.padding(.top, 8)
.padding(.trailing, 14)
}
}
}
private struct AttentionButton: View {
let group: AttentionGroup
struct StatusButton: View {
let status: ChatStatus
let action: () -> Void
@ViewState private var hover = false
var body: some View {
let (color, tint): (Color, Color) = switch status.tone {
case .attention: (Palette.warn, Palette.warnTint)
case .working: (Palette.accent, Palette.accentTint)
case .merged: (Palette.ok, Palette.okTint)
case .discarded: (Palette.muted, Palette.raised)
}
Button(action: action) {
Text(group.label)
Text(status.label)
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.warn)
.foregroundStyle(color)
.lineLimit(1)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background {
ZStack {
Palette.bg
Palette.warnTint
}
.clipShape(Capsule())
}
.overlay(Capsule().stroke(hover ? Palette.accent : Palette.warn.opacity(0.55)))
.background(tint, in: Capsule())
.overlay(Capsule().stroke(hover ? Palette.accent : color.opacity(0.55)))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.fixedSize()
.help("\(group.help); click again for the next")
.help(status.help)
.onHover { hover = $0 }
}
}
@@ -1510,7 +1528,11 @@ struct NoteView: View {
@ViewState private var cancelling = false
var body: some View {
let color = note.style == .error ? Palette.danger : Palette.muted
let color = switch note.style {
case .error: Palette.danger
case .ok: Palette.ok
case .plain: Palette.muted
}
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(text())
.font(Fonts.font(11))
+32 -22
View File
@@ -336,32 +336,42 @@ struct ComposerView: View {
Thumbnails(key: draftKey)
editor
HStack(spacing: 8) {
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)
HStack(spacing: 8) {
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")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if let status = chat.status() {
StatusButton(status: status) {
chat.showStatus(status)
}
}
HStack(spacing: 8) {
Button("Interrupt") {
if let id = live?.id {
chat.interrupt(id, overlays)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Model for the next turn")
}
Spacer()
Button("Interrupt") {
if let id = live?.id {
chat.interrupt(id, overlays)
.buttonStyle(WebButtonStyle(danger: true))
.disabled(sessionState != .running)
Button("Send") {
submit(modelState())
}
.buttonStyle(WebButtonStyle())
}
.buttonStyle(WebButtonStyle(danger: true))
.disabled(sessionState != .running)
Button("Send") {
submit(modelState())
}
.buttonStyle(WebButtonStyle())
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding(.horizontal, 16)
+18 -7
View File
@@ -210,11 +210,9 @@ final class Store {
if inbox.contains(where: { $0.taskId == task.id }) {
return TaskState(kind: .answer, label: "Needs your answer", title: "The agent asked a question")
}
if task.merging {
return TaskState(kind: .merging, label: "Merging", title: "The merge turn is running")
}
if task.mergingIntoParent {
return TaskState(kind: .merging, label: "Merging", title: "Waiting for the parent task's agent to merge it")
let busy = progress(task)
if let busy, busy.kind == .merging {
return busy
}
if task.mergeQueuedAt != nil {
return TaskState(kind: .held, label: "Merge held", title: "Waiting for the merge in progress in this project to finish")
@@ -222,8 +220,8 @@ final class Store {
if task.mergeAfterReview {
return TaskState(kind: .held, label: "Merge after review", title: "Merges when the running review finishes with no findings")
}
if let working = workingTitles[task.state] {
return TaskState(kind: .working, label: working.label, title: working.title)
if let busy {
return busy
}
switch task.ready {
case .merge:
@@ -236,6 +234,19 @@ final class Store {
return TaskState(kind: .idle, label: "Idle", title: "Waiting for a message")
}
func progress(_ task: ByreTask) -> TaskState? {
if task.merging {
return TaskState(kind: .merging, label: "Merging", title: "The merge turn is running")
}
if task.mergingIntoParent {
return TaskState(kind: .merging, label: "Merging", title: "Waiting for the parent task's agent to merge it")
}
if let working = workingTitles[task.state] {
return TaskState(kind: .working, label: working.label, title: working.title)
}
return nil
}
func taskRank(_ task: ByreTask) -> Int {
if task.discarding {
return 4
+3 -3
View File
@@ -23,7 +23,7 @@ enum NotePart {
}
enum NoteStyle {
case plain, error
case plain, error, ok
}
struct Note {
@@ -784,10 +784,10 @@ final class TranscriptBuilder {
case "task.merged":
let commit = str(p, "commit") ?? ""
if let parentTask = str(p, "parent_task_id") {
return note([.text("\(ev.actor) merged the task into "), .task(parentTask), .text(", now at \(commit.prefix(7))")])
return note([.text("✓ \(ev.actor) merged the task into "), .task(parentTask), .text(", now at \(commit.prefix(7))")], .ok)
}
let pushed = str(p, "pushed") ?? ""
return note([.text("\(ev.actor) merged the task\(commit.isEmpty ? "" : " as \(commit.prefix(7))")\(pushed.isEmpty ? "" : " and pushed it to \(pushed)")")])
return note([.text("✓ \(ev.actor) merged the task\(commit.isEmpty ? "" : " as \(commit.prefix(7))")\(pushed.isEmpty ? "" : " and pushed it to \(pushed)")")], .ok)
case "task.merged_to_main":
return note([.text("\(ev.actor) merged the work so far as \(jsString(p["commit"]).prefix(7)), \"\(jsString(p["merge_title"]))\", pushed it to \(jsString(p["pushed"])) and kept the task open")])