From e28c31385432c41c9448ce67c8cc785d372f6ef5 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 17 Oct 2026 07:26:18 -0700 Subject: [PATCH] Port the remaining tabs to the macOS client --- TODO.md | 2 - docs/macos.md | 6 +- mac/Sources/Byre/Changes.swift | 503 ++++++++++++++++++++++++ mac/Sources/Byre/Dialogs.swift | 130 ++++++- mac/Sources/Byre/Files.swift | 351 +++++++++++++++++ mac/Sources/Byre/Format.swift | 141 ++++++- mac/Sources/Byre/Inbox.swift | 650 ++++++++++++++++++++++++++++++++ mac/Sources/Byre/Markdown.swift | 4 +- mac/Sources/Byre/Panes.swift | 35 +- mac/Sources/Byre/Project.swift | 305 +++++++++++++++ mac/Sources/Byre/Reviews.swift | 447 ++++++++++++++++++++++ mac/Sources/Byre/Settings.swift | 250 ++++++++++++ mac/Sources/Byre/Status.swift | 397 +++++++++++++++++++ mac/Sources/Byre/Terminal.swift | 159 ++++++-- mac/Sources/Byre/Workflow.swift | 318 ++++++++++++++++ 15 files changed, 3648 insertions(+), 50 deletions(-) create mode 100644 mac/Sources/Byre/Changes.swift create mode 100644 mac/Sources/Byre/Files.swift create mode 100644 mac/Sources/Byre/Inbox.swift create mode 100644 mac/Sources/Byre/Project.swift create mode 100644 mac/Sources/Byre/Reviews.swift create mode 100644 mac/Sources/Byre/Settings.swift create mode 100644 mac/Sources/Byre/Status.swift create mode 100644 mac/Sources/Byre/Workflow.swift diff --git a/TODO.md b/TODO.md index cdb30b4..e58d367 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/docs/macos.md b/docs/macos.md index 3a6e206..04b7102 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -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 ` 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/` 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 | diff --git a/mac/Sources/Byre/Changes.swift b/mac/Sources/Byre/Changes.swift new file mode 100644 index 0000000..997be65 --- /dev/null +++ b/mac/Sources/Byre/Changes.swift @@ -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 = [] + 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] = [:] + 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? + @ObservationIgnored private var loadedSince: String? + @ObservationIgnored private var target = 0 + @ObservationIgnored private var fetching: Set = [] + + init(_ store: Store, compact: Bool) { + self.store = store + self.compact = compact + } + + 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 + 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 + } + } +} diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index 8d3e770..25c3cec 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -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 diff --git a/mac/Sources/Byre/Files.swift b/mac/Sources/Byre/Files.swift new file mode 100644 index 0000000..910b894 --- /dev/null +++ b/mac/Sources/Byre/Files.swift @@ -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? + + 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) + } + } +} diff --git a/mac/Sources/Byre/Format.swift b/mac/Sources/Byre/Format.swift index 5210e46..0ba0dba 100644 --- a/mac/Sources/Byre/Format.swift +++ b/mac/Sources/Byre/Format.swift @@ -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 diff --git a/mac/Sources/Byre/Inbox.swift b/mac/Sources/Byre/Inbox.swift new file mode 100644 index 0000000..833fdcb --- /dev/null +++ b/mac/Sources/Byre/Inbox.swift @@ -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.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.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.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.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) + } + } + } +} diff --git a/mac/Sources/Byre/Markdown.swift b/mac/Sources/Byre/Markdown.swift index 481a4d8..a78bbd4 100644 --- a/mac/Sources/Byre/Markdown.swift +++ b/mac/Sources/Byre/Markdown.swift @@ -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] } diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index fe86bc9..98c9984 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -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 = [] @@ -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() } } } diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift new file mode 100644 index 0000000..57279fa --- /dev/null +++ b/mac/Sources/Byre/Project.swift @@ -0,0 +1,305 @@ +import SwiftUI + +typealias Todo = Components.Schemas.Todo +typealias TodoItem = Components.Schemas.TodoItem + +private let refreshKinds: Set = ["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] = [:] + @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? + @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: 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? + 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() + } + } +} diff --git a/mac/Sources/Byre/Reviews.swift b/mac/Sources/Byre/Reviews.swift new file mode 100644 index 0000000..db24685 --- /dev/null +++ b/mac/Sources/Byre/Reviews.swift @@ -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] = [:] + static var patches: [String: Task] = [:] + + 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 { + 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)) + } + } + } +} diff --git a/mac/Sources/Byre/Settings.swift b/mac/Sources/Byre/Settings.swift new file mode 100644 index 0000000..2969119 --- /dev/null +++ b/mac/Sources/Byre/Settings.swift @@ -0,0 +1,250 @@ +import SwiftUI +import UserNotifications + +typealias LeadNote = Components.Schemas.LeadNote +typealias NotificationPrefs = Components.Schemas.NotificationPrefs + +@MainActor +private let kinds: [(key: WritableKeyPath, 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) + } + } +} diff --git a/mac/Sources/Byre/Status.swift b/mac/Sources/Byre/Status.swift new file mode 100644 index 0000000..69f8a1d --- /dev/null +++ b/mac/Sources/Byre/Status.swift @@ -0,0 +1,397 @@ +import SwiftUI + +typealias AgentProcess = Components.Schemas.AgentProcess +typealias Zombie = Components.Schemas.Zombie + +private let refreshKinds: Set = ["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) + } +} diff --git a/mac/Sources/Byre/Terminal.swift b/mac/Sources/Byre/Terminal.swift index 1ac703a..dbd7d88 100644 --- a/mac/Sources/Byre/Terminal.swift +++ b/mac/Sources/Byre/Terminal.swift @@ -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? + 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) { ws?.send(.data(Data(data))) { _ in } } diff --git a/mac/Sources/Byre/Workflow.swift b/mac/Sources/Byre/Workflow.swift new file mode 100644 index 0000000..4be5de8 --- /dev/null +++ b/mac/Sources/Byre/Workflow.swift @@ -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) + } +}