Port the remaining tabs to the macOS client

This commit is contained in:
Ian Gulliver
2026-10-17 07:26:18 -07:00
parent 9b3f775dac
commit e28c313854
15 changed files with 3648 additions and 50 deletions
-2
View File
@@ -16,8 +16,6 @@
# M8 — macOS client
- macOS client tabs
Changes, Files, Terminal, Inbox, Reviews, Workflow (native stage graph), Project, Status, Settings.
- macOS client overlays, menus and notifications
Switcher, keyboard help, alert and confirm overlays, Merge and Discard dialogs, Go menu shortcuts with the Ctrl variants and terminal passthrough, UNUserNotificationCenter notifications.
- macOS client parity review
+4 -2
View File
@@ -1,6 +1,6 @@
# byre for macOS
This is a design, partly built. What exists under `mac/`: the generated API client, the SSE client, `Store` and `Feed`, the startup sequence, the connection screen with Change Server…, the generated palette, the window with its two panes and tab strips (More included), the Lead and task transcripts through `TranscriptBuilder`, the composer with drafts and images, the Markdown port, native flowchart diagrams, pane-focus reporting and the identity banner. Overlays so far are alerts, the More menu and the full-size image view. The other tabs (Changes, Files, Terminal, Inbox, Reviews, Workflow, Project, Status, Settings) show `<tab> isn't in the macOS app yet.`. Until the switcher exists, the task picker is a plain menu of the active tasks, and the transcript head has Compact and Sync but not yet Merge or Discard. The rest of the work is under "M8 — macOS client" in `TODO.md`.
This is a design, partly built. What exists under `mac/`: the generated API client, the SSE client, `Store` and `Feed`, the startup sequence, the connection screen with Change Server…, the generated palette, the window with its two panes and tab strips (More included), the Lead and task transcripts through `TranscriptBuilder`, the composer with drafts and images, the Markdown port, native flowchart diagrams, pane-focus reporting, the identity banner, and every other tab: Changes, Files, Terminal, Inbox, Reviews, Workflow (its stage graph drawn natively), Project, Status and Settings. Overlays so far are alerts, confirmations (used by Settings to delete a lead note), the More menu and the full-size image view. Settings asks for notification permission, but nothing delivers notifications until `Notifications.swift` exists. Until the switcher exists, the task picker is a plain menu of the active tasks, and the transcript head has Compact and Sync but not yet Merge or Discard. The rest of the work is under "M8 — macOS client" in `TODO.md`.
## Decision
@@ -59,7 +59,7 @@ The Swift files follow the TypeScript ones, so a change in one points at the sam
| `switcher.ts` | `Switcher.swift` | ⌘K overlay and the shortcuts help |
| `drafts.ts` | `Drafts.swift` | Composer and answer drafts |
| `notifications.ts` | `Notifications.swift` | `UNUserNotificationCenter` delivery |
| `dom.ts` | `Dialogs.swift`, `Format.swift` | Overlays (alerts, the More menu, the image view; confirm to come), `ago`, Copy, shared button and layout styles |
| `dom.ts` | `Dialogs.swift`, `Format.swift` | Overlays (alerts, confirmations, the More menu, the image view), `ago`, Copy, shared button and layout styles, diff lines |
| `images.ts` | `Images.swift` | Blob, worktree and `data:` images, their cache and the full-size overlay |
## Store and events
@@ -176,6 +176,8 @@ Platform differences the copy can't avoid, each with what the web client does an
| Where | Web client | App |
| --- | --- | --- |
| Settings, notifications line | `This browser shows notifications while a byre page is open.` | `This Mac shows notifications while byre is running.` |
| Settings, notifications not yet allowed | `This browser doesn't show notifications.` and a **Turn on for this browser** button | `This Mac doesn't show notifications from byre.` and a **Turn on for this Mac** button, which asks macOS for permission |
| Settings, notifications refused | `Notifications are blocked for this site in the browser's settings.` | `Notifications are blocked for byre in System Settings.` |
| Opening a task by address | `#/task/<id>` in the URL bar | None; tasks open from the switcher, links and notifications |
| Clicking a transcript image | Opens the image in a new browser tab | Opens it in an in-window overlay |
| Preview links (`expose_port`) | Open in a new browser tab | Open in the default browser |
+503
View File
@@ -0,0 +1,503 @@
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.",
]
@MainActor
private enum ChangesMemory {
static var open: Set<String> = []
static var range = ChangeMode.main
static 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<String, ApiError>] = [:]
private(set) var open = ChangesMemory.open
private(set) var range = ChangesMemory.range
private(set) var pinRange = ChangesMemory.pinRange
@ObservationIgnored private var loading = false
@ObservationIgnored private var again = false
@ObservationIgnored private var timer: Task<Void, Never>?
@ObservationIgnored private var loadedSince: String?
@ObservationIgnored private var target = 0
@ObservationIgnored private var fetching: Set<String> = []
init(_ store: Store, compact: Bool) {
self.store = store
self.compact = compact
}
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
ChangesMemory.range = r
retarget()
}
func setPinRange(_ r: ChangeMode) {
if pinRange == r {
return
}
pinRange = r
ChangesMemory.pinRange = r
retarget()
}
func setOpen(_ path: String, _ on: Bool) {
if on {
open.insert(path)
} else {
open.remove(path)
}
ChangesMemory.open = open
}
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<String, ApiError>
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
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)
toggle
}
.modifier(HeaderBar())
}
@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) · \(snap.createdAt.formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits)))")
}
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 store.selectedTaskId == nil {
EmptyText(text: "No task selected.")
} else if let c = model.changes {
if c.files.isEmpty {
EmptyText(text: model.empty)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 6) {
ForEach(c.files.sorted { $0.path.localizedStandardCompare($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: model.error.isEmpty ? "Loading…" : "")
}
}
}
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)
}
}
.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
}
}
}
struct PatchView: View {
let patch: String
var body: some View {
let lines = patch.components(separatedBy: "\n")
if let start = lines.firstIndex(where: { $0.hasPrefix("@@") }) {
DiffLines(rows: lines[start...].map { DiffRow(text: $0, kind: PatchLine($0)) })
} 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
}
struct DiffLines: View {
let rows: [DiffRow]
@ViewState private var width: CGFloat = 0
var body: some View {
ScrollView(.horizontal) {
StretchColumn(minWidth: width) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
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)
.overlay(alignment: .leading) {
if row.mark {
Rectangle().fill(Palette.accent).frame(width: 3)
}
}
}
}
.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
}
}
}
+129 -1
View File
@@ -13,25 +13,51 @@ struct OverlayMenu {
let entries: [MenuEntry]
}
struct OverlayConfirm {
let paragraphs: [String]
let action: String
let danger: Bool
let anchor: CGRect?
let done: (Bool) -> Void
}
@MainActor
@Observable
final class Overlays {
var alerts: [String] = []
var image: NSImage?
var menu: OverlayMenu?
var confirm: OverlayConfirm?
@ObservationIgnored private var monitor: Any?
var isOpen: Bool {
!alerts.isEmpty || image != nil || menu != nil
!alerts.isEmpty || image != nil || menu != nil || confirm != nil
}
func showAlert(_ text: String) {
alerts.append(text)
}
func showConfirm(_ paragraphs: [String], action: String, danger: Bool = false, anchor: CGRect? = nil) async -> Bool {
confirm?.done(false)
return await withCheckedContinuation { cont in
confirm = OverlayConfirm(paragraphs: paragraphs, action: action, danger: danger, anchor: anchor) { ok in
cont.resume(returning: ok)
}
}
}
func answerConfirm(_ ok: Bool) {
let c = confirm
confirm = nil
c?.done(ok)
}
func closeTop() {
if menu != nil {
menu = nil
} else if confirm != nil {
answerConfirm(false)
} else if image != nil {
image = nil
} else if !alerts.isEmpty {
@@ -60,6 +86,10 @@ final class Overlays {
closeTop()
return true
}
if menu == nil && confirm != nil && (code == 36 || code == 76) {
answerConfirm(true)
return true
}
if menu == nil && image == nil && !alerts.isEmpty && (code == 36 || code == 76) {
alerts.removeFirst()
return true
@@ -116,6 +146,16 @@ struct OverlayHost: View {
overlays.image = nil
}
}
if let c = overlays.confirm {
Color.black.opacity(0.35)
.contentShape(Rectangle())
.onTapGesture {
overlays.answerConfirm(false)
}
ConfirmPlacement(confirm: c) { ok in
overlays.answerConfirm(ok)
}
}
if let text = overlays.alerts.first {
Color.black.opacity(0.35)
.contentShape(Rectangle())
@@ -151,6 +191,94 @@ private struct MenuRow: View {
}
}
private let overlayMargin: CGFloat = 8
private struct AnchoredBox: Layout {
let anchor: CGRect
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let width = min(520, bounds.width - overlayMargin * 2)
let size = subviews[0].sizeThatFits(ProposedViewSize(width: width, height: nil))
let below = anchor.maxY + 4
let above = anchor.minY - 4 - size.height
let y: CGFloat
if below + size.height <= bounds.height - overlayMargin {
y = below
} else if above >= overlayMargin {
y = above
} else {
y = max(overlayMargin, bounds.height - overlayMargin - size.height)
}
let x = min(max(overlayMargin, anchor.maxX - size.width), bounds.width - overlayMargin - size.width)
subviews[0].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + y), proposal: ProposedViewSize(width: width, height: size.height))
}
}
private struct ConfirmPlacement: View {
let confirm: OverlayConfirm
let answer: (Bool) -> Void
var body: some View {
GeometryReader { g in
let box = ConfirmBox(confirm: confirm, maxText: max(60, g.size.height - overlayMargin * 2 - 90), answer: answer)
if let anchor = confirm.anchor {
AnchoredBox(anchor: anchor) {
box
}
} else {
box
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}
private struct ConfirmBox: View {
let confirm: OverlayConfirm
let maxText: CGFloat
let answer: (Bool) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(confirm.paragraphs.enumerated()), id: \.offset) { _, p in
Text(p)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollBounceBehavior(.basedOnSize)
.frame(maxHeight: maxText)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
Spacer()
Button("Cancel") {
answer(false)
}
.buttonStyle(WebButtonStyle())
Button(confirm.action) {
answer(true)
}
.buttonStyle(WebButtonStyle(primary: true, danger: confirm.danger))
}
.padding(.top, 4)
}
.padding(16)
.frame(maxWidth: 520)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
.shadow(color: .black.opacity(0.18), radius: 14, y: 8)
}
}
private struct AlertBox: View {
let text: String
let ok: () -> Void
+351
View File
@@ -0,0 +1,351 @@
import SwiftUI
typealias DirListing = Components.Schemas.DirListing
typealias DirEntry = Components.Schemas.DirEntry
typealias FileContent = Components.Schemas.FileContent
private struct Place: Equatable {
var dir = ""
var file: String?
}
@MainActor
private var places: [String: Place] = [:]
private func join(_ dir: String, _ name: String) -> String {
dir.isEmpty ? name : "\(dir)/\(name)"
}
private func parent(_ path: String) -> String {
path.lastIndex(of: "/").map { String(path[..<$0]) } ?? ""
}
private func bytes(_ n: Int64) -> String {
if n < 1024 {
return "\(n) B"
}
if n < 1024 * 1024 {
return String(format: "%.1f KiB", Double(n) / 1024)
}
return String(format: "%.1f MiB", Double(n) / 1024 / 1024)
}
private func shown(_ c: FileContent) -> Int64 {
Int64(c.text.utf8.count)
}
@MainActor
@Observable
private final class FilesModel {
let store: Store
private(set) var listing: DirListing?
private(set) var content: FileContent?
private(set) var error = ""
private(set) var generation = 0
@ObservationIgnored private var target = 0
@ObservationIgnored private var timer: Task<Void, Never>?
init(_ store: Store) {
self.store = store
}
var place: Place {
store.selectedTaskId.flatMap { places[$0] } ?? Place()
}
func go(_ p: Place) {
guard let taskId = store.selectedTaskId else {
return
}
places[taskId] = p
listing = nil
content = nil
error = ""
generation += 1
Task {
await load()
}
}
func reset() {
target += 1
timer?.cancel()
listing = nil
content = nil
error = ""
Task {
await load()
}
}
func stop() {
target += 1
timer?.cancel()
}
func load() async {
guard let taskId = store.selectedTaskId else {
return
}
target += 1
let started = target
let p = place
do {
if let file = p.file {
let fresh = try await store.api.client.getTaskFile(path: .init(taskId: taskId), query: .init(path: file)).ok.body.json
if started == target {
content = fresh
listing = nil
}
} else {
let fresh = try await store.api.client.getTaskDir(path: .init(taskId: taskId), query: .init(path: p.dir)).ok.body.json
if started == target {
listing = fresh
content = nil
}
}
if started == target {
error = ""
}
} catch {
if started == target {
self.error = errorText(error)
listing = nil
content = nil
}
}
}
func transcript(_ fresh: [ByreEvent]) {
if !fresh.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
return
}
timer?.cancel()
timer = Task {
try? await Task.sleep(for: .milliseconds(1500))
if !Task.isCancelled {
await load()
}
}
}
var summary: String {
if !error.isEmpty {
return ""
}
if let c = content {
if c.binary {
return "\(bytes(c.size)) · binary"
}
return c.truncated ? "\(bytes(c.size)) · showing the first \(bytes(shown(c)))" : bytes(c.size)
}
if let l = listing {
return "\(l.entries.count)\(l.truncated ? "+" : "") \(l.entries.count == 1 ? "entry" : "entries")"
}
return store.selectedTaskId != nil ? "Loading…" : ""
}
}
struct FilesView: View {
@Environment(Store.self) private var store
@ViewState private var model: FilesModel?
var body: some View {
VStack(spacing: 0) {
if let model {
FilesHead(model: model)
FilesBody(model: model)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.task(id: store.selectedTaskId) {
let m = model ?? FilesModel(store)
model = m
m.reset()
let off = store.selectedFeed?.appended.add { [weak m] events in
m?.transcript(events)
}
await untilCancelled()
off?()
m.stop()
}
}
}
private struct FilesHead: View {
let model: FilesModel
var body: some View {
let p = model.place
let path = p.file ?? p.dir
let parts = path.isEmpty ? [] : path.components(separatedBy: "/")
HStack(alignment: .firstTextBaseline, spacing: 12) {
FlowLayout(spacing: 2) {
crumb("worktree", "", parts.isEmpty)
ForEach(Array(parts.enumerated()), id: \.offset) { i, part in
Text("/").foregroundStyle(Palette.muted)
crumb(part, parts[...i].joined(separator: "/"), i == parts.count - 1)
}
}
Spacer(minLength: 0)
Text(model.summary)
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.lineLimit(1)
.fixedSize()
}
.modifier(HeaderBar())
}
@ViewBuilder
private func crumb(_ label: String, _ dir: String, _ last: Bool) -> some View {
if last {
Text(label).foregroundStyle(Palette.text)
} else {
Button(label) {
model.go(Place(dir: dir))
}
.buttonStyle(.plain)
.foregroundStyle(Palette.accent)
.padding(.horizontal, 2)
}
}
}
private struct FilesBody: View {
let model: FilesModel
@Environment(Store.self) private var store
var body: some View {
if store.selectedTaskId == nil {
EmptyText(text: "No task selected.")
} else if !model.error.isEmpty {
EmptyText(text: model.error, error: true)
} else if let c = model.content {
if c.binary {
EmptyText(text: "Binary file.")
} else {
FileText(content: c)
.id(model.generation)
}
} else if let l = model.listing {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if !l.path.isEmpty {
EntryRow(label: "../", kind: .dir, ignored: false, meta: "", symlink: false) {
model.go(Place(dir: parent(l.path)))
}
}
ForEach(l.entries, id: \.name) { e in
let meta = [e.ignored ? "ignored" : nil, e._type == .file ? bytes(e.size) : nil].compactMap { $0 }.joined(separator: " · ")
EntryRow(label: e._type == .dir ? "\(e.name)/" : e.name, kind: e._type, ignored: e.ignored, meta: meta, symlink: e.symlink) {
if e._type == .dir {
model.go(Place(dir: join(l.path, e.name)))
} else if e._type == .file {
model.go(Place(dir: l.path, file: join(l.path, e.name)))
}
}
}
if l.entries.isEmpty {
Muted(text: "Empty directory.")
}
if l.truncated {
Text("Only the first \(l.entries.count) entries are shown.")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 8)
}
}
.padding(.horizontal, 8)
.padding(.top, 6)
.padding(.bottom, 16)
}
.id(model.generation)
} else {
EmptyText(text: "Loading…")
}
}
}
private struct EntryRow: View {
let label: String
let kind: DirEntry._TypePayload
let ignored: Bool
let meta: String
let symlink: Bool
let action: () -> Void
@ViewState private var hover = false
var body: some View {
let row = HStack(spacing: 12) {
HStack(spacing: 0) {
Text(label)
.foregroundStyle(kind == .other || ignored ? Palette.muted : kind == .dir ? Palette.accent : Palette.text)
if symlink {
Text(" →").foregroundStyle(Palette.muted)
}
}
.lineLimit(1)
Spacer(minLength: 0)
Text(meta)
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.lineLimit(1)
.fixedSize()
}
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(hover && kind != .other ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4))
.contentShape(Rectangle())
if kind == .other {
row.help("Leaves the worktree, points nowhere, or isn't a file or directory")
} else {
Button(action: action) {
row
}
.buttonStyle(.plain)
.onHover { hover = $0 }
}
}
}
private struct FileText: View {
let content: FileContent
var body: some View {
var lines = content.text.components(separatedBy: "\n")
if lines.last == "" {
lines.removeLast()
}
let numbers = (1...max(1, lines.count)).map(String.init).joined(separator: "\n")
return ScrollView([.vertical, .horizontal]) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
Text(verbatim: lines.isEmpty ? "" : numbers)
.foregroundStyle(Palette.muted)
.multilineTextAlignment(.trailing)
.frame(minWidth: 42, alignment: .trailing)
.padding(.trailing, 6)
.overlay(alignment: .trailing) {
Rectangle().fill(Palette.border).frame(width: 1)
}
.padding(.trailing, 12)
Text(verbatim: lines.joined(separator: "\n"))
.foregroundStyle(Palette.text)
.textSelection(.enabled)
.padding(.trailing, 10)
}
.font(Fonts.font(11))
.fixedSize()
.padding(.vertical, 6)
if content.truncated {
Text("Showing the first \(bytes(shown(content))) of \(bytes(content.size)).")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 16)
.padding(.top, 6)
.padding(.bottom, 16)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
+139 -2
View File
@@ -227,13 +227,14 @@ private struct WebButton: View {
@ViewState private var hover = false
var body: some View {
let fill = danger ? Palette.danger : Palette.accent
configuration.label
.font(Fonts.font(13, weight: primary ? 700 : 400))
.foregroundStyle(primary ? Palette.bg : danger ? Palette.danger : Palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(primary ? Palette.accent : Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(primary ? Palette.accent : hover && enabled ? Palette.muted : Palette.border))
.background(primary ? fill : Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(primary ? fill : hover && enabled ? Palette.muted : Palette.border))
.opacity(enabled ? (configuration.isPressed ? 0.8 : 1) : 0.45)
.contentShape(Rectangle())
.onHover { hover = $0 }
@@ -269,6 +270,142 @@ struct FieldStyle: ViewModifier {
}
}
struct HeaderBar: ViewModifier {
func body(content: Content) -> some View {
content
.padding(.horizontal, 16)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
struct RangeTabButton: View {
let label: String
let active: Bool
var help = ""
let action: () -> Void
@ViewState private var hover = false
var body: some View {
Button(action: action) {
Text(label)
.font(Fonts.font(12))
.foregroundStyle(active || hover ? Palette.text : Palette.muted)
.padding(.horizontal, 6)
.padding(.top, 2)
.padding(.bottom, 3)
.overlay(alignment: .bottom) {
Rectangle().fill(active ? Palette.accent : .clear).frame(height: 2)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(help)
.onHover { hover = $0 }
}
}
struct EmptyText: View {
let text: String
var error = false
var body: some View {
VStack {
Text(text)
.foregroundStyle(error ? Palette.danger : Palette.muted)
.multilineTextAlignment(.center)
.textSelection(.enabled)
.padding(32)
.frame(maxWidth: .infinity)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct StateDot: View {
let filled: Color?
var body: some View {
Group {
if let filled {
Circle().fill(filled)
} else {
Circle().strokeBorder(Palette.muted, lineWidth: 1.5)
}
}
.frame(width: 8, height: 8)
}
}
struct StateMark: View {
let state: TaskState
let color: Color
var body: some View {
Group {
switch state.kind {
case .working, .merging:
Spinner(color: color)
case .answer:
StateDot(filled: Palette.warn)
case .error:
StateDot(filled: Palette.danger)
default:
StateDot(filled: nil)
}
}
.help(state.title)
}
}
enum PatchLine {
case hunk, add, del, meta, ctx
init(_ l: String) {
self = l.hasPrefix("@@") ? .hunk : l.hasPrefix("+") ? .add : l.hasPrefix("-") ? .del : l.hasPrefix("\\") ? .meta : .ctx
}
var background: Color {
switch self {
case .hunk: Palette.surface
case .add: Palette.ok.opacity(0.14)
case .del: Palette.danger.opacity(0.14)
default: .clear
}
}
var color: Color {
self == .hunk || self == .meta ? Palette.muted : Palette.text
}
}
struct StretchColumn: Layout {
var minWidth: CGFloat = 0
private func width(_ proposal: ProposedViewSize, _ subviews: Subviews) -> CGFloat {
max(minWidth, proposal.width ?? 0, subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0)
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let w = width(ProposedViewSize(width: nil, height: nil), subviews)
let h = subviews.reduce(0) { $0 + $1.sizeThatFits(ProposedViewSize(width: w, height: nil)).height }
return CGSize(width: w, height: h)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var y = bounds.minY
for sub in subviews {
let h = sub.sizeThatFits(ProposedViewSize(width: bounds.width, height: nil)).height
sub.place(at: CGPoint(x: bounds.minX, y: y), proposal: ProposedViewSize(width: bounds.width, height: h))
y += h
}
}
}
struct TrailingBubble: Layout {
var fraction: CGFloat = 0.85
+650
View File
@@ -0,0 +1,650 @@
import OpenAPIRuntime
import SwiftUI
typealias Attachment = Components.Schemas.Attachment
typealias InboxResponseCreate = Components.Schemas.InboxResponseCreate
private let delegateText = "Use your judgment for this decision and continue within the existing task instructions."
private let otherStop = "\u{0}other"
private let httpURL = Re(#"^https?://"#, caseless: true)
struct InboxView: View {
@Environment(Store.self) private var store
var body: some View {
let taskId = store.selectedTaskId
let top = store.inbox.filter { $0.taskId == taskId }.sorted { $0.createdAt < $1.createdAt }.first
VStack(spacing: 0) {
ChangesView(compact: true)
if taskId == nil {
EmptyText(text: "No task selected.")
} else if let top {
ScrollView {
InboxCard(item: top)
.id(top.id)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 16)
}
.id(top.id)
} else {
EmptyText(text: "Nothing waiting on you in this task.")
}
}
.frame(maxHeight: .infinity, alignment: .top)
}
}
private func numberChoices(_ blocks: [MDBlock], _ next: inout Int) -> [MDBlock] {
blocks.map { block in
switch block {
case .list(let ordered, let start, let items, let grid):
return .list(ordered: ordered, start: start, items: items.map { item in
var out = item
if item.choice != nil {
out.choiceIndex = next
next += 1
}
out.blocks = numberChoices(item.blocks, &next)
return out
}, grid: grid)
case .quote(let inner):
return .quote(numberChoices(inner, &next))
case .details(let summary, let body):
return .details(summary: summary, body: numberChoices(body, &next))
default:
return block
}
}
}
private func firstChoices(_ blocks: [MDBlock], _ out: inout [String: Int]) {
for block in blocks {
switch block {
case .list(_, _, let items, _):
for item in items {
if let c = item.choice, out[c] == nil {
out[c] = item.choiceIndex
}
firstChoices(item.blocks, &out)
}
case .quote(let inner):
firstChoices(inner, &out)
case .details(_, let body):
firstChoices(body, &out)
default:
break
}
}
}
@MainActor
@Observable
private final class CardModel {
let item: InboxItem
let store: Store
let overlays: Overlays
let context: [MDBlock]
let firstChoice: [String: Int]
var busy = false
init(_ item: InboxItem, _ store: Store, _ overlays: Overlays) {
self.item = item
self.store = store
self.overlays = overlays
var next = 0
context = numberChoices(parseMarkdown(jsString(item.payload.value["context"] ?? ""), choices: true), &next)
var first: [String: Int] = [:]
firstChoices(context, &first)
firstChoice = first
}
var payload: JSONDict {
item.payload.value
}
var key: String {
"inbox:\(item.id)"
}
var options: [String] {
(payload["options"] as? [(any Sendable)?] ?? []).compactMap { $0 as? String }
}
var recommended: [String] {
(payload["recommended"] as? [(any Sendable)?] ?? []).compactMap { $0 as? String }
}
var multiple: Bool {
bool(payload, "multiple")
}
var checked: [String] {
store.drafts?.checkedAnswer(item.id) ?? []
}
var hasImages: Bool {
!store.images.images(key).isEmpty
}
var typed: String {
trimmed(store.drafts?.textAnswer(item.id) ?? "")
}
var editorOpen: Bool {
(store.drafts?.hasTextAnswer(item.id) ?? false) || hasImages
}
func toggle(_ text: String, _ on: Bool) {
var now = checked.filter { $0 != text }
if on {
now.append(text)
}
store.drafts?.setCheckedAnswer(item.id, options.filter { now.contains($0) })
}
func submit(_ response: InboxResponseCreate, withImages: Bool = false) {
if busy {
return
}
busy = true
let imgs = withImages ? store.images.take(key) : []
Task {
do {
var r = response
r.images = try await uploadImages(store.api, imgs)
_ = try await store.api.client.respondInbox(body: .json(.init(responses: [r]))).ok
store.drafts?.clearAnswer(item.id)
store.paneFocus.setDraft(key, false)
let others = store.inbox.contains { $0.taskId == item.taskId && $0.id != item.id }
if !others && store.focus.tab == .inbox && store.selectedTaskId == item.taskId {
store.focusTab(.transcript)
}
} catch {
store.images.restore(key, imgs)
overlays.showAlert(errorText(error))
busy = false
}
}
}
func submitQuestion() {
let opts = multiple ? checked : []
if !opts.isEmpty {
submit(InboxResponseCreate(itemId: item.id, _type: .option, options: opts, text: typed.isEmpty ? nil : store.drafts?.textAnswer(item.id)), withImages: true)
return
}
if !typed.isEmpty || hasImages {
submit(InboxResponseCreate(itemId: item.id, _type: .other, text: store.drafts?.textAnswer(item.id) ?? ""), withImages: true)
}
}
func submitOther() {
if typed.isEmpty && !hasImages {
return
}
submit(InboxResponseCreate(itemId: item.id, _type: .other, text: store.drafts?.textAnswer(item.id) ?? ""), withImages: true)
}
}
private struct InboxCard: View {
let item: InboxItem
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var model: CardModel?
@FocusState private var focus: String?
var body: some View {
Group {
if let model {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(item.kind.rawValue.replacingOccurrences(of: "_", with: " "))
Spacer(minLength: 0)
Text(ago(item.createdAt)).help(isoText(item.createdAt))
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
CardBody(model: model, focus: $focus)
Thread(item: item)
CardActions(model: model, focus: $focus)
.padding(.top, 6)
}
.onKeyPress(keys: [.downArrow, .upArrow]) { press in
move(model, press.key == .downArrow ? 1 : -1)
}
}
}
.onAppear {
if model == nil {
model = CardModel(item, store, overlays)
}
}
}
private func move(_ model: CardModel, _ by: Int) -> KeyPress.Result {
var stops = model.options
if !model.editorOpen {
stops.append(otherStop)
}
guard let current = focus, let at = stops.firstIndex(of: current) else {
return .ignored
}
focus = stops[min(max(at + by, 0), stops.count - 1)]
return .handled
}
}
private struct CardBody: View {
let model: CardModel
var focus: FocusState<String?>.Binding
var body: some View {
let p = model.payload
let item = model.item
VStack(alignment: .leading, spacing: 0) {
switch item.kind {
case .question:
MarkdownView(blocks: model.context, ctx: MDContext(taskId: item.taskId, choice: { md in
AnyView(ChoiceBox(model: model, item: md, focus: focus))
}))
.padding(.vertical, 10)
MarkdownView(blocks: parseMarkdown(jsString(p["question"] ?? "")), ctx: MDContext(taskId: item.taskId))
.padding(.top, 10)
.padding(.bottom, 4)
WhyLine(text: jsString(p["why_blocked"] ?? ""), taskId: item.taskId)
.padding(.bottom, 8)
Attachments(list: attachments(p["attachments"] ?? nil), taskId: item.taskId)
case .sessionError:
Text(jsString(p["error"] ?? ""))
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
.padding(.vertical, 10)
case .rateLimited:
let limit = dict(p, "rate_limit") ?? [:]
let resets = (limit["resetsAt"] as? Double ?? (limit["resetsAt"] as? Int).map(Double.init)).map { Date(timeIntervalSince1970: $0).formatted(date: .numeric, time: .standard) } ?? "unknown"
VStack(alignment: .leading, spacing: 10) {
Text(jsString(p["error"] ?? "")).foregroundStyle(Palette.danger)
Text("\(jsString(p["vendor"] ?? "")) \(limit["rateLimitType"].map { jsString($0) } ?? "") limit resets \(resets)")
}
.textSelection(.enabled)
.padding(.vertical, 10)
}
}
}
private func attachments(_ v: (any Sendable)?) -> [Attachment] {
guard let v else {
return []
}
do {
return try jsonDecoder().decode([Attachment].self, from: try JSONEncoder().encode(try OpenAPIValueContainer(unvalidatedValue: v)))
} catch {
log.error("decode attachments: \(String(describing: error), privacy: .public)")
return []
}
}
}
private struct WhyLine: View {
let text: String
let taskId: String
@Environment(Store.self) private var store
var body: some View {
var out = AttributedString("Blocked on: ")
out.foregroundColor = Palette.text
var rest = InlineRenderer(store: store, size: 13, taskId: taskId).text(parseInline(text))
rest.foregroundColor = rest.foregroundColor ?? Palette.muted
return Text(out + rest)
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
private struct ChoiceBox: View {
let model: CardModel
let item: MDItem
var focus: FocusState<String?>.Binding
@Environment(Store.self) private var store
@ViewState private var hover = false
var body: some View {
let text = item.choice ?? ""
if model.options.contains(text) && model.firstChoice[text] == item.choiceIndex {
box(text)
} else {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•")
VStack(alignment: .leading, spacing: 6) {
InlineView(inlines: parseInline(item.raw), ctx: MDContext(taskId: model.item.taskId))
ItemBody(item: item, ctx: MDContext(taskId: model.item.taskId))
}
}
}
}
private func box(_ text: String) -> some View {
let rec = model.recommended.contains(text)
let focused = focus.wrappedValue == text
let lit = (hover && !model.multiple) || focused
let hasDetail = !item.inline.isEmpty || !item.blocks.isEmpty
let on = model.checked.contains(text)
return VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
if model.multiple {
Toggle("", isOn: Binding(get: { on }, set: { model.toggle(text, $0) }))
.toggleStyle(.checkbox)
.labelsHidden()
.tint(Palette.accent)
.disabled(model.busy)
}
InlineView(inlines: parseInline(text), ctx: MDContext(taskId: model.item.taskId))
.font(Fonts.font(13, weight: 600))
.frame(maxWidth: .infinity, alignment: .leading)
if rec {
Text("recommended").font(Fonts.font(11)).foregroundStyle(Palette.ok)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(lit ? Palette.accentTint : rec ? Palette.ok.opacity(0.12) : Palette.surface)
if hasDetail {
ItemBody(item: MDItem(inline: item.inline, blocks: item.blocks), ctx: MDContext(taskId: model.item.taskId))
.padding(.horizontal, 10)
.padding(.vertical, 8)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Palette.raised)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(lit ? Palette.accent : rec ? Palette.ok.opacity(0.55) : Palette.border))
.overlay {
if focused {
RoundedRectangle(cornerRadius: 6).stroke(Palette.accent, lineWidth: 2).padding(-2)
}
}
.opacity(model.busy && !model.multiple ? 0.6 : 1)
.contentShape(Rectangle())
.focusable(!model.busy)
.focusEffectDisabled()
.focused(focus, equals: text)
.onTapGesture {
pick(text)
}
.onKeyPress(keys: [.return, .space]) { _ in
pick(text)
return .handled
}
.onHover { hover = $0 }
.padding(.top, 8)
}
private func pick(_ text: String) {
if model.busy {
return
}
if model.multiple {
model.toggle(text, !model.checked.contains(text))
return
}
model.submit(InboxResponseCreate(itemId: model.item.id, _type: .option, options: [text]))
}
}
private struct CardActions: View {
let model: CardModel
var focus: FocusState<String?>.Binding
var body: some View {
if model.item.kind == .question {
let disabled = model.typed.isEmpty && !model.hasImages && (!model.multiple || model.checked.isEmpty)
VStack(alignment: .leading, spacing: 6) {
TextEntry(model: model, label: "Other…", placeholder: "Your answer, a question back, or new instructions", shared: true, focus: focus) {
model.submitQuestion()
}
HStack(spacing: 8) {
Button("Let the agent decide") {
model.submit(InboxResponseCreate(itemId: model.item.id, _type: .other, text: delegateText))
}
.buttonStyle(WebButtonStyle())
Spacer()
Button("Submit") {
model.submitQuestion()
}
.buttonStyle(WebButtonStyle(primary: true))
.disabled(disabled)
}
.disabled(model.busy)
}
} else {
HStack(alignment: .top, spacing: 8) {
TextEntry(model: model, label: "Reply…", placeholder: "Reply to the agent", shared: false, focus: focus) {
model.submitOther()
}
Button("Dismiss") {
model.submit(InboxResponseCreate(itemId: model.item.id, _type: .dismiss))
}
.buttonStyle(WebButtonStyle())
.disabled(model.busy)
}
}
}
}
private struct TextEntry: View {
let model: CardModel
let label: String
let placeholder: String
let shared: Bool
var focus: FocusState<String?>.Binding
let go: () -> Void
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.paneAccent) private var accent
@FocusState private var editing: Bool
@ViewState private var hover = false
@ViewState private var targeted = false
var body: some View {
let id = model.item.id
let key = model.key
let text = Binding(get: { store.drafts?.textAnswer(id) ?? "" }, set: { v in
store.drafts?.setTextAnswer(id, v)
store.paneFocus.setDraft(key, !trimmed(v).isEmpty)
})
let focused = focus.wrappedValue == otherStop
Group {
if model.editorOpen {
VStack(alignment: .leading, spacing: 6) {
Thumbnails(key: key)
TextEditor(text: text)
.font(Fonts.font(13))
.scrollContentBackground(.hidden)
.focused($editing)
.disabled(model.busy)
.focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(key, pasteboard: pb, overlays) }))
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if press.modifiers.contains(.shift) || marked {
return .ignored
}
go()
return .handled
}
.padding(.horizontal, 5)
.padding(.vertical, 6)
.frame(height: 66)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .topLeading) {
if text.wrappedValue.isEmpty {
Text(placeholder)
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.allowsHitTesting(false)
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(targeted || editing ? Palette.accent : Palette.border))
.overlay {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(false)
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
store.images.add(key, providers: providers, overlays)
return true
}
}
if !shared {
HStack {
Spacer()
Button("Submit", action: go)
.buttonStyle(WebButtonStyle(primary: true))
.disabled(model.busy || (model.typed.isEmpty && !model.hasImages))
}
}
}
.onChange(of: editing) {
if !editing {
if trimmed(text.wrappedValue).isEmpty && !model.hasImages && !model.busy {
store.drafts?.dropTextAnswer(id)
}
store.drafts?.flush()
}
}
} else {
Text(label)
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(hover || focused ? Palette.accent : Palette.border))
.overlay {
if focused {
RoundedRectangle(cornerRadius: 6).stroke(Palette.accent, lineWidth: 2).padding(-2)
}
}
.opacity(model.busy ? 0.6 : 1)
.contentShape(Rectangle())
.focusable(!model.busy)
.focusEffectDisabled()
.focused(focus, equals: otherStop)
.onTapGesture {
open()
}
.onKeyPress(keys: [.return, .space]) { _ in
open()
return .handled
}
.onHover { hover = $0 }
}
}
.onDisappear {
store.paneFocus.setDraft(key, false)
}
}
private func open() {
if model.busy {
return
}
store.drafts?.setTextAnswer(model.item.id, "")
DispatchQueue.main.async {
editing = true
}
}
}
private struct Thread: View {
let item: InboxItem
var body: some View {
if !item.responses.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(item.responses, id: \.id) { r in
let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))"
let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)"
Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)")
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.font(Fonts.font(11))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
.padding(.vertical, 8)
}
}
}
private struct Attachments: View {
let list: [Attachment]
let taskId: String
@Environment(Store.self) private var store
var body: some View {
if !list.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(list.enumerated()), id: \.offset) { _, a in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•")
VStack(alignment: .leading, spacing: 2) {
content(a)
if let caption = a.caption, !caption.isEmpty {
Text(caption).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
}
}
}
.padding(.leading, 6)
.padding(.vertical, 6)
}
}
private func code(_ s: String) -> Text {
var a = AttributedString(s)
a.backgroundColor = Palette.surface
return Text(a)
}
@ViewBuilder
private func content(_ a: Attachment) -> some View {
switch a.kind {
case .file:
let lines = a.startLine.map { ":\($0)\(a.endLine.map { "-\($0)" } ?? "")" } ?? ""
code("\(a.path ?? "")\(lines)").textSelection(.enabled)
case .diff:
Text("diff \(code(a.ref ?? ""))").textSelection(.enabled)
case .image:
let src = (a.image?.isEmpty == false ? a.image : a.path) ?? ""
if let source = worktreeImageSource(src, taskId) {
ByreImage(source: source, src: src)
}
case .diagram:
DiagramView(source: a.mermaid ?? "")
case .task:
let id = a.taskId ?? ""
Button("task \(id)") {
store.focusTask(id)
}
.buttonStyle(.plain)
.foregroundStyle(store.taskColor(id) ?? Palette.accent)
case .snapshot:
Text("snapshot \(code(a.snapshotId ?? ""))").textSelection(.enabled)
case .url:
let url = a.url ?? ""
if httpURL.test(url), let u = URL(string: url) {
Link(url, destination: u).foregroundStyle(Palette.accent)
} else {
code(url).textSelection(.enabled)
}
}
}
}
+3 -1
View File
@@ -12,6 +12,8 @@ indirect enum MDInline: Sendable {
struct MDItem: Sendable {
var choice: String?
var choiceIndex = 0
var raw = ""
var inline: [MDInline] = []
var blocks: [MDBlock] = []
}
@@ -266,7 +268,7 @@ private func parseList(_ lines: [String], _ start: Int, _ choices: Bool) -> (MDB
}
private func item(_ body: [String], _ choices: Bool) -> MDItem {
var out = MDItem()
var out = MDItem(raw: body[0])
if choices, let m = choiceMarker.exec(body[0]) {
out.choice = m[2]
}
+18 -17
View File
@@ -167,27 +167,16 @@ private struct LeftPane: View {
.opacity(tab == .lead ? 1 : 0)
.allowsHitTesting(tab == .lead)
.accessibilityHidden(tab != .lead)
if tab != .lead {
NotYet(label: tab == .status ? "Status" : "Settings")
.background(Palette.bg)
if tab == .status {
StatusView()
} else if tab == .settings {
SettingsView()
}
}
}
}
}
private struct NotYet: View {
let label: String
var body: some View {
VStack {
Muted(text: "\(label) isn't in the macOS app yet.")
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct TaskPane: View {
@Environment(Store.self) private var store
@ViewState private var seen: Set<String> = []
@@ -228,8 +217,20 @@ private struct TaskPane: View {
case .transcript:
ChatView(kind: .task(store.selectedTaskId))
.id(store.selectedTaskId ?? "")
default:
NotYet(label: tabLabels[store.focus.tab]!)
case .changes:
ChangesView()
case .files:
FilesView()
case .terminal:
TerminalTab()
case .inbox:
InboxView()
case .reviews:
ReviewsView()
case .workflow:
WorkflowView()
case .project:
ProjectView()
}
}
}
+305
View File
@@ -0,0 +1,305 @@
import SwiftUI
typealias Todo = Components.Schemas.Todo
typealias TodoItem = Components.Schemas.TodoItem
private let refreshKinds: Set<String> = ["task.merged", "project.created"]
struct ProjectView: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var todos: [String: Result<Todo, ApiError>] = [:]
@ViewState private var off: (() -> Void)?
var body: some View {
ScrollView {
if let p = store.focusedProject() {
ProjectBlock(project: p, todo: todos[p.id])
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
} else {
Muted(text: "No project selected.")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
off = store.events.add { ev in
if refreshKinds.contains(ev.kind), let projectId = ev.projectId {
load(projectId)
}
}
refresh()
}
.onDisappear {
off?()
off = nil
}
.onChange(of: store.connected) {
if store.connected {
refresh()
}
}
.onChange(of: store.projects.map(\.id)) {
refresh()
}
.onChange(of: store.focusedProject()?.id) {
if let p = store.focusedProject(), todos[p.id] == nil {
load(p.id)
}
}
}
private func refresh() {
if let p = store.focusedProject() {
load(p.id)
}
}
private func load(_ projectId: String) {
Task {
do {
todos[projectId] = .success(try await store.api.client.getTodo(path: .init(projectId: projectId)).ok.body.json)
} catch {
todos[projectId] = .failure(ApiError(status: 0, message: "Couldn't read TODO.md: \(errorText(error))"))
}
}
}
}
private struct ProjectBlock: View {
let project: Project
let todo: Result<Todo, ApiError>?
@Environment(Store.self) private var store
var body: some View {
let active = (store.tasks[project.id] ?? []).filter { $0.status == .active }
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(project.name).font(Fonts.font(13, weight: 700))
Text(project.defaultBranch).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
.padding(.top, 16)
.padding(.bottom, 6)
heading("Tasks")
TaskRows(active: active)
heading("TODO")
TodoBlock(project: project, todo: todo, active: active)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func heading(_ text: String) -> some View {
Text(text)
.font(Fonts.font(12, weight: 700))
.padding(.top, 14)
.padding(.bottom, 4)
}
}
private func nested(_ active: [ByreTask]) -> [(task: ByreTask, depth: Int)] {
let ids = Set(active.map(\.id))
var out: [(task: ByreTask, depth: Int)] = []
func add(_ task: ByreTask, _ depth: Int) {
out.append((task, depth))
for child in active.filter({ $0.parentTaskId == task.id }).reversed() {
add(child, depth + 1)
}
}
for task in active where task.parentTaskId == nil || !ids.contains(task.parentTaskId!) {
add(task, 0)
}
return out
}
private struct TaskRows: View {
let active: [ByreTask]
@Environment(Store.self) private var store
var body: some View {
if active.isEmpty {
Text("No active tasks.")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
} else {
VStack(alignment: .leading, spacing: 0) {
ForEach(nested(active), id: \.task.id) { row in
TaskRow(task: row.task, depth: row.depth)
}
}
.padding(.bottom, 8)
}
}
}
struct HoverRow<Content: View>: View {
var selected = false
var indent: CGFloat = 0
let action: () -> Void
@ViewBuilder let content: Content
@ViewState private var hover = false
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
content
}
.padding(.leading, 10 + indent)
.padding(.trailing, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selected ? Palette.accentTint : hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hover = $0 }
}
}
private struct TaskRow: View {
let task: ByreTask
let depth: Int
@Environment(Store.self) private var store
var body: some View {
let ts = store.taskState(task)
let color = taskPalette[task.color]
let count = store.inbox.filter { $0.taskId == task.id }.count
HoverRow(selected: task.id == store.selectedTaskId, indent: task.parentTaskId != nil ? 20 * CGFloat(depth - 1) : 0) {
if store.unreadSummary(task) {
store.openSummary(task)
} else {
store.focusTask(task.id)
}
} content: {
if let parent = task.parentTaskId {
Text("↳")
.foregroundStyle(Palette.muted)
.help("Subtask of \(store.findTask(parent)?.title ?? parent)")
}
StateMark(state: ts, color: color)
Text(task.title)
.foregroundStyle(color)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
if count > 0 {
Badge(count: count).help("inbox items")
}
if store.unreadSummary(task) {
Text("new summary")
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.accent)
.help("A turn summary you haven't read")
.accessibilityLabel("New summary from \(task.title), unread")
}
Text(ts.label)
.font(Fonts.font(11))
.foregroundStyle(ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : Palette.muted)
.help(ts.title)
}
}
}
private struct TodoBlock: View {
let project: Project
let todo: Result<Todo, ApiError>?
let active: [ByreTask]
var body: some View {
switch todo {
case .none:
row("Loading…")
case .failure(let e):
row(e.message, error: true)
case .success(let t):
if !t.exists {
row("No TODO.md on \(project.defaultBranch).")
} else if t.items.isEmpty {
row("TODO.md has no items.")
} else {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(t.items.enumerated()), id: \.offset) { i, item in
if !item.section.isEmpty && (i == 0 || t.items[i - 1].section != item.section) {
Text(item.section)
.font(Fonts.font(12, weight: 700))
.foregroundStyle(Palette.muted)
.padding(.top, 10)
.padding(.bottom, 4)
}
TodoRow(project: project, item: item, task: active.first { $0.todoItem == item.title })
}
}
}
}
}
private func row(_ text: String, error: Bool = false) -> some View {
Text(text)
.foregroundStyle(error ? Palette.danger : Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
}
private struct TodoRow: View {
let project: Project
let item: TodoItem
let task: ByreTask?
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
Text(item.title)
.foregroundStyle(task.map { taskPalette[$0.color] } ?? Palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
action
}
if !item.body.isEmpty {
MarkdownView(blocks: parseMarkdown(item.body), ctx: MDContext(color: Palette.muted))
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(task != nil && task?.id == store.selectedTaskId ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 6))
}
@ViewBuilder
private var action: some View {
if let task {
let ts = store.taskState(task)
Button {
store.focusTask(task.id)
} label: {
HStack(spacing: 6) {
StateMark(state: ts, color: taskPalette[task.color])
Text(ts.label)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.fixedSize()
} else {
let busy = store.todoStarting.contains("\(project.id)\n\(item.title)")
HStack(spacing: 6) {
Button(busy ? "Starting…" : "Start") {
store.startTodo(project.id, item.title, discuss: false, overlays)
}
.help("Start a task that gets straight to work")
Button("Discuss") {
store.startTodo(project.id, item.title, discuss: true, overlays)
}
.help("Start a task that discusses it with you in chat before building anything")
}
.buttonStyle(WebButtonStyle())
.disabled(busy)
.fixedSize()
}
}
}
+447
View File
@@ -0,0 +1,447 @@
import SwiftUI
typealias FindingState = Components.Schemas.FindingState
private let findingHead = Re(#"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@"#)
private func stateText(_ r: Review) -> String {
switch r.state {
case .running: "reviewing on \(r.vendor) (\(r.model))…"
case .done: reviewCounts(r)
case .failed: "failed"
case .skipped: "skipped: no changes"
}
}
@MainActor
private enum StepPatches {
static var changes: [String: Task<TaskChanges, any Error>] = [:]
static var patches: [String: Task<String?, any Error>] = [:]
static func patch(_ api: API, _ taskId: String, _ n: Int, _ path: String) async throws -> String? {
let key = "\(taskId)/\(n)/\(path)"
if let cached = patches[key] {
return try await cached.value
}
let fetched = Task<String?, any Error> {
let ck = "\(taskId)/\(n)"
let pending = changes[ck] ?? Task {
try await api.client.getTaskChanges(path: .init(taskId: taskId), query: .init(snapshot: n, step: true)).ok.body.json
}
changes[ck] = pending
let c: TaskChanges
do {
c = try await pending.value
} catch {
changes[ck] = nil
throw error
}
guard let f = c.files.first(where: { $0.path == path }), !f.binary else {
return nil
}
return try await 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
}
patches[key] = fetched
do {
return try await fetched.value
} catch {
patches[key] = nil
throw error
}
}
}
private func linesAround(_ patch: String, _ line: Int, _ end: Int) -> [(kind: PatchLine, text: String, at: Int)] {
var lines: [(kind: PatchLine, text: String, at: Int)] = []
var n = 0
var inHunk = false
for l in patch.components(separatedBy: "\n") {
if let m = findingHead.exec(l) {
n = Int(m[1]!) ?? 0
inHunk = true
continue
}
if !inHunk {
continue
}
if l.hasPrefix("+") {
lines.append((.add, l, n))
n += 1
} else if l.hasPrefix("-") {
lines.append((.del, l, n))
} else if l.hasPrefix(" ") {
lines.append((.ctx, l, n))
n += 1
}
}
return lines.filter { $0.at >= line - 3 && $0.at <= end + 3 }
}
struct ReviewsView: View {
@Environment(Store.self) private var store
@ViewState private var reviews: [Review]?
@ViewState private var error = ""
@ViewState private var target = 0
var body: some View {
VStack(spacing: 0) {
content
}
.frame(maxHeight: .infinity, alignment: .top)
.task(id: store.selectedTaskId) {
target += 1
reviews = nil
error = ""
await load()
let off = store.selectedFeed?.appended.add { events in
if events.contains(where: isReviewEvent) {
Task {
await load()
}
}
}
await untilCancelled()
off?()
target += 1
}
}
private func load() async {
guard let taskId = store.selectedTaskId else {
return
}
target += 1
let started = target
do {
let fresh = try await store.api.client.listReviews(path: .init(taskId: taskId)).ok.body.json
if started == target {
reviews = fresh
error = ""
}
} catch {
if started == target {
reviews = nil
self.error = errorText(error)
}
}
}
@ViewBuilder
private var content: some View {
if let taskId = store.selectedTaskId {
if !error.isEmpty {
EmptyText(text: error, error: true)
} else if let reviews {
if let lastReview = reviews.last {
let n = store.reviewed ?? lastReview.snapshotN
let shown = reviews.last { $0.snapshotN == n }
let ns = Array(Set([n] + reviews.map(\.snapshotN))).sorted(by: >)
let next = ns.filter { $0 > n }.last
let prev = ns.first { $0 < n }
HStack(spacing: 8) {
Picker("", selection: Binding(get: { n }, set: { store.pickReview($0) })) {
ForEach(ns, id: \.self) { s in
Text("snapshot #\(s)").tag(s)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Snapshot whose review to show")
step(prev ?? n - 1, "← #\(prev ?? n - 1)", ns)
step(next ?? n + 1, "#\(next ?? n + 1) →", ns)
}
.modifier(HeaderBar())
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if let shown {
ReviewCard(review: shown, taskId: taskId)
} else {
Muted(text: "No review of snapshot #\(n) yet.")
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
}
} else {
EmptyText(text: "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor.")
}
} else {
EmptyText(text: "Loading…")
}
} else {
EmptyText(text: "No task selected.")
}
}
private func step(_ to: Int, _ label: String, _ ns: [Int]) -> some View {
Button(label) {
store.pickReview(to)
}
.buttonStyle(SmallLinkButtonStyle(color: Palette.text))
.disabled(!ns.contains(to))
}
}
struct ReviewCard: View {
let review: Review
let taskId: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("snapshot #\(review.snapshotN)")
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.text)
Text(stateText(review))
.foregroundStyle(review.state == .failed ? Palette.warn : Palette.muted)
Spacer(minLength: 0)
Text(ago(review.createdAt))
.help(isoText(review.createdAt))
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
if review.state == .failed {
Text(review.error)
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
}
if review.state == .done {
ReviewResult(review: review, taskId: taskId)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
}
}
private struct ReviewResult: View {
let review: Review
let taskId: String?
var body: some View {
let res = review.result?.value ?? [:]
let fs = findings(review)
let md = MDContext(taskId: taskId)
VStack(alignment: .leading, spacing: 8) {
MarkdownView(blocks: parseMarkdown(jsString(res["summary"] ?? "")), ctx: md)
ReviewImages(value: res["images"] ?? nil)
if !fs.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(fs.enumerated()), id: \.offset) { i, f in
FindingRow(review: review, index: i, finding: f, taskId: taskId)
}
}
}
}
}
}
private struct FindingRow: View {
let review: Review
let index: Int
let finding: JSONDict
let taskId: String?
var body: some View {
let f = finding
let severity = jsString(f["severity"] ?? "")
let path = str(f, "path") ?? ""
let line = max(0, int(f, "line") ?? 0)
let endRaw = int(f, "end_line") ?? 0
let end = endRaw > line ? endRaw : line
let whereText = path.isEmpty ? "" : "\(path)\(line > 0 ? ":\(line)\(end > line ? "–\(end)" : "")" : "")"
let md = MDContext(taskId: taskId)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(severity)
.font(Fonts.font(11, weight: 700))
.foregroundStyle(severity == "critical" || severity == "high" ? Palette.danger : severity == "medium" ? Palette.warn : Palette.muted)
if let category = str(f, "category"), !category.isEmpty {
Text(category).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
Text(jsString(f["title"] ?? ""))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
if !whereText.isEmpty {
Text(whereText).font(Fonts.font(11)).foregroundStyle(Palette.muted).textSelection(.enabled)
}
Spacer(minLength: 0)
if let taskId {
FindingActions(taskId: taskId, review: review, index: index, finding: f, whereText: whereText)
}
}
if let taskId, !path.isEmpty, line > 0 {
FindingDiff(taskId: taskId, n: review.snapshotN, path: path, line: line, end: end)
}
MarkdownView(blocks: parseMarkdown(jsString(f["detail"] ?? "")), ctx: md)
ReviewImages(value: f["images"] ?? nil)
}
.padding(.top, 6)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
private struct ReviewImages: View {
let value: (any Sendable)?
var body: some View {
let imgs = (value as? [(any Sendable)?] ?? []).map { $0 as? JSONDict ?? [:] }
if !imgs.isEmpty {
FlowLayout(spacing: 10) {
ForEach(Array(imgs.enumerated()), id: \.offset) { _, img in
let blob = jsString(img["blob"] ?? "")
let caption = str(img, "caption") ?? ""
VStack(alignment: .leading, spacing: 2) {
ByreImage(source: .blob(blob), src: "/api/blobs/\(blob)", maxHeight: 360)
if !caption.isEmpty {
Text(caption).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
}
}
.padding(.vertical, 8)
}
}
}
private struct FindingDiff: View {
let taskId: String
let n: Int
let path: String
let line: Int
let end: Int
@Environment(Store.self) private var store
@ViewState private var rows: [DiffRow]?
@ViewState private var failure: String?
var body: some View {
Group {
if let failure {
Text("diff not available: \(failure)")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(DiffBox())
} else if let rows, !rows.isEmpty {
DiffLines(rows: rows)
.modifier(DiffBox())
}
}
.task(id: "\(taskId)/\(n)/\(path)/\(line)/\(end)") {
do {
let patch = try await StepPatches.patch(store.api, taskId, n, path)
let lines = patch.map { linesAround($0, line, end) } ?? []
var out: [DiffRow] = []
for (i, d) in lines.enumerated() {
if i > 0 {
let prev = lines[i - 1]
if d.kind != .del && prev.kind != .del && d.at > prev.at + 1 {
out.append(DiffRow(text: "⋯", kind: .hunk))
}
}
let mark = d.kind != .del && d.at >= line && d.at <= end
out.append(DiffRow(text: d.text, kind: d.kind, lineno: d.kind == .del ? "" : String(d.at), mark: mark))
}
rows = out
} catch {
failure = errorText(error)
}
}
}
}
private struct DiffBox: ViewModifier {
func body(content: Content) -> some View {
content
.background(Palette.bg)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
.padding(.top, 2)
}
}
private struct FindingActions: View {
let taskId: String
let review: Review
let index: Int
let finding: JSONDict
let whereText: String
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var done: FindingState?
@ViewState private var busy = false
var body: some View {
if let s = done ?? review.findingStates.additionalProperties[String(index)] {
Text(s == .fixed ? "fix sent" : "ignored")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
} else {
HStack(spacing: 6) {
Button("fix") {
fix()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Send this finding to the task's agent to fix")
Button("ignore") {
ignore()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Mark this finding done without fixing it; Merge won't warn about it")
}
.disabled(busy)
}
}
private func mark(_ s: FindingState) async throws {
_ = try await store.api.client.setFindingState(path: .init(reviewId: review.id, index: index), body: .json(.init(state: s))).ok
}
private func fix() {
let f = finding
let text = "Fix this finding from the review of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))"
busy = true
Task {
do {
let sessions = try await store.api.client.listSessions(path: .init(taskId: taskId)).ok.body.json
if let live = sessions.last(where: { $0.state != .completed }) {
_ = try await store.api.client.sendMessage(path: .init(sessionId: live.id), body: .json(.init(text: text))).accepted
} else {
_ = try await store.api.client.createSession(path: .init(taskId: taskId), body: .json(.init(prompt: text))).created
}
} catch {
busy = false
overlays.showAlert(errorText(error))
return
}
done = .fixed
do {
try await mark(.fixed)
} catch {
overlays.showAlert("fix sent, but not recorded: \(errorText(error))")
}
}
}
private func ignore() {
busy = true
Task {
do {
try await mark(.ignored)
done = .ignored
} catch {
busy = false
overlays.showAlert(errorText(error))
}
}
}
}
+250
View File
@@ -0,0 +1,250 @@
import SwiftUI
import UserNotifications
typealias LeadNote = Components.Schemas.LeadNote
typealias NotificationPrefs = Components.Schemas.NotificationPrefs
@MainActor
private let kinds: [(key: WritableKeyPath<NotificationPrefs, Bool>, label: String)] = [
(\.questions, "Questions"),
(\.reports, "Turn summaries"),
(\.errors, "Session errors"),
(\.rateLimits, "Rate limits"),
]
@MainActor
private var notesProject = ""
struct SettingsView: View {
@Environment(Store.self) private var store
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
heading("Notifications")
DeviceNotifications()
PrefsBox()
heading("What your lead has learned")
Text("Your lead notes your priorities and preferences as it works with you, and starts every session with them.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
LeadNotes()
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.bg)
}
private func heading(_ text: String) -> some View {
Text(text)
.font(Fonts.font(13, weight: 700))
.padding(.top, 16)
.padding(.bottom, 6)
}
}
private struct DeviceNotifications: View {
@ViewState private var status: UNAuthorizationStatus?
@ViewState private var asking = false
var body: some View {
Group {
switch status {
case .none:
EmptyView()
case .denied:
Text("Notifications are blocked for byre in System Settings.").foregroundStyle(Palette.muted)
case .authorized, .provisional:
Text("This Mac shows notifications while byre is running.")
default:
VStack(alignment: .leading, spacing: 8) {
Text("This Mac doesn't show notifications from byre.")
Button("Turn on for this Mac") {
asking = true
Task {
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])
await read()
asking = false
}
}
.buttonStyle(WebButtonStyle())
.disabled(asking)
}
}
}
.padding(.bottom, 8)
.task {
await read()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task {
await read()
}
}
}
private func read() async {
status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
}
}
private struct PrefsBox: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
if let me = store.me {
VStack(alignment: .leading, spacing: 4) {
Text("Notify me about")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 4)
.background(Palette.bg)
.offset(y: -18)
.padding(.bottom, -18)
ForEach(kinds, id: \.label) { kind in
Toggle(kind.label, isOn: Binding(get: { me.notificationPrefs[keyPath: kind.key] }, set: { on in
var prefs = me.notificationPrefs
prefs[keyPath: kind.key] = on
Task {
do {
store.me = try await store.api.client.updateNotificationPrefs(body: .json(prefs)).ok.body.json
} catch {
overlays.showAlert(errorText(error))
}
}
}))
.toggleStyle(.checkbox)
.tint(Palette.accent)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Palette.border))
.padding(.top, 16)
}
}
}
private struct LeadNotes: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var list: [LeadNote]?
@ViewState private var failure: String?
@ViewState private var loads = 0
@ViewState private var project = notesProject
@ViewState private var off: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let list {
if list.isEmpty {
hint("Your lead hasn't noted anything about how you work yet.")
} else {
let groups = [("", "Every project")] + store.projects.map { ($0.id, $0.name) }
let current = groups.contains { $0.0 == project } ? project : ""
let shown = list.filter { ($0.projectId ?? "") == current }
Picker("", selection: Binding(get: { current }, set: { v in
project = v
notesProject = v
})) {
ForEach(groups, id: \.0) { g in
Text("\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))").tag(g.0)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Which notes to show")
.padding(.bottom, 8)
if shown.isEmpty {
hint("No notes here yet.")
} else {
ForEach(shown, id: \.id) { note in
NoteRow(note: note)
}
}
}
} else if let failure {
hint(failure)
}
}
.padding(.top, 8)
.onAppear {
off = store.events.add { ev in
if ev.kind == "lead.note_saved" || ev.kind == "lead.note_deleted" {
load()
}
}
load()
}
.onDisappear {
off?()
off = nil
loads += 1
}
}
private func hint(_ text: String) -> some View {
Text(text).foregroundStyle(Palette.muted)
}
private func load() {
loads += 1
let n = loads
Task {
do {
let loaded = try await store.api.client.listLeadNotes().ok.body.json
if n == loads {
list = loaded
}
} catch {
failure = errorText(error)
list = nil
}
}
}
}
private struct NoteRow: View {
let note: LeadNote
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var frame: CGRect = .zero
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(note.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Button("delete") {
Task {
if !(await overlays.showConfirm(["Delete this note? Your lead stops applying it.", note.text], action: "Delete", danger: true, anchor: frame)) {
return
}
do {
_ = try await store.api.client.deleteLeadNote(path: .init(noteId: note.id)).noContent
} catch {
overlays.showAlert(errorText(error))
}
}
}
.buttonStyle(SmallLinkButtonStyle())
.onGeometryChange(for: CGRect.self) { g in
g.frame(in: .named("window"))
} action: { f in
frame = f
}
}
.padding(.vertical, 6)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
+397
View File
@@ -0,0 +1,397 @@
import SwiftUI
typealias AgentProcess = Components.Schemas.AgentProcess
typealias Zombie = Components.Schemas.Zombie
private let refreshKinds: Set<String> = ["session.created", "session.state", "task.created", "task.merged", "task.discarded", "project.created", "admin.health"]
private let zombieLabel: [Zombie.KindPayload: String] = [
.runningWithoutProcess: "Running with no process",
.processWithoutSession: "Process with no session",
]
private func since(_ date: Date) -> String {
let seconds = max(0, Int(Date().timeIntervalSince(date).rounded()))
if seconds < 60 {
return "\(seconds)s ago"
}
if seconds < 3600 {
return "\(seconds / 60)m \(seconds % 60)s ago"
}
return ago(date)
}
private func bytes(_ n: Int64) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var i = 0
var v = Double(n)
while v >= 1024 && i < units.count - 1 {
v /= 1024
i += 1
}
return String(format: i == 0 ? "%.0f %@" : "%.1f %@", v, units[i])
}
private func resetText(_ unixSeconds: Double) -> String {
let at = Date(timeIntervalSince1970: unixSeconds)
let time = at.formatted(Date.FormatStyle().weekday(.abbreviated).hour().minute())
let minutes = Int((at.timeIntervalSinceNow / 60).rounded())
if minutes <= 0 {
return "\(time) (passed)"
}
let rel = minutes < 60 ? "\(minutes)m" : minutes < 1440 ? "\(minutes / 60)h \(minutes % 60)m" : "\(minutes / 1440)d \((minutes % 1440) / 60)h"
return "\(time) (in \(rel))"
}
private func number(_ v: (any Sendable)??) -> Double? {
switch v {
case .some(.some(let x)):
if let d = x as? Double {
return d
}
if let i = x as? Int {
return Double(i)
}
return nil
default:
return nil
}
}
struct StatusView: View {
@Environment(Store.self) private var store
@ViewState private var status: ServerStatus?
@ViewState private var error = ""
@ViewState private var inFlight = false
@ViewState private var again = false
@ViewState private var off: (() -> Void)?
var body: some View {
Group {
if let status {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if !error.isEmpty {
Text(error).foregroundStyle(Palette.danger).padding(.top, 8)
}
Problems(status: status)
section("Running turns")
TurnList(status: status)
section("Rate limits")
let vendors = status.rateLimits.additionalProperties.keys.sorted()
VStack(alignment: .leading, spacing: 8) {
ForEach(vendors, id: \.self) { v in
RateLimitCard(vendor: v, info: status.rateLimits.additionalProperties[v]!.value)
}
}
if vendors.isEmpty {
Text("No vendor has reported a rate-limit status since byre started.").foregroundStyle(Palette.muted)
}
section("Server")
ServerRows(status: status)
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
EmptyText(text: error.isEmpty ? "Loading…" : error, error: !error.isEmpty)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.bg)
.onAppear {
off = store.events.add { ev in
event(ev)
}
refresh()
}
.onDisappear {
off?()
off = nil
}
.onChange(of: store.connected) {
if store.connected {
refresh()
}
}
}
private func section(_ title: String) -> some View {
Text(title)
.font(Fonts.font(13, weight: 700))
.padding(.top, 16)
.padding(.bottom, 8)
}
private func refresh() {
if inFlight {
again = true
return
}
inFlight = true
Task {
do {
status = try await store.api.client.getStatus().ok.body.json
error = ""
} catch {
self.error = "Couldn't load status: \(errorText(error))"
}
inFlight = false
if again {
again = false
refresh()
}
}
}
private func event(_ ev: ByreEvent) {
if refreshKinds.contains(ev.kind) || (ev.kind == "agent.vendor" && ev.payload.value["type"] as? String == "rate_limit_event") {
refresh()
return
}
guard var s = status else {
return
}
var changed = false
if let i = s.agentProcesses.firstIndex(where: { $0.sessionId == ev.agentSessionId }), ev.createdAt.timeIntervalSince(s.agentProcesses[i].lastEventAt) >= 1 {
s.agentProcesses[i].lastEventAt = ev.createdAt
changed = true
}
if ev.seq > s.lastEventSeq {
s.lastEventSeq = ev.seq
changed = true
}
if changed {
status = s
}
}
}
private struct TaskLinkText: View {
let taskId: String
@Environment(Store.self) private var store
var body: some View {
let task = store.findTask(taskId)
let project = task.flatMap { t in store.projects.first { $0.id == t.projectId } }
Button {
store.focusTask(taskId)
} label: {
Text("\(project.map { "\($0.name) ▸ " } ?? "")\(task?.title ?? taskId)")
.foregroundStyle(store.taskColor(taskId) ?? Palette.accent)
}
.buttonStyle(.plain)
}
}
private struct Problems: View {
let status: ServerStatus
var body: some View {
let pe = status.protocolErrors
let vendors = pe.counts.additionalProperties.keys.sorted()
if !status.zombies.isEmpty || !vendors.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Problems")
.font(Fonts.font(13, weight: 700))
.padding(.top, 16)
.padding(.bottom, 4)
ForEach(Array(status.zombies.enumerated()), id: \.offset) { _, z in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(zombieLabel[z.kind]!).foregroundStyle(Palette.danger)
if let taskId = z.taskId {
TaskLinkText(taskId: taskId)
}
Text(z.detail).foregroundStyle(Palette.muted).textSelection(.enabled)
}
}
ForEach(vendors, id: \.self) { v in
let n = pe.counts.additionalProperties[v] ?? 0
VStack(alignment: .leading, spacing: 2) {
Text("\(v): \(n) bad stream line\(n == 1 ? "" : "s")").foregroundStyle(Palette.danger)
if let latest = pe.recent.first(where: { $0.vendor == v }) {
DetailsBox(boxed: false) {
Text("latest \(since(latest.at)): \(latest.reason)").foregroundStyle(Palette.muted)
} content: {
PreBox(text: latest.raw + (latest.raw.utf8.count < latest.size ? "… (\(bytes(Int64(latest.size))))" : ""), maxHeight: 180, size: 11)
.padding(.top, 4)
}
}
}
}
}
}
}
}
private struct TurnList: View {
let status: ServerStatus
@Environment(Store.self) private var store
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(status.runningTurns, id: \.sessionId) { t in
let task = store.findTask(t.taskId)
let project = task.flatMap { tk in store.projects.first { $0.id == tk.projectId } }
let color = store.taskColor(t.taskId) ?? Palette.accent
HoverRow {
store.focusTask(t.taskId)
} content: {
StateDot(filled: Palette.ok).help("running")
HStack(spacing: 0) {
if let project {
Text("\(project.name) ▸ ").foregroundStyle(Palette.muted)
}
Text(task?.title ?? t.taskId).foregroundStyle(color)
}
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
if let p = status.agentProcesses.first(where: { $0.sessionId == t.sessionId }) {
Text(p.health == .ok ? since(p.lastEventAt) : "\(p.health.rawValue) · \(since(p.lastEventAt))")
.font(Fonts.font(11))
.foregroundStyle(p.health == .ok ? Palette.muted : Palette.danger)
}
Text(t.vendor).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
if status.runningTurns.isEmpty {
Text("No turns running.")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
}
}
}
private struct RateLimitCard: View {
let vendor: String
let info: JSONDict
var body: some View {
let statusText = info["status"].map { jsString($0) } ?? "unknown"
let tone = statusText == "allowed" ? Palette.ok : statusText == "rejected" ? Palette.danger : Palette.warn
let windows = dict(info, "unifiedWindows") ?? [:]
let rest = info.filter { !["status", "rateLimitType", "resetsAt", "unifiedWindows"].contains($0.key) }.sorted { $0.key < $1.key }
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(vendor).font(Fonts.font(13, weight: 700)).foregroundStyle(Palette.text)
Text(statusText).font(Fonts.font(11, weight: 700)).foregroundStyle(tone)
if let type = info["rateLimitType"] {
Text(jsString(type))
}
Spacer(minLength: 0)
if let resets = number(info["resetsAt"]) {
Text("resets \(resetText(resets))")
}
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
if !windows.isEmpty {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 2) {
GridRow {
ForEach(["Window", "Used", "Elapsed", "Pace", "Resets"], id: \.self) { h in
Text(h).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
Divider().overlay(Palette.border)
ForEach(windows.keys.sorted(), id: \.self) { name in
let w = dict(windows, name) ?? [:]
let used = number(w["utilization"])
let elapsed = elapsedFraction(w)
GridRow {
Text(name)
Text(used.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
.foregroundStyle(used.map { $0 >= 0.9 ? Palette.danger : $0 >= 0.7 ? Palette.warn : Palette.text } ?? Palette.text)
Text(elapsed.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
PaceBar(used: used, elapsed: elapsed)
.gridColumnAlignment(.leading)
Text(number(w["resetsAt"]).map(resetText) ?? "—")
}
Divider().overlay(Palette.border)
}
}
}
if !rest.isEmpty {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 2) {
ForEach(rest, id: \.key) { k, v in
GridRow {
Text(k).foregroundStyle(Palette.muted)
Text(v is JSONDict || v is [(any Sendable)?] ? compactJSONText(v) : jsString(v)).textSelection(.enabled)
}
}
}
}
}
}
private func elapsedFraction(_ w: JSONDict) -> Double? {
guard let resets = number(w["resetsAt"]), let mins = number(w["windowMins"]) else {
return nil
}
let remaining = resets * 1000 - Date().timeIntervalSince1970 * 1000
return min(1, max(0, 1 - remaining / (mins * 60000)))
}
}
private func compactJSONText(_ v: (any Sendable)?) -> String {
prettyJSON(v).components(separatedBy: "\n").map { trimmed($0) }.joined(separator: " ")
}
private struct PaceBar: View {
let used: Double?
let elapsed: Double?
var body: some View {
if let used, let elapsed {
let diff = Int(((used - elapsed) * 100).rounded())
let title = diff > 0 ? "\(diff) points ahead of an even pace" : diff < 0 ? "\(-diff) points behind an even pace" : "on an even pace"
GeometryReader { g in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2).fill(Palette.surface)
Rectangle()
.fill((used > elapsed ? Palette.warn : Palette.ok).opacity(0.55))
.frame(width: g.size.width * min(1, used))
RoundedRectangle(cornerRadius: 2).stroke(Palette.border)
Rectangle()
.fill(Palette.text)
.frame(width: 2, height: g.size.height + 6)
.offset(x: g.size.width * elapsed - 1)
}
}
.frame(minWidth: 80, maxWidth: .infinity)
.frame(height: 10)
.help(title)
} else {
Text("—")
}
}
}
private struct ServerRows: View {
let status: ServerStatus
var body: some View {
let rows: [(String, String)] = [
("Started", "\(status.startedAt.formatted(date: .numeric, time: .standard)) (\(ago(status.startedAt)))"),
("Agent processes", String(status.liveAgents)),
("Projects", String(status.projects)),
("Active tasks", String(status.activeTasks)),
("Live clients", String(status.sseClients)),
("Database", bytes(status.dbBytes)),
("Last event", "#\(status.lastEventSeq)"),
]
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 2) {
ForEach(rows, id: \.0) { k, v in
GridRow {
Text(k).foregroundStyle(Palette.muted)
Text(v).textSelection(.enabled)
}
}
}
.padding(.top, 8)
}
}
+134 -25
View File
@@ -4,16 +4,70 @@ import SwiftUI
let exitedCode = 4000
let internalErrorCode = 1011
private let retryAfter = Duration.seconds(2)
struct TerminalTab: View {
@Environment(Store.self) private var store
@ViewState private var status = ""
@ViewState private var exited = false
@ViewState private var shell = 0
var body: some View {
let taskId = store.selectedTaskId
let task = taskId.flatMap { store.findTask($0) }
VStack(spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text("Shell in the task's worktree")
.foregroundStyle(Palette.muted)
.frame(maxWidth: .infinity, alignment: .leading)
Text(status)
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.lineLimit(1)
if exited {
Button("New shell") {
shell += 1
}
.buttonStyle(WebButtonStyle())
}
}
.modifier(HeaderBar())
Group {
if let taskId, task?.status == .active {
TerminalHost(api: store.api, taskId: taskId, active: { [weak store] in
store?.findTask(taskId)?.status == .active
}, show: { text, ended in
status = text
exited = ended
})
.id("\(taskId) \(shell)")
} else if taskId == nil {
EmptyText(text: "No task selected.")
} else {
EmptyText(text: "The task is closed; its worktree is gone.")
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.bg)
}
.onChange(of: "\(taskId ?? "") \(task?.status.rawValue ?? "")", initial: true) {
status = ""
exited = false
}
}
}
struct TerminalHost: NSViewRepresentable {
let api: API
let taskId: String
@Binding var status: String
let active: @MainActor () -> Bool
let show: @MainActor (String, Bool) -> Void
@Environment(\.colorScheme) private var scheme
func makeCoordinator() -> TerminalSession {
TerminalSession(api: api, taskId: taskId) { s in
status = s
}
TerminalSession(api: api, taskId: taskId, active: active, show: show)
}
func makeNSView(context: Context) -> TerminalView {
@@ -22,13 +76,34 @@ struct TerminalHost: NSViewRepresentable {
t.terminalDelegate = context.coordinator
context.coordinator.view = t
DispatchQueue.main.async {
colour(t)
t.window?.makeFirstResponder(t)
context.coordinator.connect()
}
return t
}
func updateNSView(_ nsView: TerminalView, context: Context) {}
func updateNSView(_ nsView: TerminalView, context: Context) {
_ = scheme
DispatchQueue.main.async {
colour(nsView)
}
}
private func colour(_ t: TerminalView) {
func resolved(_ c: SwiftUI.Color) -> NSColor {
var out = NSColor(c)
t.effectiveAppearance.performAsCurrentDrawingAppearance {
out = NSColor(c).usingColorSpace(.sRGB) ?? NSColor(c)
}
return out
}
t.nativeBackgroundColor = resolved(Palette.bg)
t.nativeForegroundColor = resolved(Palette.text)
t.caretColor = resolved(Palette.accent)
t.caretTextColor = resolved(Palette.bg)
t.selectedTextBackgroundColor = resolved(Palette.accentTint)
}
static func dismantleNSView(_ nsView: TerminalView, coordinator: TerminalSession) {
coordinator.close()
@@ -36,46 +111,64 @@ struct TerminalHost: NSViewRepresentable {
}
@MainActor
final class TerminalSession: NSObject, TerminalViewDelegate {
final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocketDelegate {
let api: API
let taskId: String
let show: (String) -> Void
let active: @MainActor () -> Bool
let show: @MainActor (String, Bool) -> Void
weak var view: TerminalView?
private var ws: URLSessionWebSocketTask?
private var retry: Task<Void, Never>?
private var done = false
init(api: API, taskId: String, show: @escaping (String) -> Void) {
init(api: API, taskId: String, active: @escaping @MainActor () -> Bool, show: @escaping @MainActor (String, Bool) -> Void) {
self.api = api
self.taskId = taskId
self.active = active
self.show = show
}
func connect() {
guard let view else {
guard let view, !done else {
return
}
retry?.cancel()
show("connecting…", false)
let t = view.terminalDimensions
let wss = api.url("/api/tasks/\(taskId)/terminal", [URLQueryItem(name: "cols", value: String(t.cols)), URLQueryItem(name: "rows", value: String(t.rows))], scheme: "wss")
let task = api.session.webSocketTask(with: wss)
let url = api.url("/api/tasks/\(taskId)/terminal", [URLQueryItem(name: "cols", value: String(t.cols)), URLQueryItem(name: "rows", value: String(t.rows))], scheme: "wss")
let task = api.session.webSocketTask(with: url)
task.delegate = self
ws = task
show("connecting…")
task.resume()
receive(task)
}
func close() {
done = true
retry?.cancel()
ws?.cancel(with: .normalClosure, reason: nil)
ws = nil
}
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
let id = ObjectIdentifier(webSocketTask)
Task { @MainActor in
guard let ws = self.ws, ObjectIdentifier(ws) == id, !self.done else {
return
}
self.view?.resetToInitialState()
self.show("", false)
}
}
private func receive(_ task: URLSessionWebSocketTask) {
task.receive { result in
Task { @MainActor in
guard self.ws === task else {
guard self.ws === task, !self.done else {
return
}
switch result {
case .success(.data(let d)):
self.show("")
self.view?.feed(byteArray: [UInt8](d)[...])
self.receive(task)
case .success(.string(let s)):
@@ -83,22 +176,38 @@ final class TerminalSession: NSObject, TerminalViewDelegate {
self.receive(task)
case .success:
self.receive(task)
case .failure(let error):
let code = task.closeCode.rawValue
let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? ""
if code == exitedCode {
self.show("shell ended: \(reason)")
} else if code == internalErrorCode {
self.show(reason)
} else {
self.show("disconnected (\(code) \(error.localizedDescription))")
}
self.ws = nil
case .failure:
self.closed(task)
}
}
}
}
private func closed(_ task: URLSessionWebSocketTask) {
ws = nil
let code = task.closeCode.rawValue
let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? ""
if code == exitedCode {
show("shell ended: \(reason)", true)
return
}
if code == internalErrorCode {
show(reason, true)
return
}
if !active() {
show("task closed", false)
return
}
show("disconnected, reconnecting…", false)
retry = Task {
try? await Task.sleep(for: retryAfter)
if !Task.isCancelled {
connect()
}
}
}
func send(source: TerminalView, data: ArraySlice<UInt8>) {
ws?.send(.data(Data(data))) { _ in }
}
+318
View File
@@ -0,0 +1,318 @@
import SwiftUI
typealias WorkflowRun = Components.Schemas.WorkflowRun
typealias RunStage = Components.Schemas.WorkflowRunStage
typealias StageAttempt = Components.Schemas.StageAttempt
private let stateColors: [RunStage.StatePayload: NodeColors] = [
.waiting: NodeColors(fill: Palette.surface, stroke: Palette.border, text: Palette.muted),
.running: NodeColors(fill: Palette.accentTint, stroke: Palette.accent, text: Palette.text),
.idle: NodeColors(fill: Palette.warnTint, stroke: Palette.warn, text: Palette.text),
.finished: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
.merged: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
.cleanedUp: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
.discarded: NodeColors(fill: Palette.dangerTint, stroke: Palette.danger, text: Palette.text),
]
private func stageGraph(_ run: WorkflowRun) -> Flowchart {
var chart = Flowchart(direction: .td)
for (i, s) in run.stages.enumerated() {
let id = "s\(i)"
chart.node(id, label: s.name, shape: .box)
chart.nodes[id]!.style = stateColors[s.state]!
}
let ids = Dictionary(run.stages.enumerated().map { ($0.element.name, "s\($0.offset)") }, uniquingKeysWith: { a, _ in a })
for s in run.stages {
for a in s.after {
if let from = ids[a], let to = ids[s.name] {
chart.edges.append(FlowEdge(from: from, to: to, label: nil, style: .solid, arrow: true))
}
}
}
return chart
}
private func attemptLabel(_ a: StageAttempt) -> String {
switch a.state {
case .running: "running"
case .idle: "idle, not finished"
case .finished: "finished"
case .merged: "merged"
case .discarded: "discarded before finishing\(a.discardedBy.map { " by \($0)" } ?? "")"
case .cleanedUp: a.discardedBy == "byre" ? "finished, then discarded by the run's cleanup" : "finished, then discarded\(a.discardedBy.map { " by \($0)" } ?? "")"
}
}
private func stageLabel(_ s: RunStage) -> String {
if s.state != .waiting {
return attemptLabel(s.attempts.last { $0.output != nil } ?? s.attempts.last!)
}
if !s.waitingOn.isEmpty {
return "waiting on \(s.waitingOn.map { "“\($0)”" }.joined(separator: ", "))"
}
return "waiting to start"
}
private func stateColor(_ state: String) -> Color {
switch state {
case "running": Palette.accent
case "idle", "discarding": Palette.warn
case "finished", "merged": Palette.ok
default: Palette.muted
}
}
struct WorkflowView: View {
@Environment(Store.self) private var store
@ViewState private var run: WorkflowRun?
@ViewState private var error = ""
@ViewState private var target = 0
@ViewState private var loading = false
@ViewState private var again = false
var body: some View {
let task = store.selectedTaskId.flatMap { store.findTask($0) }
VStack(spacing: 0) {
if let task, let runId = task.workflowRun {
if !error.isEmpty {
EmptyText(text: error, error: true)
} else if let run, run.run == runId {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(run.name).font(Fonts.font(13, weight: 700))
Text(run.title).foregroundStyle(Palette.muted)
Text(run.finished ? "workflow finished" : "in progress")
.font(Fonts.font(11))
.foregroundStyle(run.finished ? Palette.ok : Palette.accent)
}
.modifier(HeaderBar())
ScrollView {
VStack(alignment: .leading, spacing: 12) {
FlowchartCanvas(layout: layoutFlowchart(stageGraph(run)))
.frame(maxWidth: .infinity, alignment: .leading)
if !trimmed(run.brief).isEmpty {
DetailsBox(boxed: false) {
Text("Brief").foregroundStyle(Palette.muted)
} content: {
MarkdownView(blocks: parseMarkdown(run.brief), ctx: MDContext(taskId: task.id))
.padding(.top, 6)
}
}
ForEach(run.stages, id: \.name) { s in
StageCard(run: run, stage: s)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
}
} else {
EmptyText(text: "Loading…")
}
} else {
EmptyText(text: task != nil ? "This task isn't part of a workflow run." : "No task selected.")
}
}
.frame(maxHeight: .infinity, alignment: .top)
.task(id: store.selectedTaskId) {
target += 1
run = nil
error = ""
await load()
let off = store.events.add { ev in
if relevant(ev) {
Task {
await load()
}
}
}
await untilCancelled()
off()
target += 1
}
.onChange(of: task?.workflowRun) {
if run == nil && !loading && error.isEmpty {
Task {
await load()
}
}
}
}
private func relevant(_ ev: ByreEvent) -> Bool {
guard ev.seq != 0, let run else {
return false
}
if ev.kind == "task.created" {
return ev.payload.value["workflow_run"] as? String == run.run
}
let ids = Set(run.stages.flatMap { $0.attempts.map(\.taskId) })
guard let taskId = ev.taskId else {
return false
}
return ids.contains(taskId) && (ev.kind.hasPrefix("task.") || ev.kind.hasPrefix("session."))
}
private func load() async {
guard let taskId = store.selectedTaskId, store.findTask(taskId)?.workflowRun != nil else {
return
}
if loading {
again = true
return
}
loading = true
target += 1
let started = target
do {
let fresh = try await store.api.client.getWorkflowRun(path: .init(taskId: taskId)).ok.body.json
if started == target {
run = fresh
error = ""
}
} catch {
if started == target {
self.error = errorText(error)
}
}
loading = false
if again {
again = false
await load()
}
}
}
private struct StageCard: View {
let run: WorkflowRun
let stage: RunStage
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(stage.name).font(Fonts.font(13, weight: 700))
Text(stageLabel(stage))
.font(Fonts.font(11))
.foregroundStyle(stateColor(stage.state.rawValue))
}
Text(stage.after.isEmpty ? "starts from the brief" : "after \(stage.after.map { "“\($0)”" }.joined(separator: ", "))")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
if !stage.attempts.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(stage.attempts.enumerated()), id: \.offset) { i, a in
AttemptRow(run: run, stage: stage, attempt: a, index: i)
}
}
.padding(.top, 8)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
}
}
private struct AttemptRow: View {
let run: WorkflowRun
let stage: RunStage
let attempt: StageAttempt
let index: Int
@Environment(Store.self) private var store
var body: some View {
let a = attempt
let fed = run.stages.flatMap { s in s.attempts.filter { d in d.inputs.contains { $0.taskId == a.taskId } }.map(\.taskId) }
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
if stage.attempts.count > 1 {
Text("#\(index + 1)").foregroundStyle(Palette.muted)
}
Text(link(a.taskId, a.title, bold: a.taskId == store.selectedTaskId))
Text(attemptLabel(a))
.font(Fonts.font(11))
.foregroundStyle(stateColor(a.state.rawValue))
if a.discarding {
Text("discarding…").font(Fonts.font(11)).foregroundStyle(Palette.warn)
}
}
if let from = a.forkedFrom {
line([("fork of ", nil), (attemptName(from), from)])
}
if !a.inputs.isEmpty {
line([("started from ", nil)] + joined(a.inputs.map(\.taskId)))
}
if !fed.isEmpty {
line([("fed ", nil)] + joined(fed))
}
if let output = a.output {
DetailsBox(boxed: false) {
HStack(spacing: 0) {
Text("finish_stage output").foregroundStyle(Palette.muted)
if let commit = a.commit {
Text(" \(String(commit.prefix(10)))").foregroundStyle(Palette.muted)
}
}
} content: {
MarkdownView(blocks: parseMarkdown(output), ctx: MDContext(taskId: a.taskId))
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
.padding(.top, 6)
}
.padding(.top, 6)
}
}
.padding(.top, 8)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
private func joined(_ ids: [String]) -> [(String, String?)] {
var out: [(String, String?)] = []
for (n, id) in ids.enumerated() {
if n > 0 {
out.append((", ", nil))
}
out.append((attemptName(id), id))
}
return out
}
private func attemptName(_ taskId: String) -> String {
for s in run.stages {
if let i = s.attempts.firstIndex(where: { $0.taskId == taskId }) {
return s.attempts.count > 1 ? "“\(s.name)” attempt \(i + 1)" : "“\(s.name)”"
}
}
return store.findTask(taskId)?.title ?? taskId
}
private func link(_ taskId: String, _ label: String, bold: Bool = false) -> AttributedString {
var a = AttributedString(label)
a.link = AppLink.task(taskId)
a.foregroundColor = Palette.accent
a.font = Fonts.font(13, weight: bold ? 700 : 400)
return a
}
private func line(_ parts: [(String, String?)]) -> some View {
var out = AttributedString()
for (text, taskId) in parts {
if let taskId {
var a = link(taskId, text)
a.font = Fonts.font(11)
out += a
} else {
out += AttributedString(text)
}
}
return Text(out)
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.padding(.top, 2)
}
}