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

1753 lines
61 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]
}
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 {
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]?
@ObservationIgnored var stuck = true
@ObservationIgnored var prepended = false
@ObservationIgnored var released: Int?
@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, local: store.sends(taskId ?? "lead"), 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
}
var hasUnmerged: Bool {
guard let f = taskId.flatMap(store.findTask)?.mergeFacts else {
return false
}
return f.unmergedCommits > 0 || f.unmergedFiles > 0
}
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 || state == .mergeAfterReview
}
var subagents: Bool {
(session?.subagents ?? 0) > 0
}
func working(_ row: Row) -> Bool {
row.pending && (running || subagents && row.inActive || row.inReview) || row.holdsActive && (running || subagents)
}
var hasOlder: Bool {
kind == .lead && store.leadHasOlder()
}
func start() {
if started {
return
}
started = true
guard let feed else {
return
}
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
builder.stop()
store.drafts?.flush()
}
private func appended(_ all: [ByreEvent]) {
_ = builder.append(all.filter(shows))
jump()
}
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, taskId) else {
return
}
store.jump = nil
scrollTo(row.id)
}
}
private func reviewRow(_ n: Int, _ task: String?) -> Row? {
let rows = builder.root.rows.filter { $0.taskId == task }
return rows.first { $0.reviewOf == n } ?? rows.first { r in
if case .snapshot(let s) = r.kind {
return s.n == n
}
return false
}
}
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 = latestReviewedSnapshot(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 && row.taskId == taskId:
questions.append(row.id)
case .finding(let i):
if let r = row.review, r.snapshotN == latest, 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
}
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)
}
var composerHiddenByDefault: Bool {
guard let taskId, let task = store.findTask(taskId) else {
return false
}
return task.status != .active || store.inbox.contains(where: { $0.taskId == taskId }) || attention().contains { $0.id == "ready" }
}
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)
}
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, sendID: 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, sendId: sendID))).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, sendId: sendID))).accepted
return
}
_ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, images: images, sendId: sendID))).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), 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>(@ViewBuilder _ content: () -> Content) -> some View {
HStack(spacing: 8) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.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 keepOpen: Bool?
var primary = 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
if keepOpen == true {
Button("Merge & continue") {
Task {
await confirmMerge(task.id, store, overlays, keepOpen: true)
}
}
.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 {
button(target, held, intoParent)
}
}
@ViewBuilder
private func button(_ target: String, _ held: Bool, _ intoParent: Bool) -> some View {
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 && task.workflowStage == 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)"
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, keepOpen: keepOpen)
}
}
.buttonStyle(WebButtonStyle(primary: primary))
.disabled(busy || held || intoParent || !unmerged)
.help(help)
}
}
private struct DiscardButton: View {
let task: ByreTask
let busy: Bool
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
Button("Discard") {
Task {
await confirmDiscard(task, store, overlays)
}
}
.buttonStyle(WebButtonStyle(danger: true))
.disabled(busy)
.help("The agent writes the task summary, then byre deletes the worktree and branch without merging")
}
}
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) {
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)
}
}
}
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(status.label)
.font(Fonts.font(11, weight: 700))
.foregroundStyle(color)
.lineLimit(1)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(tint, in: Capsule())
.overlay(Capsule().stroke(hover ? Palette.accent : color.opacity(0.55)))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.fixedSize()
.help(status.help)
.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 {
if let seq = row.summarySeq {
content
.onScrollVisibilityChange(threshold: 0.01) { visible in
model.summaryVisible(seq, visible)
}
} else {
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(actorName(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 .reviewer(let agentModel):
DetailsBox(nested: nested, working: row.active, status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
Text(styled([(row.review?.stage ?? "review", 600, nil), (agentModel.isEmpty ? "" : " · \(agentModel)", 400, Palette.muted), (row.text.isEmpty ? "" : " \(row.text)", 400, Palette.muted)]))
.lineLimit(1)
} 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 .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):
if let error {
Text(error)
.font(Fonts.font(11))
.foregroundStyle(Palette.danger)
} else {
Rectangle().fill(Palette.border).frame(height: 1)
}
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, taskId: row.taskId, inherited: row.taskId != model.taskId, forks: row.forks)
case .question:
if let item = row.question {
QuestionBubble(item: item, inherited: row.taskId != model.taskId)
}
}
}
}
private struct QuestionBubble: View {
let item: InboxItem
let inherited: Bool
var body: some View {
let open = item.state == .open && !inherited
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) {
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))
.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)
}
}
}
}
private var status: String {
let answer = item.responses.last
switch item.state {
case .open:
return inherited ? "unanswered when this task was forked" : "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 "\(actorName(answer.user)) chose: \(options.joined(separator: "; "))"
}
return "\(actorName(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
@Environment(Overlays.self) private var overlays
@ViewState private var dismissing = false
@FocusState private var focus: String?
var body: some View {
let task = taskId.flatMap { store.findTask($0) }
let current = latest && task?.status == .active
var ctx = MDContext(taskId: taskId)
if current, let task {
let busy = dismissing
let focus = $focus
let base = ctx
func discuss(_ item: MDItem) -> AnyView {
var inner = base
inner.discuss = discuss
return AnyView(DiscussItem(item: item, key: discussKey(task, item.index), busy: busy, focus: focus, ctx: inner, send: { send(task) }))
}
ctx.discuss = discuss
}
return AttentionCard(open: current) {
VStack(alignment: .leading, spacing: 4) {
AttentionHeader(label: discard ? "suggests discarding" : "ready for merge", when: when, open: current)
MarkdownView(blocks: blocks, ctx: ctx)
if current, let task {
let busy = store.taskBusy(task.id)
let commented = comments(task).count
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 commented > 0 {
Button(commented == 1 ? "Send 1 comment" : "Send \(commented) comments") {
send(task)
}
.buttonStyle(WebButtonStyle(primary: true))
.help("Send your comments to the agent in one message; sending dismisses this")
} else if !discard {
MergeButton(task: task, busy: busy, unmerged: unmerged, keepOpen: true, primary: task.readyMerge == ._continue)
MergeButton(task: task, busy: busy, unmerged: unmerged, keepOpen: false, primary: task.readyMerge != ._continue)
}
}
.padding(.top, 4)
}
}
}
}
private func draftKey(_ task: ByreTask) -> String {
"ready:\(task.id):\(when.timeIntervalSince1970)"
}
private func discussKey(_ task: ByreTask, _ i: Int) -> String {
"\(draftKey(task)):discuss:\(i)"
}
private func keys(_ task: ByreTask) -> [String] {
discussItems(blocks).map { discussKey(task, $0.index) } + [draftKey(task)]
}
private func comments(_ task: ByreTask) -> [(MDItem, String)] {
discussItems(blocks).compactMap { item in
let key = discussKey(task, item.index)
let text = trimmed(store.drafts?.textAnswer(key) ?? "")
if text.isEmpty && !store.images.has(key) {
return nil
}
return (item, text)
}
}
private func message(_ task: ByreTask) -> String {
let other = trimmed(store.drafts?.textAnswer(draftKey(task)) ?? "")
let parts = comments(task).map { item, text in
item.quote.components(separatedBy: "\n").map { "> \($0)" }.joined(separator: "\n") + (text.isEmpty ? "" : "\n\n\(text)")
}
if parts.isEmpty {
return other
}
let intro = discard ? "Comments on your suggestion to discard:" : "Comments on your ready-for-merge overview:"
return ([intro] + parts + (other.isEmpty ? [] : [other])).joined(separator: "\n\n")
}
private func send(_ task: ByreTask) {
let text = message(task)
if text.isEmpty && !keys(task).contains(where: { store.images.has($0) }) {
return
}
dismiss(task, then: text)
}
private func dismiss(_ task: ByreTask, then text: String?) {
dismissing = true
let keys = keys(task)
let imgs = keys.map { store.images.take($0) }
Task {
do {
let ids = text == nil ? nil : try await uploadImages(store.api, imgs.flatMap { $0 })
_ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok
if let text {
try await store.messageTask(task.id, text, images: ids)
}
for key in keys {
store.drafts?.dropTextAnswer(key)
store.paneFocus.setDraft(key, false)
}
} catch {
for (key, imgs) in zip(keys, imgs) {
store.images.restore(key, imgs)
}
overlays.showAlert(errorText(error))
}
dismissing = false
}
}
}
private struct DiscussItem: View {
let item: MDItem
let key: String
let busy: Bool
var focus: FocusState<String?>.Binding
let ctx: MDContext
let send: () -> Void
@Environment(Store.self) private var store
var body: some View {
let open = otherOpen(store, key)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
ItemBody(item: item, ctx: ctx)
if !open {
Button("Discuss") {
store.drafts?.setTextAnswer(key, "")
}
.buttonStyle(SmallLinkButtonStyle())
.help("Comment on this; your comments go to the agent together")
.disabled(busy)
}
}
if open {
OtherEntry(key: key, placeholder: "Your comment on this", busy: busy, focus: focus, send: send)
}
}
}
}
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))
}
}
@MainActor
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 canceling = false
var body: some View {
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))
.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") {
canceling = true
Task {
do {
_ = try await store.api.client.cancelMergeAfterReview(path: .init(taskId: taskId)).ok
} catch {
overlays.showAlert(errorText(error))
}
canceling = false
}
}
.buttonStyle(SmallLinkButtonStyle())
.disabled(canceling)
.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):
out += taskLink(store, id)
case .preview(let taskId, let port):
guard let url = store.forwards.url(taskId, port) else {
out += AttributedString("port \(port)")
continue
}
var a = AttributedString(url.absoluteString)
a.link = AppLink.preview(taskId, port)
a.foregroundColor = Palette.accent
out += a
}
}
return out
}
}
@MainActor
func taskLink(_ store: Store, _ id: String) -> AttributedString {
guard let task = store.findTask(id) else {
return AttributedString(id)
}
var a = AttributedString(task.name)
if task.status == .active {
a.link = AppLink.task(task.id)
a.foregroundColor = taskPalette[task.color]
}
return a
}
private struct SnapshotRow: View {
let snap: Snapshot
let taskId: String?
let inherited: Bool
let forks: [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)")
if !inherited {
Button("Changes") {
store.pinSnapshot(snap.n)
store.sheet = Sheet(owner: .task(store.selectedTaskId), view: .tab(.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")
ForEach(forks, id: \.self) { id in
Text(AttributedString("forked as ") + taskLink(store, id))
.fixedSize()
}
Rectangle().fill(Palette.border).frame(height: 1)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.help("\(snap.trigger.rawValue.replacingOccurrences(of: "_", with: " ")) · \(time)")
}
}