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

1060 lines
36 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import 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<Void, Never>?
@ObservationIgnored private var offs: [() -> Void] = []
@ObservationIgnored private var focusStart = true
@ObservationIgnored private var visibleSummaries: Set<Int64> = []
@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 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 takeFocusStart() -> Bool {
let take = 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<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
HStack(spacing: 8) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
private struct 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)
.onGeometryChange(for: CGRect.self) { g in
g.frame(in: .named("window"))
} action: { frame in
overlays.mergeFrame = frame
}
.onDisappear {
overlays.mergeFrame = nil
}
}
}
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
@ViewState private var phase = false
var body: some View {
Capsule()
.fill(LinearGradient(colors: [.clear, color, .clear], startPoint: .leading, endPoint: .trailing))
.frame(width: 30, height: 2)
.offset(x: phase ? 54 : 0)
.frame(width: 84, height: 2, alignment: .leading)
.onAppear {
withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) {
phase = true
}
}
}
}
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<Summary: View, Content: View>: 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
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 ? accent : Palette.border)
}
}
}
}
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.running && row.pending) {
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.running && row.pending) {
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.running && row.pending) {
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")
}
}