Files
byre/mac/Sources/Byre/Chat.swift
T

1414 lines
49 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import ByreAPI
import SwiftUI
enum ChatKind: Equatable {
case lead
case task(String?)
}
@MainActor
@Observable
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]?
private(set) var hasUnmerged = false
@ObservationIgnored var stuck = true
@ObservationIgnored var prepended = false
@ObservationIgnored private var unmergedTimer: Task<Void, Never>?
@ObservationIgnored private var offs: [() -> Void] = []
@ObservationIgnored private var focusStart = true
@ObservationIgnored private var visibleSummaries: Set<Int64> = []
@ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken?
@ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken?
@ObservationIgnored private var started = false
init(_ kind: ChatKind, _ store: Store) {
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()
case .task(let id): id.flatMap { store?.currentSession($0) }
}
}
builder = TranscriptBuilder(taskId: taskId, worktree: { [weak store] in
taskId.flatMap { store?.findTask($0)?.worktreePath } ?? ""
}, running: {
session()?.state == .running
})
}
var key: String? {
switch kind {
case .lead: "lead"
case .task(let id): id
}
}
var taskId: String? {
if case .task(let id) = kind {
return id
}
return nil
}
private var feed: Feed? {
switch kind {
case .lead: store.leadFeed
case .task(let id): id != nil ? store.selectedFeed : nil
}
}
var session: AgentSession? {
switch kind {
case .lead: store.currentLead()
case .task(let id): id.flatMap { store.currentSession($0) }
}
}
var liveSession: AgentSession? {
session.flatMap { $0.state != .completed ? $0 : nil }
}
var loading: Bool {
switch kind {
case .lead: store.leadLoading()
case .task: store.transcriptLoading()
}
}
var team: Bool {
member != nil
}
var placeholder: String {
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 {
kind == .lead ? "" : "No task selected. Ask your lead, or pick one with ⌘K."
}
var running: Bool {
builder.open && session?.state == .running
}
var subagents: Bool {
(session?.subagents ?? 0) > 0
}
func working(_ row: Row) -> Bool {
row.pending && (running || subagents && row.inActive) || row.active && (running || subagents)
}
var hasOlder: Bool {
kind == .lead && store.leadHasOlder()
}
func start() {
if started {
return
}
started = true
guard let feed else {
return
}
Task {
await loadUnmerged()
}
appended(feed.events)
offs.append(feed.appended.add { [weak self] events in
self?.appended(events)
})
offs.append(feed.streamed.add { [weak self] ev in
if self?.shows(ev) == true {
self?.builder.appendDelta(ev)
}
})
activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in
self?.markShown()
}
keyObserver = NotificationCenter.default.addObserver(of: NSWindow.self, for: .didBecomeKey) { [weak self] _ in
self?.markShown()
}
}
func stop() {
for off in offs {
off()
}
offs = []
if let activeObserver {
NotificationCenter.default.removeObserver(activeObserver)
}
activeObserver = nil
if let keyObserver {
NotificationCenter.default.removeObserver(keyObserver)
}
keyObserver = nil
unmergedTimer?.cancel()
unmergedTimer = nil
builder.stop()
store.drafts?.flush()
}
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
try? await Task.sleep(for: .milliseconds(1500))
if !Task.isCancelled {
await self?.loadUnmerged()
}
}
}
_ = builder.append(events)
jump()
}
private func loadUnmerged() async {
guard let taskId else {
return
}
do {
let u = try await store.api.client.getUnmerged(path: .init(taskId: taskId)).ok.body.json
hasUnmerged = u.commits > 0 || u.files > 0
} catch {
log.error("load unmerged changes: \(errorText(error), privacy: .public)")
}
}
func takeFocus() -> Bool {
let asked = taskId != nil && store.composerFocus == taskId
if asked {
store.composerFocus = nil
}
let take = asked || (focusStart && session == nil)
focusStart = false
return take
}
func summaryVisible(_ seq: Int64, _ visible: Bool) {
if visible {
visibleSummaries.insert(seq)
} else {
visibleSummaries.remove(seq)
}
markShown()
}
private func markShown() {
guard let taskId, store.paneFocus.windowKey else {
return
}
for seq in visibleSummaries {
store.markSummaryRead(taskId, seq)
}
}
func jump() {
guard let target = store.jump, target.taskId == taskId else {
return
}
let rows = builder.root.rows
guard let i = rows.firstIndex(where: { $0.summarySeq == target.seq }) else {
return
}
store.jump = nil
stuck = false
switch i > 0 ? rows[i - 1].kind : nil {
case .agent, .subagentMessage:
requestScroll(rows[i - 1].id, .top)
default:
requestScroll(rows[i].id, .bottom)
}
}
private func requestScroll(_ id: Int, _ anchor: UnitPoint) {
scrollRequest = ((scrollRequest?.seq ?? 0) + 1, id, anchor)
}
func loadOlder() {
if fetching || older != nil || !hasOlder {
return
}
fetching = true
Task {
do {
older = try await store.loadOlderLead()
} catch {
log.error("load earlier events: \(errorText(error), privacy: .public)")
}
fetching = false
}
}
func prependOlder() {
guard let events = older else {
return
}
older = nil
let n = builder.root.rows.count
builder.prepend(events)
prepended = builder.root.rows.count > n || !hasOlder
if !prepended {
loadOlder()
}
}
func send(sessionId: String?, text: String, images: [String]?, model: String?, firstModel: String) async throws {
let api = store.api.client
switch kind {
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
}
_ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, images: images))).created
}
}
func withdraw(_ p: PendingSend, _ overlays: Overlays) async {
guard let taskId, let inputId = p.inputId else {
return
}
let key = "message:\(taskId)"
do {
var imgs: [PendingImage] = []
for id in p.imageIds {
let data = try await ImageCache.fetch(.blob(id), store.api)
guard let image = NSImage(data: data) else {
throw ApiError(status: 0, message: "not an image")
}
imgs.append(PendingImage(data: data, image: image))
}
_ = try await store.api.client.withdrawMessage(path: .init(taskId: taskId, inputId: Int64(inputId))).noContent
builder.withdrawn(inputId)
store.drafts?.restoreText(key, p.text)
store.images.restore(key, imgs)
} catch {
overlays.showAlert(errorText(error))
}
}
func interrupt(_ sessionId: String, _ overlays: Overlays) {
Task {
do {
_ = try await store.api.client.interruptSession(path: .init(sessionId: sessionId)).accepted
} catch {
overlays.showAlert(errorText(error))
}
}
}
}
struct ChatView: View {
let kind: ChatKind
@Environment(Store.self) private var store
@ViewState private var model: ChatModel?
var body: some View {
VStack(spacing: 0) {
if let model {
ChatHead(model: model)
TranscriptScroll(model: model)
if let key = model.key {
ComposerView(chat: model, key: key)
}
} else {
Spacer()
}
}
.onAppear {
let m = model ?? ChatModel(kind, store)
model = m
m.start()
}
.onDisappear {
model?.stop()
}
.onChange(of: store.jump?.seq) {
model?.jump()
}
}
}
private struct ChatHead: View {
let model: ChatModel
@Environment(Store.self) private var store
var body: some View {
switch model.kind {
case .lead:
let live = model.liveSession
header {
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)
}
}
}
}
}
private func header<Content: View>(background: Color = .clear, @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)
}
}
}
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
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var sending = false
var body: some View {
let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch"
Button("Sync from \(target)") {
sending = true
Task {
do {
_ = try await store.api.client.mergeIn(path: .init(taskId: task.id)).accepted
} catch {
overlays.showAlert(errorText(error))
sending = false
}
}
}
.buttonStyle(WebButtonStyle())
.disabled(busy || sending)
.help("Ask the agent to bring \(target) into this branch and resolve any conflicts")
.onChange(of: busy) {
sending = false
}
}
}
private struct MergeButton: View {
let task: ByreTask
let busy: Bool
let unmerged: Bool
var small = false
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch"
let held = task.mergeQueuedAt != nil
let intoParent = task.mergingIntoParent
let help = intoParent
? "Waiting for the parent task's agent to merge this subtask"
: held
? "Waiting for the merge in progress in this project to finish"
: task.mergeAfterReview
? "Merges when the running review finishes with no findings; open to merge now instead (⌘S)"
: 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" : "Merge") {
Task {
await confirmMerge(task.id, store, overlays)
}
}
Group {
if small {
button.buttonStyle(SmallLinkButtonStyle())
} else {
button.buttonStyle(WebButtonStyle())
}
}
.disabled(busy || held || intoParent || !unmerged)
.help(help)
}
}
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
@ViewState private var frame: CGRect = .zero
var body: some View {
let button = Button("Discard") {
Task {
await confirmDiscard(task, store, overlays, anchor: frame)
}
}
Group {
if small {
button.buttonStyle(SmallLinkButtonStyle(color: Palette.danger))
} else {
button.buttonStyle(WebButtonStyle(danger: true))
}
}
.disabled(busy)
.help("The agent writes the task summary, then byre deletes the worktree and branch without merging")
.onGeometryChange(for: CGRect.self) { g in
g.frame(in: .named("window"))
} action: { f in
frame = f
}
}
}
struct CompactButton: View {
let sessionId: String?
let state: AgentSession.StatePayload?
let usage: Usage?
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.isEnabled) private var enabled
@ViewState private var sending = false
var body: some View {
let pct = usage.map { $0.window > 0 ? min(100, Double($0.used) / Double($0.window) * 100) : 0 } ?? 0
let level = pct >= 90 ? Palette.danger : pct >= 70 ? Palette.warn : Palette.accent
let detail = usage.map { u in u.window > 0 ? "\ncontext: \(u.used.formatted()) of \(u.window.formatted()) tokens\(u.model.isEmpty ? "" : " (\(u.model))")" : "" } ?? ""
let disabled = sessionId == nil || state != .idle || sending
Menu {
ForEach(models, id: \.self) { m in
Button("Continue on \(m)") {
compact(m)
}
}
} label: {
Text("Compact")
.foregroundStyle(Palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background {
GeometryReader { g in
HStack(spacing: 0) {
ZStack {
Palette.raised
level.opacity(0.25)
}
.frame(width: g.size.width * pct / 100)
Palette.raised
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(pct >= 70 ? level : Palette.border))
.opacity(disabled ? 0.45 : 1)
.contentShape(Rectangle())
} primaryAction: {
compact(nil)
}
.menuStyle(.button)
.menuIndicator(.hidden)
.buttonStyle(.plain)
.fixedSize()
.disabled(disabled)
.help("Ask the agent for a handoff, then continue in a new session with a fresh context; hold to continue on another model\(detail)")
.onChange(of: state) {
sending = false
}
}
private func compact(_ model: String?) {
guard let sessionId else {
return
}
sending = true
Task {
do {
_ = try await store.api.client.compactSession(path: .init(sessionId: sessionId), body: .json(.init(model: model))).accepted
} catch {
overlays.showAlert(errorText(error))
sending = false
}
}
}
}
private struct ScrollMetrics: Equatable {
let content: CGFloat
let visible: CGFloat
let distance: CGFloat
let offset: CGFloat
}
private struct TranscriptScroll: View {
let model: ChatModel
@Environment(Store.self) private var store
@Environment(\.paneAccent) private var accent
@ViewState private var position = ScrollPosition(edge: .bottom)
@ViewState private var scrolling = false
var body: some View {
let running = model.running
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if model.key == nil {
Muted(text: model.empty)
} else {
if model.loading {
Muted(text: "loading…")
}
if model.hasOlder || model.older != nil {
Muted(text: "loading earlier…")
.opacity(model.fetching || model.older != nil ? 1 : 0)
}
ForEach(model.builder.root.rows) { row in
RowView(row: row, model: model, nested: false)
}
ForEach(model.builder.pending) { p in
PendingView(pending: p, model: model)
}
}
}
.scrollTargetLayout()
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
Scanner(color: accent)
.frame(width: 84, height: 2)
.offset(y: 8)
}
}
.padding(16)
}
.scrollPosition($position)
.defaultScrollAnchor(.bottom)
.onScrollGeometryChange(for: ScrollMetrics.self) { g in
ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY, offset: g.contentOffset.y)
} action: { old, new in
if model.prepended && new.content != old.content {
model.prepended = false
position.scrollTo(y: new.offset + new.content - old.content)
return
}
if new.offset < new.visible {
model.loadOlder()
}
if scrolling {
model.stuck = new.distance < 80
return
}
if model.stuck && new.distance > 1 && (new.content != old.content || new.visible != old.visible) {
position.scrollTo(edge: .bottom)
}
}
.onScrollPhaseChange { _, phase in
scrolling = phase.isScrolling
if !scrolling {
model.prependOlder()
}
}
.onChange(of: model.older == nil) {
if !scrolling {
model.prependOlder()
}
}
.onChange(of: model.builder.pending.last?.id) { old, new in
guard let new, new > old ?? 0 else {
return
}
model.stuck = true
position.scrollTo(edge: .bottom)
}
.onChange(of: model.scrollRequest?.seq, initial: true) {
if let r = model.scrollRequest {
position.scrollTo(id: r.id, anchor: r.anchor)
}
}
.overlay {
Rectangle().stroke(running ? accent : .clear, lineWidth: 1)
.animation(.easeInOut(duration: 0.3), value: running)
}
}
}
private struct Scanner: NSViewRepresentable {
let color: Color
func makeNSView(context: Context) -> ScannerView {
ScannerView()
}
func updateNSView(_ v: ScannerView, context: Context) {
let c = NSColor(color)
CATransaction.begin()
CATransaction.setDisableActions(true)
v.glow.colors = [c.withAlphaComponent(0).cgColor, c.cgColor, c.withAlphaComponent(0).cgColor]
CATransaction.commit()
}
}
private final class ScannerView: NSView {
let glow = CAGradientLayer()
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
let dots = CALayer()
dots.frame = CGRect(x: 0, y: 0, width: 84, height: 2)
for x in stride(from: 0.0, through: 80, by: 5) {
let dot = CALayer()
dot.frame = CGRect(x: x, y: 0, width: 2, height: 2)
dot.backgroundColor = NSColor.black.cgColor
dots.addSublayer(dot)
}
glow.bounds = CGRect(x: 0, y: 0, width: 30, height: 2)
glow.position = CGPoint(x: 15, y: 1)
glow.startPoint = CGPoint(x: 0, y: 0.5)
glow.endPoint = CGPoint(x: 1, y: 0.5)
let strip = CALayer()
strip.frame = dots.frame
strip.mask = dots
strip.addSublayer(glow)
layer?.addSublayer(strip)
let sweep = CABasicAnimation(keyPath: "position.x")
sweep.fromValue = 15
sweep.toValue = 69
sweep.duration = 1.2
sweep.autoreverses = true
sweep.repeatCount = .infinity
sweep.timingFunction = CAMediaTimingFunction(controlPoints: 0.37, 0, 0.63, 1)
sweep.isRemovedOnCompletion = false
glow.add(sweep, forKey: "sweep")
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
}
private struct PendingView: View {
let pending: PendingSend
let model: ChatModel
@Environment(Overlays.self) private var overlays
@ViewState private var withdrawing = false
var body: some View {
TrailingBubble {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Text(pending.queued ? "queued…" : "sending…")
.font(Fonts.font(11, italic: true))
.foregroundStyle(Palette.muted)
if pending.inputId != nil {
Button("×") {
withdrawing = true
Task {
await model.withdraw(pending, overlays)
withdrawing = false
}
}
.buttonStyle(ThumbRemoveStyle())
.disabled(withdrawing)
.help("Take this message back into the composer")
}
}
if !pending.images.isEmpty {
FlowLayout(spacing: 6) {
ForEach(pending.images) { img in
Image(nsImage: img.image)
.resizable()
.scaledToFit()
.frame(maxWidth: 240, maxHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
}
}
.padding(.vertical, 4)
}
if !pending.imageIds.isEmpty {
MessageImages(ids: pending.imageIds)
}
if !pending.md.isEmpty {
MarkdownView(blocks: pending.md)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Palette.accentTint, in: RoundedRectangle(cornerRadius: 10))
}
}
}
struct DetailsBox<Summary: View, Content: View>: View {
var boxed = true
var nested = false
var working = false
var status: (text: String, color: Color)? = nil
@ViewBuilder let summary: Summary
@ViewBuilder let content: Content
@Environment(\.paneAccent) private var accent
@ViewState private var open = false
@ViewState private var shown = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
open.toggle()
} label: {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(open ? "▾" : "▸")
.foregroundStyle(Palette.muted)
summary
Spacer(minLength: 0)
if let status {
Text(status.text)
.font(Fonts.font(12))
.foregroundStyle(status.color)
.padding(.leading, 12)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
content
}
}
.padding(.horizontal, boxed ? 10 : 0)
.padding(.vertical, boxed ? 4 : 0)
.background(boxed ? (nested ? Palette.raised : Palette.surface) : .clear, in: RoundedRectangle(cornerRadius: 8))
.overlay {
if boxed {
RoundedRectangle(cornerRadius: 8).stroke(working && shown ? accent : Palette.border)
.animation(.easeInOut(duration: 0.6), value: working && shown)
}
}
.onAppear {
shown = true
}
}
}
struct WhenText: View {
let when: Date
var body: some View {
Text(ago(when))
.help(isoText(when))
}
}
private struct SubagentTag: View {
let show: Bool
var body: some View {
if show {
Text("↳ subagent")
.font(Fonts.font(11))
.foregroundStyle(Palette.accent)
.padding(.trailing, 4)
}
}
}
struct RowView: View {
let row: Row
let model: ChatModel
let nested: Bool
@Environment(Store.self) private var store
var body: some View {
if !row.hidden {
content
}
}
private var md: MDContext {
MDContext(taskId: row.taskId)
}
@ViewBuilder
private var content: some View {
switch row.kind {
case .user(let actor, let when, let images, let blocks):
TrailingBubble {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(actor)
WhenText(when: when)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
if !images.isEmpty {
MessageImages(ids: images)
}
if let blocks {
MarkdownView(blocks: blocks, ctx: md)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Palette.accentTint, in: RoundedRectangle(cornerRadius: 10))
}
case .byrePrompt(let brief, let when, let blocks):
DetailsBox(nested: nested) {
Text(styled([("byre", 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 12)
WhenText(when: when).font(Fonts.font(12)).foregroundStyle(Palette.muted)
} content: {
MarkdownView(blocks: blocks, ctx: md)
.padding(.vertical, 6)
}
case .agent(let blocks):
MarkdownView(blocks: blocks, ctx: md)
case .subagentMessage(let when, let blocks):
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
SubagentTag(show: row.tag)
WhenText(when: when)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
MarkdownView(blocks: blocks, ctx: md)
}
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.accent).frame(width: 1)
}
case .stream:
MarkdownView(blocks: row.md, ctx: md)
case .thinking(let blocks):
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
SubagentTag(show: row.tag)
Text("thinking")
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
MarkdownView(blocks: blocks, ctx: md)
}
case .tool(let name, let brief, let input):
DetailsBox(nested: nested, working: model.working(row)) {
SubagentTag(show: row.tag)
Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
} content: {
VStack(alignment: .leading, spacing: 6) {
PreBox(text: input)
ToolResult(result: row.result, unfinished: row.unfinished, started: row.started)
}
.padding(.vertical, 6)
}
case .subagent(let title, let description, let prompt):
DetailsBox(nested: nested, working: model.working(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
SubagentTag(show: row.tag)
Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)]))
} content: {
VStack(alignment: .leading, spacing: 6) {
MarkdownView(blocks: prompt, ctx: md)
.padding(.vertical, 6)
ForEach(row.body!.rows) { child in
RowView(row: child, model: model, nested: true)
}
ToolResult(result: row.result, unfinished: row.unfinished, started: row.started)
}
}
case .group:
DetailsBox(nested: nested, working: model.working(row)) {
SubagentTag(show: row.tag)
RunSummary(calls: row.calls)
} content: {
VStack(alignment: .leading, spacing: 6) {
ForEach(row.body!.rows) { child in
RowView(row: child, model: model, nested: true)
}
}
.padding(.top, 6)
.padding(.bottom, 4)
}
case .orphan(let text):
PreBox(text: text)
case .turnEnd(let error):
Group {
if let error {
Text(error)
.font(Fonts.font(11))
.foregroundStyle(Palette.danger)
} else {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
.onScrollVisibilityChange(threshold: 0.01) { visible in
if let seq = row.summarySeq {
model.summaryVisible(seq, visible)
}
}
case .note(let note):
NoteView(note: note, cancelVisible: row.id == model.builder.lastMergeAfterReview)
case .review(let note, let blocks):
VStack(alignment: .leading, spacing: 4) {
NoteView(note: note, cancelVisible: false)
MarkdownView(blocks: blocks, ctx: md)
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
}
case .ready(let when, let blocks):
ReadyBubble(when: when, blocks: blocks, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged)
case .handoff:
DetailsBox(boxed: false) {
NoteView(note: row.summary ?? Note(parts: []), cancelVisible: false)
} content: {
VStack(alignment: .leading, spacing: 12) {
ForEach(row.body!.rows) { child in
RowView(row: child, model: model, nested: false)
}
}
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
.padding(.top, 8)
}
case .snapshot(let snap):
SnapshotRow(snap: snap, review: row.review, taskId: row.taskId)
case .question:
if let item = row.question {
QuestionBubble(item: item)
}
}
}
}
private struct QuestionBubble: View {
let item: InboxItem
@Environment(Store.self) private var store
@ViewState private var hover = false
var body: some View {
let open = item.state == .open
let tone = open ? Palette.warn : Palette.muted
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("asked")
WhenText(when: item.createdAt)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId))
Text(status)
.font(Fonts.font(11, weight: open ? 700 : 400))
.foregroundStyle(tone)
.fixedSize(horizontal: false, vertical: true)
}
.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(hover ? Palette.accent : open ? Palette.warn.opacity(0.55) : Palette.border))
.contentShape(Rectangle())
.onTapGesture {
store.showQuestion(item.taskId, item.id)
}
.environment(\.choiceTap, { [store, item] in
store.showQuestion(item.taskId, item.id)
})
.onHover { hover = $0 }
.help("Show this question in the Questions tab")
}
private var status: String {
let answer = item.responses.last
switch item.state {
case .open:
return "waiting for your answer"
case .dismissed:
return item.resolvedBy == nil ? "withdrawn by the agent" : "dismissed by \(item.resolvedBy!)"
default:
guard let answer else {
return item.state.rawValue
}
if let options = answer.options, !options.isEmpty {
return "\(answer.user) chose: \(options.joined(separator: "; "))"
}
return "\(answer.user) answered: \(firstLine(answer.text ?? ""))"
}
}
private func firstLine(_ s: String) -> String {
let line = trimmed(s).components(separatedBy: "\n").first ?? ""
return line.count > 120 ? "\(line.prefix(120))…" : line
}
}
private struct ReadyBubble: View {
let when: Date
let blocks: [MDBlock]
let taskId: String?
let latest: Bool
let unmerged: Bool
@Environment(Store.self) private var store
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("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)
Spacer(minLength: 0)
if current, let task {
let busy = store.taskBusy(task.id)
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true)
DiscardButton(task: task, busy: busy, small: true)
}
}
MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId))
}
.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 struct ToolResult: View {
let result: String?
let unfinished: Bool
let started: Date?
var body: some View {
if let result {
PreBox(text: result)
} else if unfinished || started == nil {
label(unfinished ? "no result" : "running…")
} else {
TimelineView(.periodic(from: .now, by: 1)) { ctx in
label("running… \(elapsed(ctx.date.timeIntervalSince(started!)))")
}
}
}
private func label(_ text: String) -> some View {
Text(text)
.font(Fonts.font(12, italic: true))
.foregroundStyle(Palette.muted)
.padding(.vertical, 4)
}
private func elapsed(_ t: TimeInterval) -> String {
let s = max(0, Int(t))
if s < 60 {
return "\(s)s"
}
return "\(s / 60)m \(s % 60 < 10 ? "0" : "")\(s % 60)s"
}
}
private struct RunSummary: View {
let calls: [Call]
var body: some View {
var order: [String] = []
var counts: [String: (call: Call, n: Int)] = [:]
for c in calls {
let key = "\(c.name)\0\(c.target)"
if counts[key] == nil {
order.append(key)
counts[key] = (c, 0)
}
counts[key]!.n += 1
}
var parts: [(String, CGFloat, Color?)] = []
for (i, key) in order.enumerated() {
let (call, n) = counts[key]!
if i > 0 {
parts.append((", ", 400, nil))
}
parts.append((call.name, 600, nil))
if !call.target.isEmpty {
parts.append((" \(call.target)", 400, Palette.muted))
}
if n > 1 {
parts.append((" ×\(n)", 400, Palette.muted))
}
}
return Text(styled(parts))
}
}
private func styled(_ parts: [(String, CGFloat, Color?)], size: CGFloat = 12) -> AttributedString {
var out = AttributedString()
for (text, weight, color) in parts {
var a = AttributedString(text)
a.font = Fonts.font(size, weight: weight)
if let color {
a.foregroundColor = color
}
out += a
}
return out
}
struct NoteView: View {
let note: Note
let cancelVisible: Bool
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var cancelling = false
var body: some View {
let color = note.style == .error ? Palette.danger : Palette.muted
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(text())
.font(Fonts.font(11))
.foregroundStyle(color)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
if let when = note.when {
WhenText(when: when)
.font(Fonts.font(11))
.foregroundStyle(color)
}
if let taskId = note.cancelMergeAfterReview, cancelVisible, store.findTask(taskId)?.mergeAfterReview == true {
Button("Cancel") {
cancelling = true
Task {
do {
_ = try await store.api.client.cancelMergeAfterReview(path: .init(taskId: taskId)).ok
} catch {
overlays.showAlert(errorText(error))
}
cancelling = false
}
}
.buttonStyle(SmallLinkButtonStyle())
.disabled(cancelling)
.help("Withdraw this request; nothing is merged")
}
}
}
private func text() -> AttributedString {
var out = AttributedString()
for part in note.parts {
switch part {
case .text(let s):
out += AttributedString(s)
case .task(let id):
guard let task = store.findTask(id) else {
out += AttributedString(id)
continue
}
var a = AttributedString(task.title)
if task.status == .active {
a.link = AppLink.task(task.id)
a.foregroundColor = taskPalette[task.color]
}
out += a
case .url(let port):
let url = previewURL(store.api.origin, Int(port) ?? 0)
var a = AttributedString(url.absoluteString)
a.link = url
a.foregroundColor = Palette.accent
out += a
}
}
return out
}
}
func previewURL(_ origin: URL, _ port: Int) -> URL {
var c = URLComponents()
c.scheme = "https"
c.host = origin.host()
c.port = port
c.path = "/"
return c.url!
}
private struct SnapshotRow: View {
let snap: Snapshot
let review: Review?
let taskId: String?
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var forking = false
var body: some View {
let time = clockText(snap.createdAt)
HStack(spacing: 8) {
Rectangle().fill(Palette.border).frame(height: 1)
Text("snapshot #\(snap.n)")
Button("changes") {
store.pinSnapshot(snap.n)
store.focusTab(.changes)
}
.buttonStyle(SmallLinkButtonStyle())
.help("Show snapshot \(snap.n) in the Changes tab")
Button("fork") {
guard let taskId else {
return
}
forking = true
Task {
do {
let task = try await store.api.client.forkSnapshot(path: .init(taskId: taskId, n: snap.n)).created.body.json
store.focusTask(task.id)
} catch {
forking = false
overlays.showAlert(errorText(error))
}
}
}
.buttonStyle(SmallLinkButtonStyle())
.disabled(forking)
.help("Start a new task from this snapshot, its agent continuing the conversation as it stood here")
if snap.trigger != .answer {
reviewButton
}
Rectangle().fill(Palette.border).frame(height: 1)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.help("\(snap.trigger.rawValue.replacingOccurrences(of: "_", with: " ")) · \(time)")
}
private var reviewButton: some View {
let n = review.map(findingCount) ?? 0
return Button {
store.showReview(snap.n)
} label: {
Text("review")
.font(Fonts.font(11, weight: n > 0 ? 700 : 400))
.overlay(alignment: .topTrailing) {
if let review {
switch review.state {
case .running, .failed:
EmptyView()
case .skipped:
Text("●").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.muted).help("skipped: no changes")
.fixedSize()
.offset(x: 13, y: -7)
case .done:
if n == 0 {
Text("✓").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.ok).help(review.findingStates.additionalProperties.isEmpty ? "no findings" : "every finding addressed")
.fixedSize()
.offset(x: 13, y: -7)
} else {
Text("\(n)")
.font(Fonts.font(10, weight: 700))
.foregroundStyle(Palette.danger)
.frame(height: 14)
.padding(.horizontal, 5)
.background(Palette.dangerTint, in: Capsule())
.help("\(n) open findings")
.fixedSize()
.offset(x: 15, y: -9)
}
}
}
}
}
.buttonStyle(SmallLinkButtonStyle(color: n > 0 ? Palette.warn : Palette.accent, fill: review?.state == .running ? Palette.accentTint : .clear))
.help(review?.state == .running ? "Review in progress; show snapshot \(snap.n)'s review in the Reviews tab" : "Show the review of snapshot \(snap.n) in the Reviews tab")
}
}