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

1351 lines
46 KiB
Swift
Raw Normal View History

2026-10-16 23:30:57 -07:00
import AppKit
import ByreAPI
2026-10-16 23:30:57 -07:00
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] = [:]
2026-10-16 23:30:57 -07:00
private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)?
private(set) var fetching = false
2026-10-17 13:14:35 -07:00
private(set) var older: [ByreEvent]?
private(set) var hasUnmerged = false
2026-10-16 23:30:57 -07:00
@ObservationIgnored var stuck = true
2026-10-17 13:14:35 -07:00
@ObservationIgnored var prepended = false
@ObservationIgnored private var unmergedTimer: Task<Void, Never>?
2026-10-16 23:30:57 -07:00
@ObservationIgnored private var offs: [() -> Void] = []
@ObservationIgnored private var focusStart = true
@ObservationIgnored private var visibleSummaries: Set<Int64> = []
2026-10-18 00:31:46 -07:00
@ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken?
@ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken?
2026-10-16 23:30:57 -07:00
@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) }
2026-10-16 23:30:57 -07:00
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
}
2026-10-16 23:30:57 -07:00
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
2026-10-16 23:30:57 -07:00
}
var empty: String {
kind == .lead ? "" : "No task selected. Ask your lead, or pick one with ⌘K."
}
var running: Bool {
builder.open && session?.state == .running
}
2026-10-17 08:57:26 -07:00
var subagents: Bool {
(session?.subagents ?? 0) > 0
}
func working(_ row: Row) -> Bool {
row.pending && (running || subagents && row.inActive) || row.active && (running || subagents)
}
2026-10-16 23:30:57 -07:00
var hasOlder: Bool {
kind == .lead && store.leadHasOlder()
}
func start() {
if started {
return
}
started = true
guard let feed else {
return
}
Task {
await loadUnmerged()
}
2026-10-16 23:30:57 -07:00
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)
}
2026-10-16 23:30:57 -07:00
})
2026-10-18 00:31:46 -07:00
activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in
self?.markShown()
2026-10-16 23:30:57 -07:00
}
keyObserver = NotificationCenter.default.addObserver(of: NSWindow.self, for: .didBecomeKey) { [weak self] _ in
self?.markShown()
}
2026-10-16 23:30:57 -07:00
}
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
2026-10-16 23:30:57 -07:00
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()
}
}
}
2026-10-16 23:30:57 -07:00
_ = 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)")
}
}
2026-10-17 09:04:32 -07:00
func takeFocus() -> Bool {
let asked = taskId != nil && store.composerFocus == taskId
if asked {
store.composerFocus = nil
}
let take = asked || (focusStart && session == nil)
2026-10-16 23:30:57 -07:00
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 {
2026-10-16 23:30:57 -07:00
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
2026-10-17 10:54:06 -07:00
switch i > 0 ? rows[i - 1].kind : nil {
case .agent, .subagentMessage:
2026-10-16 23:30:57 -07:00
requestScroll(rows[i - 1].id, .top)
2026-10-17 10:54:06 -07:00
default:
2026-10-16 23:30:57 -07:00
requestScroll(rows[i].id, .bottom)
}
}
private func requestScroll(_ id: Int, _ anchor: UnitPoint) {
scrollRequest = ((scrollRequest?.seq ?? 0) + 1, id, anchor)
}
func loadOlder() {
2026-10-17 13:14:35 -07:00
if fetching || older != nil || !hasOlder {
2026-10-16 23:30:57 -07:00
return
}
fetching = true
Task {
do {
2026-10-17 13:14:35 -07:00
older = try await store.loadOlderLead()
2026-10-16 23:30:57 -07:00
} catch {
log.error("load earlier events: \(errorText(error), privacy: .public)")
}
fetching = false
2026-10-17 13:14:35 -07:00
}
}
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()
2026-10-16 23:30:57 -07:00
}
}
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
}
2026-10-16 23:30:57 -07:00
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))
}
}
2026-10-16 23:30:57 -07:00
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) {
2026-10-18 21:44:53 -07:00
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 {
2026-10-16 23:30:57 -07:00
header {
Text(task.status.rawValue)
}
} else {
let sess = store.currentSession(task.id)
let busy = store.taskBusy(task.id)
2026-10-16 23:30:57 -07:00
header {
if model.team {
MemberPicker(taskId: task.id, picked: model.member)
}
2026-10-16 23:30:57 -07:00
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)
2026-10-16 23:30:57 -07:00
}
}
}
}
}
2026-10-18 21:44:53 -07:00
private func header<Content: View>(background: Color = .clear, @ViewBuilder _ content: () -> Content) -> some View {
2026-10-16 23:30:57 -07:00
HStack(spacing: 8) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
2026-10-16 23:30:57 -07:00
.padding(.horizontal, 16)
.padding(.vertical, 8)
2026-10-18 21:44:53 -07:00
.background(background)
2026-10-16 23:30:57 -07:00
.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))")
}
}
}
}
2026-10-16 23:30:57 -07:00
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
@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)"
Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : "Merge") {
Task {
await confirmMerge(task.id, store, overlays)
}
}
.buttonStyle(WebButtonStyle())
.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
@ViewState private var frame: CGRect = .zero
var body: some View {
Button("Discard") {
Task {
await confirmDiscard(task, store, overlays, anchor: frame)
}
}
.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
}
}
}
2026-10-16 23:30:57 -07:00
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)
2026-10-16 23:30:57 -07:00
}
}
} 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)
2026-10-16 23:30:57 -07:00
}
.menuStyle(.button)
.menuIndicator(.hidden)
2026-10-16 23:30:57 -07:00
.buttonStyle(.plain)
.fixedSize()
2026-10-16 23:30:57 -07:00
.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)")
2026-10-16 23:30:57 -07:00
.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
}
}
}
2026-10-16 23:30:57 -07:00
}
private struct ScrollMetrics: Equatable {
let content: CGFloat
let visible: CGFloat
let distance: CGFloat
2026-10-17 13:14:35 -07:00
let offset: CGFloat
2026-10-16 23:30:57 -07:00
}
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
2026-10-16 23:30:57 -07:00
var body: some View {
let running = model.running
ScrollView {
2026-10-17 13:14:35 -07:00
VStack(alignment: .leading, spacing: 12) {
2026-10-16 23:30:57 -07:00
if model.key == nil {
Muted(text: model.empty)
} else {
if model.loading {
Muted(text: "loading…")
}
2026-10-17 13:14:35 -07:00
if model.hasOlder || model.older != nil {
2026-10-16 23:30:57 -07:00
Muted(text: "loading earlier…")
2026-10-17 13:14:35 -07:00
.opacity(model.fetching || model.older != nil ? 1 : 0)
2026-10-16 23:30:57 -07:00
}
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)
2026-10-16 23:30:57 -07:00
}
}
}
.scrollTargetLayout()
2026-10-17 13:14:35 -07:00
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
Scanner(color: accent)
.frame(width: 84, height: 2)
.offset(y: 8)
}
2026-10-17 11:34:53 -07:00
}
2026-10-17 13:14:35 -07:00
.padding(16)
2026-10-17 11:34:53 -07:00
}
2026-10-16 23:30:57 -07:00
.scrollPosition($position)
.defaultScrollAnchor(.bottom)
.onScrollGeometryChange(for: ScrollMetrics.self) { g in
2026-10-17 13:14:35 -07:00
ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY, offset: g.contentOffset.y)
2026-10-16 23:30:57 -07:00
} action: { old, new in
2026-10-17 13:14:35 -07:00
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
2026-10-16 23:30:57 -07:00
return
}
2026-10-17 11:34:53 -07:00
if model.stuck && new.distance > 1 && (new.content != old.content || new.visible != old.visible) {
position.scrollTo(edge: .bottom)
}
}
2026-10-17 11:34:53 -07:00
.onScrollPhaseChange { _, phase in
scrolling = phase.isScrolling
2026-10-17 13:14:35 -07:00
if !scrolling {
model.prependOlder()
}
}
.onChange(of: model.older == nil) {
if !scrolling {
model.prependOlder()
}
2026-10-16 23:30:57 -07:00
}
2026-10-17 10:54:06 -07:00
.onChange(of: model.builder.pending.last?.id) { old, new in
guard let new, new > old ?? 0 else {
return
}
2026-10-16 23:30:57 -07:00
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)
}
}
}
2026-10-17 13:14:35 -07:00
private struct Scanner: NSViewRepresentable {
2026-10-17 11:34:53 -07:00
let color: Color
2026-10-17 13:14:35 -07:00
func makeNSView(context: Context) -> ScannerView {
ScannerView()
2026-10-17 11:34:53 -07:00
}
2026-10-17 13:14:35 -07:00
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()
}
}
2026-10-16 23:30:57 -07:00
2026-10-17 13:14:35 -07:00
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")
2026-10-16 23:30:57 -07:00
}
}
private struct PendingView: View {
let pending: PendingSend
let model: ChatModel
@Environment(Overlays.self) private var overlays
@ViewState private var withdrawing = false
2026-10-16 23:30:57 -07:00
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")
}
}
2026-10-16 23:30:57 -07:00
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)
}
2026-10-16 23:30:57 -07:00
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
2026-10-17 10:54:06 -07:00
var status: (text: String, color: Color)? = nil
2026-10-16 23:30:57 -07:00
@ViewBuilder let summary: Summary
@ViewBuilder let content: Content
@Environment(\.paneAccent) private var accent
@ViewState private var open = false
2026-10-17 08:57:26 -07:00
@ViewState private var shown = false
2026-10-16 23:30:57 -07:00
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)
2026-10-17 10:54:06 -07:00
if let status {
Text(status.text)
.font(Fonts.font(12))
.foregroundStyle(status.color)
.padding(.leading, 12)
}
2026-10-16 23:30:57 -07:00
}
.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 {
2026-10-17 08:57:26 -07:00
RoundedRectangle(cornerRadius: 8).stroke(working && shown ? accent : Palette.border)
.animation(.easeInOut(duration: 0.6), value: working && shown)
2026-10-16 23:30:57 -07:00
}
}
2026-10-17 08:57:26 -07:00
.onAppear {
shown = true
}
2026-10-16 23:30:57 -07:00
}
}
struct WhenText: View {
2026-10-16 23:30:57 -07:00
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) {
2026-10-17 10:54:06 -07:00
Text(styled([("byre", 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
.fixedSize(horizontal: false, vertical: true)
2026-10-16 23:30:57 -07:00
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):
2026-10-17 15:01:43 -07:00
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)
2026-10-16 23:30:57 -07:00
}
case .tool(let name, let brief, let input):
2026-10-17 08:57:26 -07:00
DetailsBox(nested: nested, working: model.working(row)) {
2026-10-16 23:30:57 -07:00
SubagentTag(show: row.tag)
Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
} content: {
VStack(alignment: .leading, spacing: 6) {
PreBox(text: input)
2026-10-18 08:50:34 -07:00
ToolResult(result: row.result, unfinished: row.unfinished, started: row.started)
2026-10-16 23:30:57 -07:00
}
.padding(.vertical, 6)
}
case .subagent(let title, let description, let prompt):
2026-10-17 10:54:06 -07:00
DetailsBox(nested: nested, working: model.working(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
2026-10-16 23:30:57 -07:00
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)
}
2026-10-18 08:50:34 -07:00
ToolResult(result: row.result, unfinished: row.unfinished, started: row.started)
2026-10-16 23:30:57 -07:00
}
}
case .group:
2026-10-17 08:57:26 -07:00
DetailsBox(nested: nested, working: model.working(row)) {
2026-10-16 23:30:57 -07:00
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 .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)
}
2026-10-16 23:30:57 -07:00
}
}
}
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.surface, 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)
}
2026-10-18 20:55:27 -07:00
.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
}
}
2026-10-16 23:30:57 -07:00
private struct ToolResult: View {
let result: String?
let unfinished: Bool
2026-10-18 08:50:34 -07:00
let started: Date?
2026-10-16 23:30:57 -07:00
var body: some View {
if let result {
PreBox(text: result)
2026-10-18 08:50:34 -07:00
} else if unfinished || started == nil {
label(unfinished ? "no result" : "running…")
2026-10-16 23:30:57 -07:00
} else {
2026-10-18 08:50:34 -07:00
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"
2026-10-16 23:30:57 -07:00
}
2026-10-18 08:50:34 -07:00
return "\(s / 60)m \(s % 60 < 10 ? "0" : "")\(s % 60)s"
2026-10-16 23:30:57 -07:00
}
}
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
2026-10-16 23:30:57 -07:00
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 {
2026-10-17 10:54:06 -07:00
let time = clockText(snap.createdAt)
2026-10-16 23:30:57 -07:00
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
}
2026-10-16 23:30:57 -07:00
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: {
2026-10-17 10:54:06 -07:00
Text("review")
.font(Fonts.font(11, weight: n > 0 ? 700 : 400))
.overlay(alignment: .topTrailing) {
if let review {
switch review.state {
2026-10-17 23:21:40 -07:00
case .running, .failed:
2026-10-17 10:54:06 -07:00
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)
}
2026-10-16 23:30:57 -07:00
}
}
}
}
2026-10-18 10:45:13 -07:00
.buttonStyle(SmallLinkButtonStyle(color: n > 0 ? Palette.warn : Palette.accent, fill: review?.state == .running ? Palette.accentTint : .clear))
2026-10-17 23:21:40 -07:00
.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")
2026-10-16 23:30:57 -07:00
}
}