Unify attention cards

This commit is contained in:
Ian Gulliver
2026-10-21 07:20:16 -07:00
parent 0d3ed1fdd3
commit e05165723d
5 changed files with 256 additions and 269 deletions
+38 -126
View File
@@ -630,9 +630,8 @@ private struct MergeButton: View {
let task: ByreTask
let busy: Bool
let unmerged: Bool
var small = false
var keepOpen: Bool?
var highlight = false
var primary = false
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@@ -646,7 +645,7 @@ private struct MergeButton: View {
await confirmMerge(task.id, store, overlays, keepOpen: true)
}
}
.buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border))
.buttonStyle(WebButtonStyle(primary: primary))
.disabled(busy || held || intoParent || !unmerged)
.help("The agent merges \(target) in, then squashes the work so far into one commit on \(target) and pushes it, keeping the task open; the task's other agents wait until it's done")
} else {
@@ -665,18 +664,12 @@ private struct MergeButton: View {
: task.parentTaskId != nil
? "Merge this subtask into its parent task's branch, or into \(target) (⌘S)"
: "The agent merges \(target) in, then byre squashes into one commit on \(target) and pushes it to origin; held while another task in the project is merging (⌘S)"
let button = Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : keepOpen == false ? "Merge & complete" : "Merge") {
Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : keepOpen == false ? "Merge & complete" : "Merge") {
Task {
await confirmMerge(task.id, store, overlays)
}
}
Group {
if small {
button.buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border))
} else {
button.buttonStyle(WebButtonStyle())
}
}
.buttonStyle(WebButtonStyle(primary: primary))
.disabled(busy || held || intoParent || !unmerged)
.help(help)
}
@@ -685,23 +678,16 @@ private struct MergeButton: View {
private struct DiscardButton: View {
let task: ByreTask
let busy: Bool
var small = false
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
let button = Button("Discard") {
Button("Discard") {
Task {
await confirmDiscard(task, store, overlays)
}
}
Group {
if small {
button.buttonStyle(SmallLinkButtonStyle(color: Palette.danger))
} else {
button.buttonStyle(WebButtonStyle(danger: true))
}
}
.buttonStyle(WebButtonStyle(danger: true))
.disabled(busy)
.help("The agent writes the task summary, then byre deletes the worktree and branch without merging")
}
@@ -1336,19 +1322,14 @@ private struct QuestionBubble: View {
var body: some View {
let open = item.state == .open && !inherited
Group {
AttentionCard(open: open) {
if open {
QuestionCard(item: item)
.id("\(item.id)/\(item.state.rawValue)/\(item.responses.count)")
} else {
DetailsBox(boxed: false) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("asked")
WhenText(when: item.createdAt)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
AttentionHeader(label: "asked", when: item.createdAt, open: false)
MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId))
Text(status)
.font(Fonts.font(11))
@@ -1365,11 +1346,6 @@ private struct QuestionBubble: View {
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(open ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(open ? Palette.warn.opacity(0.55) : Palette.border))
}
private var status: String {
@@ -1438,117 +1414,52 @@ private struct ReadyBubble: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var dismissing = false
@ViewState private var other: String?
@FocusState private var editing: Bool
@FocusState private var focus: String?
var body: some View {
let task = taskId.flatMap { store.findTask($0) }
let current = latest && task?.status == .active
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(discard ? "suggests discarding" : "ready for merge")
.font(Fonts.font(11, weight: current ? 700 : 400))
.foregroundStyle(current ? Palette.warn : Palette.muted)
WhenText(when: when)
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
}
MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId))
if current, let task {
let busy = store.taskBusy(task.id)
otherEntry(task)
AttentionCard(open: current) {
VStack(alignment: .leading, spacing: 4) {
AttentionHeader(label: discard ? "suggests discarding" : "ready for merge", when: when, open: current)
MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId))
if current, let task {
let busy = store.taskBusy(task.id)
AttentionReply(key: draftKey(task), placeholder: "Something else to tell the agent; sending it dismisses this", busy: dismissing, focus: $focus, send: { send(task) }) {
Button("Dismiss") {
dismiss(task, then: nil)
}
.buttonStyle(WebButtonStyle())
.help(discard ? "Drop this suggestion; the task stays open" : "Drop this ready call; the task stays open")
DiscardButton(task: task, busy: busy)
} trailing: {
if !discard {
MergeButton(task: task, busy: busy, unmerged: unmerged, keepOpen: true, primary: keepOpen)
MergeButton(task: task, busy: busy, unmerged: unmerged, keepOpen: false, primary: !keepOpen)
}
}
.padding(.top, 4)
HStack(spacing: 8) {
Spacer(minLength: 0)
Button("Dismiss") {
dismiss(task, then: nil)
}
.buttonStyle(SmallLinkButtonStyle())
.disabled(dismissing)
.help(discard ? "Drop this suggestion; the task stays open" : "Drop this ready call; the task stays open")
DiscardButton(task: task, busy: busy, small: true)
if !discard {
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: true, highlight: keepOpen)
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: false, highlight: !keepOpen)
}
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(current ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(current ? Palette.warn.opacity(0.55) : Palette.border))
}
private func imageKey(_ task: ByreTask) -> String {
private func draftKey(_ task: ByreTask) -> String {
"ready:\(task.id):\(when.timeIntervalSince1970)"
}
@ViewBuilder
private func otherEntry(_ task: ByreTask) -> some View {
let key = imageKey(task)
let hasImages = store.images.has(key)
if other != nil || hasImages {
let text = other ?? ""
VStack(alignment: .leading, spacing: 6) {
Thumbnails(key: key)
TextEditor(text: Binding(get: { text }, set: { other = $0 }))
.font(Fonts.font(13))
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused($editing)
.disabled(dismissing)
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if press.modifiers.contains(.shift) || marked || (trimmed(text).isEmpty && !hasImages) {
return .ignored
}
dismiss(task, then: text)
return .handled
}
.padding(.horizontal, 5)
.padding(.vertical, 6)
.frame(height: 66)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Something else to tell the agent; sending it dismisses this")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.allowsHitTesting(false)
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border))
.imageInput(key)
.onChange(of: editing) {
if !editing && trimmed(other ?? "").isEmpty && !store.images.has(key) {
other = nil
}
}
}
} else {
Text("Other…")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
.contentShape(Rectangle())
.onTapGesture {
other = ""
DispatchQueue.main.async {
editing = true
}
}
private func send(_ task: ByreTask) {
let key = draftKey(task)
let text = store.drafts?.textAnswer(key) ?? ""
if trimmed(text).isEmpty && !store.images.has(key) {
return
}
dismiss(task, then: text)
}
private func dismiss(_ task: ByreTask, then text: String?) {
dismissing = true
let key = imageKey(task)
let key = draftKey(task)
let imgs = store.images.take(key)
Task {
do {
@@ -1556,8 +1467,9 @@ private struct ReadyBubble: View {
_ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok
if let text {
try await store.messageTask(task.id, text, images: ids)
other = nil
}
store.drafts?.dropTextAnswer(key)
store.paneFocus.setDraft(key, false)
} catch {
store.images.restore(key, imgs)
overlays.showAlert(errorText(error))