Files
byre/mac/Sources/Byre/Chat.swift
T
2026-10-19 19:05:19 -07:00

1673 lines
58 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?)
}
struct AttentionGroup: Identifiable {
let id: String
let label: String
let help: String
let rows: [Int]
}
@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 var released: Int?
@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
@ObservationIgnored private var cursors: [String: Int] = [:]
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 reviewing: Bool {
let state = taskId.flatMap { store.findTask($0) }?.state
return state == .reviewing || state == .reviewPending
}
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 j = store.jump, j.taskId == taskId else {
return
}
let rows = builder.root.rows
switch j.target {
case .question(let id):
guard let row = rows.first(where: { $0.question?.id == id }) else {
return
}
store.jump = nil
scrollTo(row.id)
case .review(let n):
guard let row = reviewRow(n) else {
return
}
store.jump = nil
scrollTo(row.id)
}
}
private func reviewRow(_ n: Int) -> Row? {
let rows = builder.root.rows
return rows.first { $0.reviewOf == n } ?? rows.first { r in
if case .snapshot(let s) = r.kind {
return s.n == n
}
return false
}
}
func showReview(_ n: Int) {
if let row = reviewRow(n) {
scrollTo(row.id)
}
}
func showFinding(_ reviewId: String, _ index: Int) {
let row = builder.root.rows.first { r in
if case .finding(let i) = r.kind {
return i == index && r.review?.id == reviewId
}
return false
}
if let row {
scrollTo(row.id)
}
}
func attention() -> [AttentionGroup] {
guard let taskId else {
return []
}
let latest = latestReview(store.transcript)
let task = store.findTask(taskId)
let active = task.map { $0.status == .active && !$0.merging && !$0.mergingIntoParent && $0.mergeQueuedAt == nil && !$0.mergeAfterReview } ?? false
var questions: [Int] = []
var open: [Int] = []
var ready: [Int] = []
var discard = false
for row in builder.root.rows where !row.hidden {
switch row.kind {
case .question where row.question?.state == .open:
questions.append(row.id)
case .finding(let i):
if let r = row.review, r.id == latest?.id, findingOpen(r, i) {
open.append(row.id)
}
case .ready(_, _, let d) where row.id == builder.lastReady && active:
ready.append(row.id)
discard = d
default:
break
}
}
var groups: [AttentionGroup] = []
if !questions.isEmpty {
groups.append(AttentionGroup(id: "questions", label: questions.count == 1 ? "1 question" : "\(questions.count) questions", help: "Scroll to the next open question", rows: questions))
}
if !open.isEmpty {
groups.append(AttentionGroup(id: "findings", label: open.count == 1 ? "1 finding" : "\(open.count) findings", help: "Scroll to the next finding of the latest review that nobody has answered", rows: open))
}
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))
}
return groups
}
var attentionRows: [Int] {
let ids = Set(attention().flatMap(\.rows))
return builder.root.rows.filter { ids.contains($0.id) }.map(\.id)
}
var held: Int? {
guard let first = attentionRows.first, first != released else {
return nil
}
return first
}
func closed(_ old: [Int], _ new: [Int]) {
guard let gone = old.first(where: { !new.contains($0) }), let first = new.first else {
return
}
let rows = builder.root.rows
let at = rows.firstIndex { $0.id == gone } ?? rows.endIndex
scrollTo(rows[at...].first { new.contains($0.id) }?.id ?? first)
}
func release() {
released = held ?? released
}
func cycle(_ g: AttentionGroup) {
let next = (cursors[g.id] ?? -1) + 1
let i = next < g.rows.count ? next : 0
cursors[g.id] = i
scrollTo(g.rows[i])
}
private func scrollTo(_ id: Int) {
stuck = false
requestScroll(id, .top)
}
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) {
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
let active = running || model.reviewing
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if model.key == nil {
Muted(text: model.empty)
.padding(.top, 12)
} else {
if model.loading {
Muted(text: "loading…")
.padding(.top, 12)
}
if model.hasOlder || model.older != nil {
Muted(text: "loading earlier…")
.opacity(model.fetching || model.older != nil ? 1 : 0)
.padding(.top, 12)
}
ForEach(model.builder.root.rows.filter { !$0.hidden }) { row in
RowView(row: row, model: model, nested: false)
.padding(.top, 12)
}
ForEach(model.builder.pending) { p in
PendingView(pending: p, model: model)
.padding(.top, 12)
}
}
}
.scrollTargetLayout()
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
Scanner(color: accent)
.frame(width: 84, height: 2)
.offset(y: 8)
}
}
.padding([.horizontal, .bottom], 16)
.padding(.top, 4)
}
.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.content == old.content && new.visible == old.visible) {
return
}
if let id = model.held {
position.scrollTo(id: id, anchor: .top)
} else if new.distance > 1 {
position.scrollTo(edge: .bottom)
}
}
.onScrollPhaseChange { _, phase in
scrolling = phase.isScrolling
if phase == .interacting {
model.release()
}
if !scrolling {
model.prependOlder()
}
}
.onChange(of: model.held, initial: true) { _, id in
if !model.stuck || scrolling {
return
}
if let id {
position.scrollTo(id: id, anchor: .top)
} else {
position.scrollTo(edge: .bottom)
}
}
.onChange(of: model.attentionRows) { old, new in
model.closed(old, new)
}
.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.release()
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(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
let action: () -> Void
@ViewState private var hover = false
var body: some View {
Button(action: action) {
Text(group.label)
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.warn)
.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)))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.fixedSize()
.help("\(group.help); click again for the next")
.onHover { hover = $0 }
}
}
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)
VStack(alignment: .leading, spacing: 4) {
MarkdownView(blocks: blocks, ctx: md)
ReviewImages(value: row.review?.result?.value["images"] ?? nil)
}
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
}
case .finding(let index):
if let review = row.review {
FindingRow(review: review, index: index, taskId: row.taskId)
}
case .findingResponse(let snapshot, let index, let response, let reason):
FindingResponseView(row: row, model: model, snapshot: snapshot, index: index, response: response, reason: reason)
case .ready(let when, let blocks, let discard):
ReadyBubble(when: when, blocks: blocks, discard: discard, 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) {
model.showReview(snap.n)
}
case .question:
if let item = row.question {
QuestionBubble(item: item)
}
}
}
}
private struct QuestionBubble: View {
let item: InboxItem
var body: some View {
let open = item.state == .open
Group {
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)
MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId))
Text(status)
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
}
} content: {
QuestionDetails(item: item)
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
.padding(.top, 8)
}
}
}
.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 {
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 FindingResponseView: View {
let row: Row
let model: ChatModel
let snapshot: Int
let index: Int
let response: String
let reason: String
var body: some View {
let title = row.review.flatMap { r in findings(r).indices.contains(index) ? str(findings(r)[index], "title") : nil } ?? ""
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(response) finding \(index + 1)\(title.isEmpty ? "" : ", \(title),") of snapshot #\(snapshot)'s review: \(reason)")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
if let review = row.review, findings(review).indices.contains(index) {
Button("show") {
model.showFinding(review.id, index)
}
.buttonStyle(SmallLinkButtonStyle())
.help("Scroll to the finding")
if response == FindingResponse.ResponsePayload.ignoring.rawValue, let taskId = row.taskId {
FindingActions(taskId: taskId, review: review, index: index)
}
}
}
}
}
private struct ReadyBubble: View {
let when: Date
let blocks: [MDBlock]
let discard: Bool
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(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)
HStack(spacing: 8) {
Spacer(minLength: 0)
if !discard {
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true)
}
DiscardButton(task: task, busy: busy, small: true)
}
}
}
.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?
let showReview: () -> Void
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.paneAccent) private var accent
@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 {
showReview()
} 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, border: review?.state == .running ? accent : Palette.border))
.disabled(review == nil || review?.state == .running)
.help(review?.state == .running ? "Review in progress" : review == nil ? "Not reviewed" : "Scroll to the review of snapshot \(snap.n)")
}
}