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

1781 lines
62 KiB
Swift
Raw Normal View History

2026-10-16 23:30:57 -07:00
import AppKit
import ByreAPI
2026-10-16 23:30:57 -07:00
import SwiftUI
enum ChatKind: Equatable {
case lead
case task(String?)
}
struct AttentionGroup: Identifiable {
let id: String
let label: String
let help: String
let rows: [Int]
}
struct ChatStatus {
enum Tone {
case attention, working, merged, discarded
}
let label: String
let help: String
let tone: Tone
let group: AttentionGroup?
}
2026-10-16 23:30:57 -07:00
@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)?
2026-10-16 23:30:57 -07:00
private(set) var fetching = false
2026-10-17 13:14:35 -07:00
private(set) var older: [ByreEvent]?
private(set) var hasUnmerged = false
2026-10-16 23:30:57 -07:00
@ObservationIgnored var stuck = true
2026-10-17 13:14:35 -07:00
@ObservationIgnored var prepended = false
2026-10-19 10:00:37 -07:00
@ObservationIgnored var released: Int?
@ObservationIgnored private var unmergedTimer: Task<Void, Never>?
2026-10-16 23:30:57 -07:00
@ObservationIgnored private var offs: [() -> Void] = []
@ObservationIgnored private var focusStart = true
@ObservationIgnored private var visibleSummaries: Set<Int64> = []
2026-10-18 00:31:46 -07:00
@ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken?
@ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken?
2026-10-16 23:30:57 -07:00
@ObservationIgnored private var started = false
@ObservationIgnored private var cursors: [String: Int] = [:]
2026-10-16 23:30:57 -07:00
init(_ kind: ChatKind, _ store: Store) {
self.kind = kind
self.store = store
let taskId: String? = if case .task(let id) = kind { id } else { nil }
member = taskId.flatMap { store.pickedMember($0) }
2026-10-16 23:30:57 -07:00
let session: () -> AgentSession? = { [weak store] in
switch kind {
case .lead: store?.currentLead()
case .task(let id): id.flatMap { store?.currentSession($0) }
}
}
builder = TranscriptBuilder(taskId: taskId, local: store.sends(taskId ?? "lead"), worktree: { [weak store] in
2026-10-16 23:30:57 -07:00
taskId.flatMap { store?.findTask($0)?.worktreePath } ?? ""
}, running: {
session()?.state == .running
})
}
var key: String? {
switch kind {
case .lead: "lead"
case .task(let id): id
}
}
var taskId: String? {
if case .task(let id) = kind {
return id
}
return nil
}
private var feed: Feed? {
switch kind {
case .lead: store.leadFeed
case .task(let id): id != nil ? store.selectedFeed : nil
}
}
var session: AgentSession? {
switch kind {
case .lead: store.currentLead()
case .task(let id): id.flatMap { store.currentSession($0) }
}
}
var liveSession: AgentSession? {
session.flatMap { $0.state != .completed ? $0 : nil }
}
var loading: Bool {
switch kind {
case .lead: store.leadLoading()
case .task: store.transcriptLoading()
}
}
var team: Bool {
member != nil
}
2026-10-16 23:30:57 -07:00
var placeholder: String {
if team {
return "Post to the team's board; every agent of the team gets it."
}
return kind == .lead ? "Message your lead. Paste or drop images to attach them." : "Message the agent. Paste or drop images to attach them."
}
private func shows(_ ev: ByreEvent) -> Bool {
guard let member, let sid = ev.agentSessionId else {
return true
}
if ev.kind == "session.created" {
members[sid] = str(ev.payload.value, "member") ?? ""
}
let owner = members[sid] ?? store.sessions[taskId ?? ""]?.first { $0.id == sid }?.member ?? ""
return owner.isEmpty || owner == member
2026-10-16 23:30:57 -07:00
}
var empty: String {
kind == .lead ? "" : "No task selected. Ask your lead, or pick one with ⌘K."
}
var running: Bool {
builder.open && session?.state == .running
}
2026-10-19 19:05:19 -07:00
var reviewing: Bool {
let state = taskId.flatMap { store.findTask($0) }?.state
return state == .reviewing || state == .reviewPending || state == .mergeAfterReview
2026-10-19 19:05:19 -07:00
}
2026-10-17 08:57:26 -07:00
var subagents: Bool {
(session?.subagents ?? 0) > 0
}
func working(_ row: Row) -> Bool {
row.pending && (running || subagents && row.inActive || row.inReview) || row.holdsActive && (running || subagents)
2026-10-17 08:57:26 -07:00
}
2026-10-16 23:30:57 -07:00
var hasOlder: Bool {
kind == .lead && store.leadHasOlder()
}
func start() {
if started {
return
}
started = true
guard let feed else {
return
}
Task {
await loadUnmerged()
}
2026-10-16 23:30:57 -07:00
appended(feed.events)
offs.append(feed.appended.add { [weak self] events in
self?.appended(events)
})
offs.append(feed.streamed.add { [weak self] ev in
if self?.shows(ev) == true {
self?.builder.appendDelta(ev)
}
2026-10-16 23:30:57 -07:00
})
2026-10-18 00:31:46 -07:00
activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in
self?.markShown()
2026-10-16 23:30:57 -07:00
}
keyObserver = NotificationCenter.default.addObserver(of: NSWindow.self, for: .didBecomeKey) { [weak self] _ in
self?.markShown()
}
2026-10-16 23:30:57 -07:00
}
func stop() {
for off in offs {
off()
}
offs = []
if let activeObserver {
NotificationCenter.default.removeObserver(activeObserver)
}
activeObserver = nil
if let keyObserver {
NotificationCenter.default.removeObserver(keyObserver)
}
keyObserver = nil
unmergedTimer?.cancel()
unmergedTimer = nil
2026-10-16 23:30:57 -07:00
builder.stop()
store.drafts?.flush()
}
private func appended(_ all: [ByreEvent]) {
let events = all.filter(shows)
if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
unmergedTimer?.cancel()
unmergedTimer = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(1500))
if !Task.isCancelled {
await self?.loadUnmerged()
}
}
}
2026-10-16 23:30:57 -07:00
_ = builder.append(events)
jump()
}
private func loadUnmerged() async {
guard let taskId else {
return
}
do {
let u = try await store.api.client.getUnmerged(path: .init(taskId: taskId)).ok.body.json
hasUnmerged = u.commits > 0 || u.files > 0
} catch {
log.error("load unmerged changes: \(errorText(error), privacy: .public)")
}
}
2026-10-17 09:04:32 -07:00
func takeFocus() -> Bool {
let asked = taskId != nil && store.composerFocus == taskId
if asked {
store.composerFocus = nil
}
let take = asked || (focusStart && session == nil)
2026-10-16 23:30:57 -07:00
focusStart = false
return take
}
func summaryVisible(_ seq: Int64, _ visible: Bool) {
if visible {
visibleSummaries.insert(seq)
} else {
visibleSummaries.remove(seq)
}
markShown()
}
private func markShown() {
guard let taskId, store.paneFocus.windowKey else {
2026-10-16 23:30:57 -07:00
return
}
for seq in visibleSummaries {
store.markSummaryRead(taskId, seq)
}
}
func jump() {
guard let j = store.jump, j.taskId == taskId else {
2026-10-16 23:30:57 -07:00
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, taskId) else {
return
}
store.jump = nil
scrollTo(row.id)
2026-10-16 23:30:57 -07:00
}
}
private func reviewRow(_ n: Int, _ task: String?) -> Row? {
let rows = builder.root.rows.filter { $0.taskId == task }
return rows.first { $0.reviewOf == n } ?? rows.first { r in
if case .snapshot(let s) = r.kind {
return s.n == n
}
return false
}
}
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)
2026-10-16 23:30:57 -07:00
}
}
func attention() -> [AttentionGroup] {
guard let taskId else {
return []
}
let latest = latestReviewedSnapshot(store.transcript)
2026-10-19 12:00:05 -07:00
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 && row.taskId == taskId:
questions.append(row.id)
case .finding(let i):
if let r = row.review, r.snapshotN == latest, 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
}
func status() -> ChatStatus? {
guard let taskId, let task = store.findTask(taskId) else {
return nil
}
let end = "; click to scroll to the end"
switch task.status {
case .merged:
let commit = task.mergeCommit ?? ""
return ChatStatus(label: "Merged\(commit.isEmpty ? "" : " as \(commit.prefix(7))")", help: "The task was merged\(end)", tone: .merged, group: nil)
case .discarded:
return ChatStatus(label: "Discarded", help: "The task was discarded\(end)", tone: .discarded, group: nil)
case .active:
break
}
let groups = attention()
let flagged = { (g: AttentionGroup) in
ChatStatus(label: g.label, help: "\(g.help); click again for the next", tone: .attention, group: g)
}
if let q = groups.first(where: { $0.id == "questions" }) {
return flagged(q)
}
if let busy = store.progress(task) {
return ChatStatus(label: busy.label, help: "\(busy.title)\(end)", tone: .working, group: nil)
}
return groups.first.map(flagged)
}
var composerHiddenByDefault: Bool {
guard let taskId, let task = store.findTask(taskId) else {
return false
}
return task.status != .active || store.inbox.contains(where: { $0.taskId == taskId }) || attention().contains { $0.id == "ready" }
}
func showStatus(_ s: ChatStatus) {
if let g = s.group {
cycle(g)
return
}
release()
stuck = true
requestScroll(nil, .bottom)
}
2026-10-19 12:22:39 -07:00
var attentionRows: [Int] {
2026-10-19 10:00:37 -07:00
let ids = Set(attention().flatMap(\.rows))
2026-10-19 12:22:39 -07:00
return builder.root.rows.filter { ids.contains($0.id) }.map(\.id)
}
var held: Int? {
guard let first = attentionRows.first, first != released else {
2026-10-19 10:00:37 -07:00
return nil
}
return first
}
2026-10-19 12:22:39 -07:00
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)
}
2026-10-19 10:00:37 -07:00
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) {
2026-10-16 23:30:57 -07:00
scrollRequest = ((scrollRequest?.seq ?? 0) + 1, id, anchor)
}
func loadOlder() {
2026-10-17 13:14:35 -07:00
if fetching || older != nil || !hasOlder {
2026-10-16 23:30:57 -07:00
return
}
fetching = true
Task {
do {
2026-10-17 13:14:35 -07:00
older = try await store.loadOlderLead()
2026-10-16 23:30:57 -07:00
} catch {
log.error("load earlier events: \(errorText(error), privacy: .public)")
}
fetching = false
2026-10-17 13:14:35 -07:00
}
}
func prependOlder() {
guard let events = older else {
return
}
older = nil
let n = builder.root.rows.count
builder.prepend(events)
prepended = builder.root.rows.count > n || !hasOlder
if !prepended {
loadOlder()
2026-10-16 23:30:57 -07:00
}
}
func send(sessionId: String?, text: String, images: [String]?, model: String?, firstModel: String, sendID: String) async throws {
2026-10-16 23:30:57 -07:00
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, sendId: sendID))).accepted
2026-10-16 23:30:57 -07:00
case .task(let id):
if team {
if images != nil {
throw ApiError(status: 0, message: "The board takes text only; drop the images to post.")
}
_ = try await api.postToBoard(path: .init(taskId: id ?? ""), body: .json(.init(text: text))).noContent
return
}
2026-10-16 23:30:57 -07:00
if let sessionId {
_ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model, sendId: sendID))).accepted
2026-10-16 23:30:57 -07:00
return
}
_ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, images: images, sendId: sendID))).created
2026-10-16 23:30:57 -07:00
}
}
func withdraw(_ p: PendingSend, _ overlays: Overlays) async {
guard let taskId, let inputId = p.inputId else {
return
}
let key = "message:\(taskId)"
do {
var imgs: [PendingImage] = []
for id in p.imageIds {
let data = try await ImageCache.fetch(.blob(id), store.api)
guard let image = NSImage(data: data) else {
throw ApiError(status: 0, message: "not an image")
}
imgs.append(PendingImage(data: data, image: image))
}
_ = try await store.api.client.withdrawMessage(path: .init(taskId: taskId, inputId: Int64(inputId))).noContent
builder.withdrawn(inputId)
store.drafts?.restoreText(key, p.text)
store.images.restore(key, imgs)
} catch {
overlays.showAlert(errorText(error))
}
}
2026-10-16 23:30:57 -07:00
func interrupt(_ sessionId: String, _ overlays: Overlays) {
Task {
do {
_ = try await store.api.client.interruptSession(path: .init(sessionId: sessionId)).accepted
} catch {
overlays.showAlert(errorText(error))
}
}
}
}
struct ChatView: View {
let kind: ChatKind
@Environment(Store.self) private var store
@ViewState private var model: ChatModel?
var body: some View {
VStack(spacing: 0) {
if let model {
ChatHead(model: model)
TranscriptScroll(model: model)
if let key = model.key {
ComposerView(chat: model, key: key)
}
} else {
Spacer()
}
}
.onAppear {
let m = model ?? ChatModel(kind, store)
model = m
m.start()
}
.onDisappear {
model?.stop()
}
.onChange(of: store.jump) {
2026-10-16 23:30:57 -07:00
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), task.status == .active {
let sess = store.currentSession(task.id)
let busy = store.taskBusy(task.id)
header {
if model.team {
MemberPicker(taskId: task.id, picked: model.member)
2026-10-16 23:30:57 -07:00
}
CompactButton(sessionId: sess?.id, state: sess?.state, usage: sess.flatMap { model.builder.context[$0.id] })
SyncButton(task: task, busy: busy)
Spacer(minLength: 12)
MergeButton(task: task, busy: busy, unmerged: model.hasUnmerged)
DiscardButton(task: task, busy: busy)
2026-10-16 23:30:57 -07:00
}
}
}
}
private func header<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
2026-10-16 23:30:57 -07:00
HStack(spacing: 8) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
2026-10-16 23:30:57 -07:00
.padding(.horizontal, 16)
.padding(.vertical, 8)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
private struct MemberPicker: View {
let taskId: String
let picked: String?
@Environment(Store.self) private var store
var body: some View {
HStack(spacing: 4) {
ForEach(store.team(taskId), id: \.id) { s in
PaneTabButton(label: s.state == .running ? "\(s.member) ●" : s.member, active: s.member == picked) {
store.pickedMembers[taskId] = s.member
}
.help("Show \(s.member)'s transcript (\(s.model.isEmpty ? s.vendor.rawValue : s.model), \(s.state.rawValue))")
}
}
}
}
2026-10-16 23:30:57 -07:00
private struct SyncButton: View {
let task: ByreTask
let busy: Bool
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var sending = false
var body: some View {
let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch"
Button("Sync from \(target)") {
sending = true
Task {
do {
_ = try await store.api.client.mergeIn(path: .init(taskId: task.id)).accepted
} catch {
overlays.showAlert(errorText(error))
sending = false
}
}
}
.buttonStyle(WebButtonStyle())
.disabled(busy || sending)
.help("Ask the agent to bring \(target) into this branch and resolve any conflicts")
.onChange(of: busy) {
sending = false
}
}
}
private struct MergeButton: View {
let task: ByreTask
let busy: Bool
let unmerged: Bool
var small = false
var keepOpen: Bool?
var highlight = 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
if keepOpen == true {
Button("Merge & continue") {
Task {
await confirmMerge(task.id, store, overlays, keepOpen: true)
}
}
.buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border))
.disabled(busy || held || intoParent || !unmerged)
.help("The agent merges \(target) in, then squashes the work so far into one commit on \(target) and pushes it, keeping the task open; the task's other agents wait until it's done")
} else {
button(target, held, intoParent)
}
}
@ViewBuilder
private func button(_ target: String, _ held: Bool, _ intoParent: Bool) -> some View {
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" : keepOpen == false ? "Merge & complete" : "Merge") {
Task {
await confirmMerge(task.id, store, overlays)
}
}
Group {
if small {
button.buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border))
} 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
var body: some View {
let button = Button("Discard") {
Task {
2026-10-20 22:40:48 -07:00
await confirmDiscard(task, store, overlays)
}
}
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")
}
}
2026-10-16 23:30:57 -07:00
struct CompactButton: View {
let sessionId: String?
let state: AgentSession.StatePayload?
let usage: Usage?
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.isEnabled) private var enabled
@ViewState private var sending = false
var body: some View {
let pct = usage.map { $0.window > 0 ? min(100, Double($0.used) / Double($0.window) * 100) : 0 } ?? 0
let level = pct >= 90 ? Palette.danger : pct >= 70 ? Palette.warn : Palette.accent
let detail = usage.map { u in u.window > 0 ? "\ncontext: \(u.used.formatted()) of \(u.window.formatted()) tokens\(u.model.isEmpty ? "" : " (\(u.model))")" : "" } ?? ""
let disabled = sessionId == nil || state != .idle || sending
Menu {
ForEach(models, id: \.self) { m in
Button("Continue on \(m)") {
compact(m)
2026-10-16 23:30:57 -07:00
}
}
} label: {
Text("Compact")
.foregroundStyle(Palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background {
GeometryReader { g in
HStack(spacing: 0) {
ZStack {
Palette.raised
level.opacity(0.25)
}
.frame(width: g.size.width * pct / 100)
Palette.raised
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(pct >= 70 ? level : Palette.border))
.opacity(disabled ? 0.45 : 1)
.contentShape(Rectangle())
} primaryAction: {
compact(nil)
2026-10-16 23:30:57 -07:00
}
.menuStyle(.button)
.menuIndicator(.hidden)
2026-10-16 23:30:57 -07:00
.buttonStyle(.plain)
.fixedSize()
2026-10-16 23:30:57 -07:00
.disabled(disabled)
.help("Ask the agent for a handoff, then continue in a new session with a fresh context; hold to continue on another model\(detail)")
2026-10-16 23:30:57 -07:00
.onChange(of: state) {
sending = false
}
}
private func compact(_ model: String?) {
guard let sessionId else {
return
}
sending = true
Task {
do {
_ = try await store.api.client.compactSession(path: .init(sessionId: sessionId), body: .json(.init(model: model))).accepted
} catch {
overlays.showAlert(errorText(error))
sending = false
}
}
}
2026-10-16 23:30:57 -07:00
}
private struct ScrollMetrics: Equatable {
let content: CGFloat
let visible: CGFloat
let distance: CGFloat
2026-10-17 13:14:35 -07:00
let offset: CGFloat
2026-10-16 23:30:57 -07:00
}
private struct TranscriptScroll: View {
let model: ChatModel
@Environment(Store.self) private var store
@Environment(\.paneAccent) private var accent
@ViewState private var position = ScrollPosition(edge: .bottom)
@ViewState private var scrolling = false
2026-10-16 23:30:57 -07:00
var body: some View {
let running = model.running
2026-10-19 19:05:19 -07:00
let active = running || model.reviewing
2026-10-16 23:30:57 -07:00
ScrollView {
2026-10-19 16:57:16 -07:00
VStack(alignment: .leading, spacing: 0) {
2026-10-16 23:30:57 -07:00
if model.key == nil {
Muted(text: model.empty)
2026-10-19 16:57:16 -07:00
.padding(.top, 12)
2026-10-16 23:30:57 -07:00
} else {
if model.loading {
Muted(text: "loading…")
2026-10-19 16:57:16 -07:00
.padding(.top, 12)
2026-10-16 23:30:57 -07:00
}
2026-10-17 13:14:35 -07:00
if model.hasOlder || model.older != nil {
2026-10-16 23:30:57 -07:00
Muted(text: "loading earlier…")
2026-10-17 13:14:35 -07:00
.opacity(model.fetching || model.older != nil ? 1 : 0)
2026-10-19 16:57:16 -07:00
.padding(.top, 12)
2026-10-16 23:30:57 -07:00
}
2026-10-19 16:57:16 -07:00
ForEach(model.builder.root.rows.filter { !$0.hidden }) { row in
2026-10-16 23:30:57 -07:00
RowView(row: row, model: model, nested: false)
2026-10-19 16:57:16 -07:00
.padding(.top, 12)
2026-10-16 23:30:57 -07:00
}
ForEach(model.builder.pending) { p in
PendingView(pending: p, model: model)
2026-10-19 16:57:16 -07:00
.padding(.top, 12)
2026-10-16 23:30:57 -07:00
}
}
}
.scrollTargetLayout()
2026-10-17 13:14:35 -07:00
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
Scanner(color: accent)
.frame(width: 84, height: 2)
.offset(y: 8)
}
2026-10-17 11:34:53 -07:00
}
2026-10-19 16:57:16 -07:00
.padding([.horizontal, .bottom], 16)
.padding(.top, 4)
2026-10-17 11:34:53 -07:00
}
2026-10-16 23:30:57 -07:00
.scrollPosition($position)
.defaultScrollAnchor(.bottom)
.onScrollGeometryChange(for: ScrollMetrics.self) { g in
2026-10-17 13:14:35 -07:00
ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY, offset: g.contentOffset.y)
2026-10-16 23:30:57 -07:00
} action: { old, new in
2026-10-17 13:14:35 -07:00
if model.prepended && new.content != old.content {
model.prepended = false
position.scrollTo(y: new.offset + new.content - old.content)
return
}
if new.offset < new.visible {
model.loadOlder()
}
if scrolling {
model.stuck = new.distance < 80
2026-10-16 23:30:57 -07:00
return
}
2026-10-19 10:00:37 -07:00
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)
}
}
2026-10-17 11:34:53 -07:00
.onScrollPhaseChange { _, phase in
scrolling = phase.isScrolling
2026-10-19 10:00:37 -07:00
if phase == .interacting {
model.release()
}
2026-10-17 13:14:35 -07:00
if !scrolling {
model.prependOlder()
}
}
2026-10-19 16:57:16 -07:00
.onChange(of: model.held, initial: true) { _, id in
2026-10-19 10:00:37 -07:00
if !model.stuck || scrolling {
return
}
if let id {
position.scrollTo(id: id, anchor: .top)
} else {
position.scrollTo(edge: .bottom)
}
}
2026-10-19 12:22:39 -07:00
.onChange(of: model.attentionRows) { old, new in
model.closed(old, new)
}
2026-10-17 13:14:35 -07:00
.onChange(of: model.older == nil) {
if !scrolling {
model.prependOlder()
}
2026-10-16 23:30:57 -07:00
}
2026-10-17 10:54:06 -07:00
.onChange(of: model.builder.pending.last?.id) { old, new in
guard let new, new > old ?? 0 else {
return
}
2026-10-19 10:00:37 -07:00
model.release()
2026-10-16 23:30:57 -07:00
model.stuck = true
position.scrollTo(edge: .bottom)
}
.onChange(of: model.scrollRequest?.seq, initial: true) {
guard let r = model.scrollRequest else {
return
}
if let id = r.id {
position.scrollTo(id: id, anchor: r.anchor)
} else {
position.scrollTo(edge: .bottom)
2026-10-16 23:30:57 -07:00
}
}
.overlay {
2026-10-19 19:05:19 -07:00
Rectangle().stroke(active ? accent : .clear, lineWidth: 1)
.animation(.easeInOut(duration: 0.3), value: active)
2026-10-16 23:30:57 -07:00
}
}
}
struct StatusButton: View {
let status: ChatStatus
let action: () -> Void
@ViewState private var hover = false
var body: some View {
let (color, tint): (Color, Color) = switch status.tone {
case .attention: (Palette.warn, Palette.warnTint)
case .working: (Palette.accent, Palette.accentTint)
case .merged: (Palette.ok, Palette.okTint)
case .discarded: (Palette.muted, Palette.raised)
}
Button(action: action) {
Text(status.label)
.font(Fonts.font(11, weight: 700))
.foregroundStyle(color)
.lineLimit(1)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(tint, in: Capsule())
.overlay(Capsule().stroke(hover ? Palette.accent : color.opacity(0.55)))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.fixedSize()
.help(status.help)
.onHover { hover = $0 }
2026-10-16 23:30:57 -07:00
}
}
2026-10-17 13:14:35 -07:00
private struct Scanner: NSViewRepresentable {
2026-10-17 11:34:53 -07:00
let color: Color
2026-10-17 13:14:35 -07:00
func makeNSView(context: Context) -> ScannerView {
ScannerView()
2026-10-17 11:34:53 -07:00
}
2026-10-17 13:14:35 -07:00
func updateNSView(_ v: ScannerView, context: Context) {
let c = NSColor(color)
CATransaction.begin()
CATransaction.setDisableActions(true)
v.glow.colors = [c.withAlphaComponent(0).cgColor, c.cgColor, c.withAlphaComponent(0).cgColor]
CATransaction.commit()
}
}
2026-10-16 23:30:57 -07:00
2026-10-17 13:14:35 -07:00
private final class ScannerView: NSView {
let glow = CAGradientLayer()
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
let dots = CALayer()
dots.frame = CGRect(x: 0, y: 0, width: 84, height: 2)
for x in stride(from: 0.0, through: 80, by: 5) {
let dot = CALayer()
dot.frame = CGRect(x: x, y: 0, width: 2, height: 2)
dot.backgroundColor = NSColor.black.cgColor
dots.addSublayer(dot)
}
glow.bounds = CGRect(x: 0, y: 0, width: 30, height: 2)
glow.position = CGPoint(x: 15, y: 1)
glow.startPoint = CGPoint(x: 0, y: 0.5)
glow.endPoint = CGPoint(x: 1, y: 0.5)
let strip = CALayer()
strip.frame = dots.frame
strip.mask = dots
strip.addSublayer(glow)
layer?.addSublayer(strip)
let sweep = CABasicAnimation(keyPath: "position.x")
sweep.fromValue = 15
sweep.toValue = 69
sweep.duration = 1.2
sweep.autoreverses = true
sweep.repeatCount = .infinity
sweep.timingFunction = CAMediaTimingFunction(controlPoints: 0.37, 0, 0.63, 1)
sweep.isRemovedOnCompletion = false
glow.add(sweep, forKey: "sweep")
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
2026-10-16 23:30:57 -07:00
}
}
private struct PendingView: View {
let pending: PendingSend
let model: ChatModel
@Environment(Overlays.self) private var overlays
@ViewState private var withdrawing = false
2026-10-16 23:30:57 -07:00
var body: some View {
TrailingBubble {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Text(pending.queued ? "queued…" : "sending…")
.font(Fonts.font(11, italic: true))
.foregroundStyle(Palette.muted)
if pending.inputId != nil {
Button("×") {
withdrawing = true
Task {
await model.withdraw(pending, overlays)
withdrawing = false
}
}
.buttonStyle(ThumbRemoveStyle())
.disabled(withdrawing)
.help("Take this message back into the composer")
}
}
2026-10-16 23:30:57 -07:00
if !pending.images.isEmpty {
FlowLayout(spacing: 6) {
ForEach(pending.images) { img in
Image(nsImage: img.image)
.resizable()
.scaledToFit()
.frame(maxWidth: 240, maxHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
}
}
.padding(.vertical, 4)
}
if !pending.imageIds.isEmpty {
MessageImages(ids: pending.imageIds)
}
2026-10-16 23:30:57 -07:00
if !pending.md.isEmpty {
MarkdownView(blocks: pending.md)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Palette.accentTint, in: RoundedRectangle(cornerRadius: 10))
}
}
}
struct DetailsBox<Summary: View, Content: View>: View {
var boxed = true
var nested = false
var working = false
2026-10-17 10:54:06 -07:00
var status: (text: String, color: Color)? = nil
2026-10-16 23:30:57 -07:00
@ViewBuilder let summary: Summary
@ViewBuilder let content: Content
@Environment(\.paneAccent) private var accent
@ViewState private var open = false
2026-10-17 08:57:26 -07:00
@ViewState private var shown = false
2026-10-16 23:30:57 -07:00
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
open.toggle()
} label: {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(open ? "▾" : "▸")
.foregroundStyle(Palette.muted)
summary
Spacer(minLength: 0)
2026-10-17 10:54:06 -07:00
if let status {
Text(status.text)
.font(Fonts.font(12))
.foregroundStyle(status.color)
.padding(.leading, 12)
}
2026-10-16 23:30:57 -07:00
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
content
}
}
.padding(.horizontal, boxed ? 10 : 0)
.padding(.vertical, boxed ? 4 : 0)
.background(boxed ? (nested ? Palette.raised : Palette.surface) : .clear, in: RoundedRectangle(cornerRadius: 8))
.overlay {
if boxed {
2026-10-17 08:57:26 -07:00
RoundedRectangle(cornerRadius: 8).stroke(working && shown ? accent : Palette.border)
.animation(.easeInOut(duration: 0.6), value: working && shown)
2026-10-16 23:30:57 -07:00
}
}
2026-10-17 08:57:26 -07:00
.onAppear {
shown = true
}
2026-10-16 23:30:57 -07:00
}
}
struct WhenText: View {
2026-10-16 23:30:57 -07:00
let when: Date
var body: some View {
Text(ago(when))
.help(isoText(when))
}
}
private struct SubagentTag: View {
let show: Bool
var body: some View {
if show {
Text("↳ subagent")
.font(Fonts.font(11))
.foregroundStyle(Palette.accent)
.padding(.trailing, 4)
}
}
}
struct RowView: View {
let row: Row
let model: ChatModel
let nested: Bool
@Environment(Store.self) private var store
var body: some View {
if !row.hidden {
if let seq = row.summarySeq {
content
.onScrollVisibilityChange(threshold: 0.01) { visible in
model.summaryVisible(seq, visible)
}
} else {
content
}
2026-10-16 23:30:57 -07:00
}
}
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(actorName(actor))
2026-10-16 23:30:57 -07:00
WhenText(when: when)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
if !images.isEmpty {
MessageImages(ids: images)
}
if let blocks {
MarkdownView(blocks: blocks, ctx: md)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Palette.accentTint, in: RoundedRectangle(cornerRadius: 10))
}
case .byrePrompt(let brief, let when, let blocks):
DetailsBox(nested: nested) {
2026-10-17 10:54:06 -07:00
Text(styled([("byre", 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
.fixedSize(horizontal: false, vertical: true)
2026-10-16 23:30:57 -07:00
Spacer(minLength: 12)
WhenText(when: when).font(Fonts.font(12)).foregroundStyle(Palette.muted)
} content: {
MarkdownView(blocks: blocks, ctx: md)
.padding(.vertical, 6)
}
case .agent(let blocks):
MarkdownView(blocks: blocks, ctx: md)
case .subagentMessage(let when, let blocks):
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
SubagentTag(show: row.tag)
WhenText(when: when)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
MarkdownView(blocks: blocks, ctx: md)
}
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.accent).frame(width: 1)
}
case .stream:
MarkdownView(blocks: row.md, ctx: md)
case .thinking(let blocks):
2026-10-17 15:01:43 -07:00
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
SubagentTag(show: row.tag)
Text("thinking")
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
MarkdownView(blocks: blocks, ctx: md)
2026-10-16 23:30:57 -07:00
}
case .tool(let name, let brief, let input):
2026-10-17 08:57:26 -07:00
DetailsBox(nested: nested, working: model.working(row)) {
2026-10-16 23:30:57 -07:00
SubagentTag(show: row.tag)
Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
} content: {
VStack(alignment: .leading, spacing: 6) {
PreBox(text: input)
2026-10-18 08:50:34 -07:00
ToolResult(result: row.result, unfinished: row.unfinished, started: row.started)
2026-10-16 23:30:57 -07:00
}
.padding(.vertical, 6)
}
case .subagent(let title, let description, let prompt):
2026-10-17 10:54:06 -07:00
DetailsBox(nested: nested, working: model.working(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
2026-10-16 23:30:57 -07:00
SubagentTag(show: row.tag)
Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)]))
} content: {
VStack(alignment: .leading, spacing: 6) {
MarkdownView(blocks: prompt, ctx: md)
.padding(.vertical, 6)
ForEach(row.body!.rows) { child in
RowView(row: child, model: model, nested: true)
}
2026-10-18 08:50:34 -07:00
ToolResult(result: row.result, unfinished: row.unfinished, started: row.started)
2026-10-16 23:30:57 -07:00
}
}
case .reviewer(let agentModel):
DetailsBox(nested: nested, working: row.active, status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
Text(styled([(row.review?.stage ?? "review", 600, nil), (agentModel.isEmpty ? "" : " · \(agentModel)", 400, Palette.muted), (row.text.isEmpty ? "" : " \(row.text)", 400, Palette.muted)]))
.lineLimit(1)
} 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)
}
2026-10-16 23:30:57 -07:00
case .group:
2026-10-17 08:57:26 -07:00
DetailsBox(nested: nested, working: model.working(row)) {
2026-10-16 23:30:57 -07:00
SubagentTag(show: row.tag)
RunSummary(calls: row.calls)
} content: {
VStack(alignment: .leading, spacing: 6) {
ForEach(row.body!.rows) { child in
RowView(row: child, model: model, nested: true)
}
}
.padding(.top, 6)
.padding(.bottom, 4)
}
case .orphan(let text):
PreBox(text: text)
case .turnEnd(let error):
if let error {
Text(error)
.font(Fonts.font(11))
.foregroundStyle(Palette.danger)
} else {
Rectangle().fill(Palette.border).frame(height: 1)
2026-10-16 23:30:57 -07:00
}
case .note(let note):
NoteView(note: note, cancelVisible: row.id == model.builder.lastMergeAfterReview)
2026-10-19 08:38:22 -07:00
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)
2026-10-19 08:38:22 -07:00
}
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, let keepOpen):
ReadyBubble(when: when, blocks: blocks, discard: discard, keepOpen: keepOpen, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged)
2026-10-16 23:30:57 -07:00
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, taskId: row.taskId, inherited: row.taskId != model.taskId, forks: row.forks)
case .question:
if let item = row.question {
QuestionBubble(item: item, inherited: row.taskId != model.taskId)
}
2026-10-16 23:30:57 -07:00
}
}
}
private struct QuestionBubble: View {
let item: InboxItem
let inherited: Bool
var body: some View {
let open = item.state == .open && !inherited
Group {
if open {
QuestionCard(item: item)
.id("\(item.id)/\(item.state.rawValue)/\(item.responses.count)")
} else {
2026-10-19 10:18:21 -07:00
DetailsBox(boxed: false) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("asked")
WhenText(when: item.createdAt)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
2026-10-19 10:18:21 -07:00
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)
2026-10-19 08:38:22 -07:00
.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 inherited ? "unanswered when this task was forked" : "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 "\(actorName(answer.user)) chose: \(options.joined(separator: "; "))"
}
return "\(actorName(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) {
2026-10-20 23:28:57 -07:00
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 keepOpen: Bool
let taskId: String?
let latest: Bool
let unmerged: Bool
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var dismissing = false
@ViewState private var other: String?
@FocusState private var editing: Bool
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)
2026-10-19 10:03:02 -07:00
}
MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId))
if current, let task {
let busy = store.taskBusy(task.id)
otherEntry(task)
.padding(.top, 4)
2026-10-19 10:03:02 -07:00
HStack(spacing: 8) {
Spacer(minLength: 0)
Button("Dismiss") {
dismiss(task, then: nil)
}
.buttonStyle(SmallLinkButtonStyle())
.disabled(dismissing)
.help(discard ? "Drop this suggestion; the task stays open" : "Drop this ready call; the task stays open")
DiscardButton(task: task, busy: busy, small: true)
if !discard {
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: true, highlight: keepOpen)
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: false, highlight: !keepOpen)
}
}
}
}
.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 func imageKey(_ task: ByreTask) -> String {
"ready:\(task.id):\(when.timeIntervalSince1970)"
}
@ViewBuilder
private func otherEntry(_ task: ByreTask) -> some View {
let key = imageKey(task)
let hasImages = store.images.has(key)
if other != nil || hasImages {
let text = other ?? ""
VStack(alignment: .leading, spacing: 6) {
Thumbnails(key: key)
TextEditor(text: Binding(get: { text }, set: { other = $0 }))
.font(Fonts.font(13))
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused($editing)
.disabled(dismissing)
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if press.modifiers.contains(.shift) || marked || (trimmed(text).isEmpty && !hasImages) {
return .ignored
}
dismiss(task, then: text)
return .handled
}
.padding(.horizontal, 5)
.padding(.vertical, 6)
.frame(height: 66)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Something else to tell the agent; sending it dismisses this")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.allowsHitTesting(false)
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border))
.imageInput(key)
.onChange(of: editing) {
if !editing && trimmed(other ?? "").isEmpty && !store.images.has(key) {
other = nil
}
}
}
} else {
Text("Other…")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
.contentShape(Rectangle())
.onTapGesture {
other = ""
DispatchQueue.main.async {
editing = true
}
}
}
}
private func dismiss(_ task: ByreTask, then text: String?) {
dismissing = true
let key = imageKey(task)
let imgs = store.images.take(key)
Task {
do {
let ids = text == nil ? nil : try await uploadImages(store.api, imgs)
_ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok
if let text {
try await store.messageTask(task.id, text, images: ids)
other = nil
}
} catch {
store.images.restore(key, imgs)
overlays.showAlert(errorText(error))
}
dismissing = false
}
}
}
2026-10-16 23:30:57 -07:00
private struct ToolResult: View {
let result: String?
let unfinished: Bool
2026-10-18 08:50:34 -07:00
let started: Date?
2026-10-16 23:30:57 -07:00
var body: some View {
if let result {
PreBox(text: result)
2026-10-18 08:50:34 -07:00
} else if unfinished || started == nil {
label(unfinished ? "no result" : "running…")
2026-10-16 23:30:57 -07:00
} else {
2026-10-18 08:50:34 -07:00
TimelineView(.periodic(from: .now, by: 1)) { ctx in
label("running… \(elapsed(ctx.date.timeIntervalSince(started!)))")
}
}
}
private func label(_ text: String) -> some View {
Text(text)
.font(Fonts.font(12, italic: true))
.foregroundStyle(Palette.muted)
.padding(.vertical, 4)
}
private func elapsed(_ t: TimeInterval) -> String {
let s = max(0, Int(t))
if s < 60 {
return "\(s)s"
2026-10-16 23:30:57 -07:00
}
2026-10-18 08:50:34 -07:00
return "\(s / 60)m \(s % 60 < 10 ? "0" : "")\(s % 60)s"
2026-10-16 23:30:57 -07:00
}
}
private struct RunSummary: View {
let calls: [Call]
var body: some View {
var order: [String] = []
var counts: [String: (call: Call, n: Int)] = [:]
for c in calls {
let key = "\(c.name)\0\(c.target)"
if counts[key] == nil {
order.append(key)
counts[key] = (c, 0)
}
counts[key]!.n += 1
}
var parts: [(String, CGFloat, Color?)] = []
for (i, key) in order.enumerated() {
let (call, n) = counts[key]!
if i > 0 {
parts.append((", ", 400, nil))
}
parts.append((call.name, 600, nil))
if !call.target.isEmpty {
parts.append((" \(call.target)", 400, Palette.muted))
}
if n > 1 {
parts.append((" ×\(n)", 400, Palette.muted))
}
}
return Text(styled(parts))
}
}
2026-10-20 19:32:27 -07:00
@MainActor
2026-10-16 23:30:57 -07:00
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
2026-10-20 22:28:36 -07:00
@ViewState private var canceling = false
2026-10-16 23:30:57 -07:00
var body: some View {
let color = switch note.style {
case .error: Palette.danger
case .ok: Palette.ok
case .plain: Palette.muted
}
2026-10-16 23:30:57 -07:00
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(text())
.font(Fonts.font(11))
.foregroundStyle(color)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
if let when = note.when {
WhenText(when: when)
.font(Fonts.font(11))
.foregroundStyle(color)
}
if let taskId = note.cancelMergeAfterReview, cancelVisible, store.findTask(taskId)?.mergeAfterReview == true {
Button("Cancel") {
2026-10-20 22:28:36 -07:00
canceling = true
2026-10-16 23:30:57 -07:00
Task {
do {
_ = try await store.api.client.cancelMergeAfterReview(path: .init(taskId: taskId)).ok
} catch {
overlays.showAlert(errorText(error))
}
2026-10-20 22:28:36 -07:00
canceling = false
2026-10-16 23:30:57 -07:00
}
}
.buttonStyle(SmallLinkButtonStyle())
2026-10-20 22:28:36 -07:00
.disabled(canceling)
2026-10-16 23:30:57 -07:00
.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):
out += taskLink(store, id)
case .preview(let taskId, let port):
guard let url = store.forwards.url(taskId, port) else {
out += AttributedString("port \(port)")
continue
}
2026-10-16 23:30:57 -07:00
var a = AttributedString(url.absoluteString)
a.link = url
a.foregroundColor = Palette.accent
out += a
}
}
return out
}
}
@MainActor
func taskLink(_ store: Store, _ id: String) -> AttributedString {
guard let task = store.findTask(id) else {
return AttributedString(id)
}
var a = AttributedString(task.name)
if task.status == .active {
a.link = AppLink.task(task.id)
a.foregroundColor = taskPalette[task.color]
}
return a
}
2026-10-16 23:30:57 -07:00
private struct SnapshotRow: View {
let snap: Snapshot
let taskId: String?
let inherited: Bool
let forks: [String]
2026-10-16 23:30:57 -07:00
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var forking = false
var body: some View {
2026-10-17 10:54:06 -07:00
let time = clockText(snap.createdAt)
2026-10-16 23:30:57 -07:00
HStack(spacing: 8) {
Rectangle().fill(Palette.border).frame(height: 1)
Text("snapshot #\(snap.n)")
if !inherited {
2026-10-20 23:28:57 -07:00
Button("Changes") {
store.pinSnapshot(snap.n)
store.focusTab(.changes)
}
.buttonStyle(SmallLinkButtonStyle())
.help("Show snapshot \(snap.n) in the Changes tab")
2026-10-16 23:30:57 -07:00
}
2026-10-20 23:28:57 -07:00
Button("Fork") {
2026-10-16 23:30:57 -07:00
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")
ForEach(forks, id: \.self) { id in
Text(AttributedString("forked as ") + taskLink(store, id))
.fixedSize()
}
2026-10-16 23:30:57 -07:00
Rectangle().fill(Palette.border).frame(height: 1)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.help("\(snap.trigger.rawValue.replacingOccurrences(of: "_", with: " ")) · \(time)")
}
}