965 lines
32 KiB
Swift
965 lines
32 KiB
Swift
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
|
||
@ObservationIgnored var stuck = true
|
||
@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
|
||
}
|
||
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
|
||
builder.stop()
|
||
store.drafts?.flush()
|
||
}
|
||
|
||
private func appended(_ events: [ByreEvent]) {
|
||
_ = builder.append(events)
|
||
jump()
|
||
}
|
||
|
||
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)
|
||
header {
|
||
CompactButton(sessionId: sess?.id, state: sess?.state, usage: sess.flatMap { model.builder.context[$0.id] })
|
||
SyncButton(task: task, busy: sess?.state == .running)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func header<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||
HStack(spacing: 8) {
|
||
content()
|
||
Spacer(minLength: 0)
|
||
}
|
||
.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
|
||
}
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|