import ByreAPI import SwiftUI typealias TaskChanges = Components.Schemas.TaskChanges typealias FileChange = Components.Schemas.FileChange private let statusLetter: [FileChange.StatusPayload: String] = [ .added: "A", .modified: "M", .deleted: "D", .renamed: "R", .typeChanged: "T", ] enum ChangeMode { case main, turn, step } private let emptyText: [ChangeMode: String] = [ .main: "No changes from the default branch.", .turn: "No changes this turn.", .step: "This step changed nothing.", ] struct ChangesMemory { var open: Set = [] var range = ChangeMode.main var pinRange = ChangeMode.step } func untilCancelled() async { while !Task.isCancelled { try? await Task.sleep(for: .seconds(3600)) } } @MainActor @Observable final class ChangesModel { let store: Store let compact: Bool private(set) var changes: TaskChanges? private(set) var error = "" private(set) var diffs: [String: Result] = [:] private(set) var open: Set private(set) var range: ChangeMode private(set) var pinRange: ChangeMode @ObservationIgnored private var loading = false @ObservationIgnored private var again = false @ObservationIgnored private var timer: Task? @ObservationIgnored private var loadedSince: String? @ObservationIgnored private var target = 0 @ObservationIgnored private var fetching: Set = [] init(_ store: Store, compact: Bool) { self.store = store self.compact = compact open = store.changesMemory.open range = store.changesMemory.range pinRange = store.changesMemory.pinRange } var pinned: Int? { compact ? nil : store.pinned } var mode: ChangeMode { compact ? .main : pinned != nil ? pinRange : range } var empty: String { emptyText[mode]! } private func turnStart() -> String? { store.transcript.last { $0.kind == "user.message" && $0.payload.value["head"] is String }?.payload.value["head"] as? String } func load() async { guard let taskId = store.selectedTaskId else { changes = nil return } let snapshot = pinned let since = snapshot == nil && mode == .turn ? turnStart() : nil if snapshot == nil && mode == .turn && since == nil { changes = nil error = "No turn has started in this task yet." return } if loading { again = true return } loading = true let started = target do { let query: Operations.GetTaskChanges.Input.Query = snapshot == nil ? .init(since: since) : .init(snapshot: snapshot, step: pinRange == .step) let fresh = try await store.api.client.getTaskChanges(path: .init(taskId: taskId), query: query).ok.body.json if started == target { loadedSince = since if fresh.tree != changes?.tree || fresh.base != changes?.base { diffs = [:] } changes = fresh error = "" } } catch { if started == target { self.error = errorText(error) } } loading = false if again { again = false await load() } } func retarget() { target += 1 timer?.cancel() changes = nil diffs = [:] error = "" Task { await load() } } private func soon() { timer?.cancel() timer = Task { try? await Task.sleep(for: .milliseconds(1500)) if !Task.isCancelled { await load() } } } func stop() { target += 1 timer?.cancel() } func transcript(_ fresh: [ByreEvent]) { if pinned != nil { return } if mode == .turn && turnStart() != loadedSince { Task { await load() } return } if fresh.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) { soon() } } func setRange(_ r: ChangeMode) { if range == r { return } range = r store.changesMemory.range = r retarget() } func setPinRange(_ r: ChangeMode) { if pinRange == r { return } pinRange = r store.changesMemory.pinRange = r retarget() } func setOpen(_ path: String, _ on: Bool) { if on { open.insert(path) } else { open.remove(path) } store.changesMemory.open = open } func shows(_ a: LineAnchor) -> Bool { guard open.contains(a.path), case .success(let patch)? = diffs[a.path] else { return false } let lines = patch.components(separatedBy: "\n") guard let start = lines.firstIndex(where: { $0.hasPrefix("@@") }) else { return false } return patchRows(lines[start...], a.path).contains { $0.anchor == a } } func ensureDiff(_ c: TaskChanges, _ f: FileChange) async { guard let taskId = store.selectedTaskId, diffs[f.path] == nil, !fetching.contains(f.path) else { return } fetching.insert(f.path) defer { fetching.remove(f.path) } let result: Result do { result = .success(try await store.api.client.getTaskDiff(path: .init(taskId: taskId), query: .init(base: c.base, tree: c.tree, path: f.path, oldPath: f.oldPath)).ok.body.json.patch) } catch { result = .failure(ApiError(status: 0, message: errorText(error))) } if changes?.base == c.base && changes?.tree == c.tree { diffs[f.path] = result } } } struct ChangesView: View { var compact = false @Environment(Store.self) private var store @ViewState private var model: ChangesModel? var body: some View { VStack(spacing: 0) { if let model { ChangesHead(model: model) if !compact { ChangesList(model: model) } } } .frame(maxHeight: compact ? nil : .infinity, alignment: .top) .task(id: store.selectedTaskId) { let m = model ?? ChangesModel(store, compact: compact) model = m m.retarget() let off = store.selectedFeed?.appended.add { [weak m] events in m?.transcript(events) } await untilCancelled() off?() m.stop() } .onChange(of: store.pinned) { if !compact { model?.retarget() } } } } private struct ChangesHead: View { let model: ChangesModel @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays var body: some View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 4) { if let n = model.pinned { SnapWhere(n: n) } summary .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .textSelection(.enabled) } Spacer(minLength: 0) if !model.compact, let taskId = store.selectedTaskId, let drafts = store.drafts { let n = drafts.lineComments(taskId).filter { !trimmed($0.text).isEmpty }.count if n > 0 { Button("Send \(n) \(n == 1 ? "comment" : "comments")") { sendComments(taskId, drafts) } .buttonStyle(WebButtonStyle(primary: true)) .fixedSize() .help("Send the line comments to the task's agent as one message") } } toggle } .modifier(HeaderBar()) } private func sendComments(_ taskId: String, _ drafts: Drafts) { let sent = drafts.takeLineComments(taskId) Task { do { try await store.messageTask(taskId, commentsMessage(sent)) } catch { drafts.restoreLineComments(taskId, sent) overlays.showAlert(errorText(error)) } } } @ViewBuilder private var summary: some View { if let c = model.changes { let adds = c.files.reduce(0) { $0 + $1.additions } let dels = c.files.reduce(0) { $0 + $1.deletions } HStack(spacing: 0) { Text("\(c.files.count) \(c.files.count == 1 ? "file" : "files") ") Text("+\(adds)").foregroundStyle(Palette.ok) Text(" ") Text("−\(dels)").foregroundStyle(Palette.danger) Text(" vs \(String(c.base.prefix(8)))").help(c.base) if !model.error.isEmpty { Text(" \(model.error)").foregroundStyle(Palette.danger) } } } else { Text(!model.error.isEmpty ? model.error : store.selectedTaskId != nil ? "Loading changes…" : "No task selected.") } } @ViewBuilder private var toggle: some View { if !model.compact { HStack(spacing: 4) { if model.pinned != nil { RangeTabButton(label: "Since main", active: model.pinRange == .main) { model.setPinRange(.main) } RangeTabButton(label: "This step", active: model.pinRange == .step) { model.setPinRange(.step) } RangeTabButton(label: "Live", active: false, help: "Back to the live worktree") { store.pinSnapshot(nil) } } else { RangeTabButton(label: "Since main", active: model.range == .main) { model.setRange(.main) } RangeTabButton(label: "This turn", active: model.range == .turn) { model.setRange(.turn) } } } .fixedSize() } } } private struct SnapWhere: View { let n: Int @Environment(Store.self) private var store var body: some View { let snaps = store.snapshots() let snap = snaps.first { $0.n == n } HStack(spacing: 0) { Text("snapshot \(n)") if let snap { Text(" · \(snap.trigger.rawValue) · \(clockText(snap.createdAt))") } Text(" · ") step(n - 1, "← #\(n - 1)", snaps) Text(" ") step(n + 1, "#\(n + 1) →", snaps) } .font(Fonts.font(12)) } private func step(_ to: Int, _ label: String, _ snaps: [Snapshot]) -> some View { Button(label) { store.pinSnapshot(to) } .buttonStyle(SmallLinkButtonStyle(color: Palette.text)) .disabled(!snaps.contains { $0.n == to }) } } private struct ChangesList: View { let model: ChangesModel @Environment(Store.self) private var store var body: some View { if let taskId = store.selectedTaskId, let drafts = store.drafts { let hidden = drafts.lineComments(taskId).filter { !model.shows($0.anchor) } let files = model.changes?.files ?? [] if files.isEmpty && hidden.isEmpty { EmptyText(text: model.changes != nil ? model.empty : model.error.isEmpty ? "Loading…" : "") } else { ScrollView { LazyVStack(alignment: .leading, spacing: 6) { if !hidden.isEmpty { HiddenComments(drafts: drafts, taskId: taskId, comments: hidden) } if let c = model.changes { ForEach(c.files.sorted { $0.path.localizedCompare($1.path) == .orderedAscending }, id: \.path) { f in FileRow(model: model, changes: c, file: f) } } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 16) } } } else { EmptyText(text: "No task selected.") } } } private struct HiddenComments: View { let drafts: Drafts let taskId: String let comments: [LineComment] var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Comments on lines not in the diff shown") .font(Fonts.font(11)) .foregroundStyle(Palette.muted) ForEach(comments, id: \.anchor) { c in VStack(alignment: .leading, spacing: 2) { Text("\(c.anchor.path):\(c.anchor.line)\(c.anchor.old ? " (removed line)" : "")") .font(Fonts.font(11)) .foregroundStyle(Palette.muted) Text(verbatim: c.anchor.quote) .font(Fonts.font(11)) .lineLimit(1) .truncationMode(.tail) LineCommentBox(drafts: drafts, taskId: taskId, anchor: c.anchor) } } } .padding(.bottom, 6) } } private struct FileRow: View { let model: ChangesModel let changes: TaskChanges let file: FileChange var body: some View { let open = model.open.contains(file.path) VStack(alignment: .leading, spacing: 0) { Button { model.setOpen(file.path, !open) } label: { HStack(alignment: .firstTextBaseline, spacing: 10) { Text(statusLetter[file.status]!) .font(Fonts.font(13, weight: 700)) .foregroundStyle(statusColor) .help(file.status.rawValue.replacingOccurrences(of: "_", with: " ")) Text(file.oldPath.map { "\($0) → \(file.path)" } ?? file.path) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) Group { if file.binary { Text("binary").foregroundStyle(Palette.muted) } else { HStack(spacing: 0) { Text("+\(file.additions)").foregroundStyle(Palette.ok) Text(" ") Text("−\(file.deletions)").foregroundStyle(Palette.danger) } } } .font(Fonts.font(11)) .fixedSize() } .padding(.horizontal, 10) .padding(.vertical, 4) .contentShape(Rectangle()) } .buttonStyle(.plain) if open { Group { switch model.diffs[file.path] { case .none: Text("loading…").foregroundStyle(Palette.muted).padding(.horizontal, 10).padding(.vertical, 6) case .failure(let e): Text(e.message).foregroundStyle(Palette.danger).padding(.horizontal, 10).padding(.vertical, 6) case .success(let patch): PatchView(patch: patch, path: file.path, taskId: model.store.selectedTaskId) } } .frame(maxWidth: .infinity, alignment: .leading) .background(Palette.raised) .overlay(alignment: .top) { Rectangle().fill(Palette.border).frame(height: 1) } .task(id: "\(changes.base) \(changes.tree)") { await model.ensureDiff(changes, file) } } } .background(Palette.surface, in: RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Palette.border)) } private var statusColor: Color { switch file.status { case .added: Palette.ok case .deleted: Palette.danger case .renamed, .modified: Palette.accent case .typeChanged: Palette.muted } } } private let hunkHead = Re(#"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@"#) private func patchRows(_ lines: ArraySlice, _ path: String) -> [DiffRow] { var old = 0 var new = 0 return lines.map { l in var row = DiffRow(text: l, kind: PatchLine(l)) switch row.kind { case .hunk: if let m = hunkHead.exec(l) { old = Int(m[1]!) ?? 0 new = Int(m[2]!) ?? 0 } case .add: row.anchor = LineAnchor(path: path, line: new, old: false, quote: l) new += 1 case .del: row.anchor = LineAnchor(path: path, line: old, old: true, quote: l) old += 1 case .ctx: if l.hasPrefix(" ") { row.anchor = LineAnchor(path: path, line: new, old: false, quote: l) old += 1 new += 1 } case .meta: break } return row } } func commentsMessage(_ comments: [LineComment]) -> String { comments.map { c in "`\(c.anchor.path):\(c.anchor.line)`\(c.anchor.old ? " (removed line)" : "")\n> \(c.anchor.quote)\n\n\(trimmed(c.text))" }.joined(separator: "\n\n") } struct PatchView: View { let patch: String let path: String let taskId: String? var body: some View { let lines = patch.components(separatedBy: "\n") if let start = lines.firstIndex(where: { $0.hasPrefix("@@") }) { DiffLines(rows: patchRows(lines[start...], path), taskId: taskId) } else { Text("No textual changes.") .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 6) } } } struct DiffRow { let text: String let kind: PatchLine var lineno: String? var mark = false var anchor: LineAnchor? } struct DiffLines: View { let rows: [DiffRow] var taskId: String? @Environment(Store.self) private var store @ViewState private var width: CGFloat = 0 var body: some View { ScrollView(.horizontal) { StretchColumn(minWidth: width) { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in if let taskId, let anchor = row.anchor, let drafts = store.drafts { let comment = drafts.lineComment(taskId, anchor) DiffLineRow(row: row, mark: comment != nil, hoverable: true) .simultaneousGesture(TapGesture().onEnded { drafts.toggleLineComment(taskId, anchor) }) if comment != nil { LineCommentBox(drafts: drafts, taskId: taskId, anchor: anchor) .frame(width: max(0, width - 20)) .padding(.horizontal, 10) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) } } else { DiffLineRow(row: row, mark: row.mark, hoverable: false) } } } .font(Fonts.font(11)) .padding(.vertical, 4) .textSelection(.enabled) } .scrollBounceBehavior(.basedOnSize) .onGeometryChange(for: CGFloat.self) { g in g.size.width } action: { w in width = w } } } private struct DiffLineRow: View { let row: DiffRow let mark: Bool let hoverable: Bool @ViewState private var hover = false var body: some View { HStack(spacing: 0) { if let n = row.lineno { Text(n) .foregroundStyle(Palette.muted) .frame(minWidth: 30, alignment: .trailing) .padding(.trailing, 8) } Text(verbatim: row.text.isEmpty ? " " : row.text) .foregroundStyle(row.kind.color) } .fixedSize() .padding(.horizontal, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(row.kind.background) .background(hover ? Palette.accent.opacity(0.08) : .clear) .overlay(alignment: .leading) { if mark { Rectangle().fill(Palette.accent).frame(width: 3) } } .contentShape(Rectangle()) .help(hoverable ? "Click to comment on this line" : "") .onHover { on in hover = hoverable && on } } } private struct LineCommentBox: View { let drafts: Drafts let taskId: String let anchor: LineAnchor @FocusState private var focused: Bool var body: some View { let text = Binding(get: { drafts.lineComment(taskId, anchor)?.text ?? "" }, set: { drafts.setLineComment(taskId, anchor, $0) }) VStack(alignment: .trailing, spacing: 4) { TextEditor(text: text) .font(Fonts.font(12)) .scrollContentBackground(.hidden) .focused($focused) .padding(.horizontal, 5) .padding(.vertical, 4) .frame(height: 64) .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) .overlay(alignment: .topLeading) { if text.wrappedValue.isEmpty { Text("Comment on line \(anchor.line)…") .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 4) .allowsHitTesting(false) } } .overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border)) Button("remove") { drafts.dropLineComment(taskId, anchor) } .buttonStyle(SmallLinkButtonStyle()) } .onAppear { if text.wrappedValue.isEmpty { focused = true } } } }