Show task status in the composer bar instead of floating pills
This commit is contained in:
+90
-68
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")])
|
||||
|
||||
Reference in New Issue
Block a user