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? @ObservationIgnored private var offs: [() -> Void] = [] @ObservationIgnored private var focusStart = true @ObservationIgnored private var visibleSummaries: Set = [] @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(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: 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)") } }