import AppKit import SwiftUI enum ChatKind: Equatable { case lead case task(String?) } @MainActor @Observable final class ChatModel { let kind: ChatKind let store: Store let builder: TranscriptBuilder private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)? private(set) var fetching = false private(set) var olderGeneration = 0 private(set) var hasUnmerged = false @ObservationIgnored var stuck = true @ObservationIgnored private var unmergedTimer: Task? @ObservationIgnored private var offs: [() -> Void] = [] @ObservationIgnored private var focusStart = true @ObservationIgnored private var visibleSummaries: Set = [] @ObservationIgnored private var activeObserver: (any NSObjectProtocol)? @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 } 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 placeholder: String { kind == .lead ? "Message your lead. Paste or drop images to attach them." : "Message the agent. Paste or drop images to attach them." } var empty: String { kind == .lead ? "" : "No task selected. Ask your lead, or pick one with ⌘K." } var running: Bool { builder.open && session?.state == .running } var subagents: Bool { (session?.subagents ?? 0) > 0 } func working(_ row: Row) -> Bool { row.pending && (running || subagents && row.inActive) || row.active && (running || subagents) } var hasOlder: Bool { kind == .lead && store.leadHasOlder() } func start() { if started { return } started = true guard let feed else { return } Task { await loadUnmerged() } appended(feed.events) offs.append(feed.appended.add { [weak self] events in self?.appended(events) }) offs.append(feed.streamed.add { [weak self] ev in self?.builder.appendDelta(ev) }) activeObserver = NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.markShown() } } } func stop() { for off in offs { off() } offs = [] if let activeObserver { NotificationCenter.default.removeObserver(activeObserver) } activeObserver = nil unmergedTimer?.cancel() unmergedTimer = nil builder.stop() store.drafts?.flush() } private func appended(_ events: [ByreEvent]) { 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, NSApp.isActive else { return } for seq in visibleSummaries { store.markSummaryRead(taskId, seq) } } func jump() { guard let target = store.jump, target.taskId == taskId else { return } let rows = builder.root.rows guard let i = rows.firstIndex(where: { $0.summarySeq == target.seq }) else { return } store.jump = nil stuck = false if i > 0, case .agent = rows[i - 1].kind { requestScroll(rows[i - 1].id, .top) } else { requestScroll(rows[i].id, .bottom) } } private func requestScroll(_ id: Int, _ anchor: UnitPoint) { scrollRequest = ((scrollRequest?.seq ?? 0) + 1, id, anchor) } func loadOlder() { if fetching || !hasOlder { return } fetching = true Task { do { let events = try await store.loadOlderLead() let first = builder.root.rows.first?.id builder.prepend(events) if let first { requestScroll(first, .top) } } catch { log.error("load earlier events: \(errorText(error), privacy: .public)") } fetching = false olderGeneration += 1 } } 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 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 interrupt(_ sessionId: String, _ overlays: Overlays) { Task { do { _ = try await store.api.client.interruptSession(path: .init(sessionId: sessionId)).accepted } catch { overlays.showAlert(errorText(error)) } } } } struct ChatView: View { let kind: ChatKind @Environment(Store.self) private var store @ViewState private var model: ChatModel? var body: some View { VStack(spacing: 0) { if let model { ChatHead(model: model) TranscriptScroll(model: model) if let key = model.key { ComposerView(chat: model, key: key) } } else { Spacer() } } .onAppear { let m = model ?? ChatModel(kind, store) model = m m.start() } .onDisappear { model?.stop() } .onChange(of: store.jump?.seq) { model?.jump() } } } private struct ChatHead: View { let model: ChatModel @Environment(Store.self) private var store var body: some View { switch model.kind { case .lead: let live = model.liveSession header { CompactButton(sessionId: live?.id, state: live?.state, usage: live.flatMap { model.builder.context[$0.id] }) } case .task(let id): if let id, let task = store.findTask(id) { if task.status != .active { header { Text(task.status.rawValue) } } else { let sess = store.currentSession(task.id) let busy = sess?.state == .running header { 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(@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 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 } } } 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 Button { guard let sessionId else { return } sending = true Task { do { _ = try await store.api.client.compactSession(path: .init(sessionId: sessionId)).accepted } catch { overlays.showAlert(errorText(error)) sending = false } } } 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()) } .buttonStyle(.plain) .disabled(disabled) .help("Ask the agent for a handoff, then continue in a new session with a fresh context\(detail)") .onChange(of: state) { sending = false } } } private struct ScrollMetrics: Equatable { let content: CGFloat let visible: CGFloat let distance: 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) var body: some View { let running = model.running ScrollView { LazyVStack(alignment: .leading, spacing: 12) { if model.key == nil { Muted(text: model.empty) } else { if model.loading { Muted(text: "loading…") } if model.fetching { Muted(text: "loading earlier…") } if model.hasOlder && !model.fetching { Color.clear .frame(height: 1) .id("older-\(model.olderGeneration)") .onAppear { model.loadOlder() } } ForEach(model.builder.root.rows) { row in RowView(row: row, model: model, nested: false) } ForEach(model.builder.pending) { p in PendingView(pending: p) } if running { Scanner(color: accent) } } } .scrollTargetLayout() .padding(16) } .scrollPosition($position) .defaultScrollAnchor(.bottom) .onScrollGeometryChange(for: ScrollMetrics.self) { g in ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY) } action: { old, new in if new.content != old.content || new.visible != old.visible { if model.stuck && new.distance > 1 { position.scrollTo(edge: .bottom) } return } model.stuck = new.distance < 80 } .onChange(of: model.builder.pending.count) { model.stuck = true position.scrollTo(edge: .bottom) } .onChange(of: model.scrollRequest?.seq, initial: true) { if let r = model.scrollRequest { position.scrollTo(id: r.id, anchor: r.anchor) } } .overlay { Rectangle().stroke(running ? accent : .clear, lineWidth: 1) .animation(.easeInOut(duration: 0.3), value: running) } } } private struct Scanner: View { let color: Color var body: some View { TimelineView(.animation) { context in Canvas { gc, _ in let t = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.4) / 1.2 let leg = t < 1 ? t : 2 - t let center = 15 + 54 * (1 - cos(.pi * leg)) / 2 for x in stride(from: 0.0, through: 80, by: 5) { let alpha = max(0, 1 - abs(x + 1 - center) / 15) if alpha > 0 { gc.fill(Path(CGRect(x: x, y: 0, width: 2, height: 2)), with: .color(color.opacity(alpha))) } } } } .frame(width: 84, height: 2) } } private struct PendingView: View { let pending: PendingSend var body: some View { TrailingBubble { VStack(alignment: .leading, spacing: 2) { Text(pending.queued ? "queued…" : "sending…") .font(Fonts.font(11, italic: true)) .foregroundStyle(Palette.muted) 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.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 @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 if working { Spinner(color: accent) .padding(.leading, 4) } Spacer(minLength: 0) } .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 } } } private 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("byre").font(Fonts.font(12, weight: 600)) Text(brief).font(Fonts.font(12)).foregroundStyle(Palette.muted).lineLimit(1) 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): DetailsBox(nested: nested) { SubagentTag(show: row.tag) Text("thinking").foregroundStyle(Palette.muted) } content: { MarkdownView(blocks: blocks, ctx: MDContext(color: Palette.muted, taskId: row.taskId)) .padding(.vertical, 6) } 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) } .padding(.vertical, 6) } case .subagent(let title, let description, let prompt): DetailsBox(nested: nested, working: model.working(row)) { SubagentTag(show: row.tag) Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)])) Spacer(minLength: 12) Text(row.status) .font(Fonts.font(12)) .foregroundStyle(row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent) } 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) } } 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 .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) } } } private struct ToolResult: View { let result: String? let unfinished: Bool var body: some View { if let result { PreBox(text: result) } else { Text(unfinished ? "no result" : "running…") .font(Fonts.font(12, italic: true)) .foregroundStyle(Palette.muted) .padding(.vertical, 4) } } } 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 } if order.isEmpty { return Text(styled([("thinking", 400, Palette.muted)])) } 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 : note.style == .ask ? Palette.accent : Palette.muted HStack(alignment: .firstTextBaseline, spacing: 12) { Text(text()) .font(Fonts.font(11)) .foregroundStyle(color) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) if let when = note.when { WhenText(when: when) .font(Fonts.font(11)) .foregroundStyle(color) } if let taskId = note.cancelMergeAfterReview, cancelVisible, store.findTask(taskId)?.mergeAfterReview == true { Button("Cancel") { cancelling = true Task { do { _ = try await store.api.client.cancelMergeAfterReview(path: .init(taskId: taskId)).ok } catch { overlays.showAlert(errorText(error)) } cancelling = false } } .buttonStyle(SmallLinkButtonStyle()) .disabled(cancelling) .help("Withdraw this request; nothing is merged") } } } private func text() -> AttributedString { var out = AttributedString() for part in note.parts { switch part { case .text(let s): out += AttributedString(s) case .task(let id): guard let task = store.findTask(id) else { out += AttributedString(id) continue } var a = AttributedString(task.title) if task.status == .active { a.link = AppLink.task(task.id) a.foregroundColor = taskPalette[task.color] } out += a case .url(let port): let url = previewURL(store.api.origin, Int(port) ?? 0) var a = AttributedString(url.absoluteString) a.link = url a.foregroundColor = Palette.accent out += a } } return out } } func previewURL(_ origin: URL, _ port: Int) -> URL { var c = URLComponents() c.scheme = "https" c.host = origin.host() c.port = port c.path = "/" return c.url! } private struct SnapshotRow: View { let snap: Snapshot let review: Review? let taskId: String? @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var forking = false var body: some View { let time = snap.createdAt.formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits)) 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") reviewButton Rectangle().fill(Palette.border).frame(height: 1) } .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .help("\(snap.trigger.rawValue.replacingOccurrences(of: "_", with: " ")) · \(time)") } private var reviewButton: some View { let n = review.map(findingCount) ?? 0 return Button { store.showReview(snap.n) } label: { HStack(spacing: 2) { Text("review") .font(Fonts.font(11, weight: n > 0 ? 700 : 400)) if let review { switch review.state { case .running: Spinner(color: Palette.accent).font(Fonts.font(10)).help("review in progress") case .failed: EmptyView() case .skipped: Text("●").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.muted).help("skipped: no changes") 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") } else { Badge(count: n).help("\(n) open findings") } } } } } .buttonStyle(SmallLinkButtonStyle(color: n > 0 ? Palette.warn : Palette.accent)) .help("Show the review of snapshot \(snap.n) in the Reviews tab") } }