macOS client parity review

This commit is contained in:
Ian Gulliver
2026-10-17 10:54:06 -07:00
parent b85de9a2d0
commit 31037e1c9f
24 changed files with 651 additions and 225 deletions
+1 -1
View File
@@ -87,6 +87,6 @@ struct ByreApp: App {
}
}
.keyboardShortcut(KeyEquivalent(Character(key)))
.disabled(connection.store == nil || overlays.isOpen)
.disabled(connection.store == nil || overlays.dialogOpen)
}
}
+2 -2
View File
@@ -316,7 +316,7 @@ private struct SnapWhere: View {
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(" · \(snap.trigger.rawValue) · \(clockText(snap.createdAt))")
}
Text(" · ")
step(n - 1, "← #\(n - 1)", snaps)
@@ -348,7 +348,7 @@ private struct ChangesList: View {
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 6) {
ForEach(c.files.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending }, id: \.path) { f in
ForEach(c.files.sorted { $0.path.localizedCompare($1.path) == .orderedAscending }, id: \.path) { f in
FileRow(model: model, changes: c, file: f)
}
}
+56 -31
View File
@@ -210,9 +210,10 @@ final class ChatModel {
}
store.jump = nil
stuck = false
if i > 0, case .agent = rows[i - 1].kind {
switch i > 0 ? rows[i - 1].kind : nil {
case .agent, .subagentMessage:
requestScroll(rows[i - 1].id, .top)
} else {
default:
requestScroll(rows[i].id, .bottom)
}
}
@@ -528,12 +529,15 @@ private struct TranscriptScroll: View {
ForEach(model.builder.pending) { p in
PendingView(pending: p)
}
if running {
Scanner(color: accent)
}
}
}
.scrollTargetLayout()
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
Scanner(color: accent)
.offset(y: 8)
}
}
.padding(16)
}
.scrollPosition($position)
@@ -555,7 +559,10 @@ private struct TranscriptScroll: View {
scrollLog.notice("phase \(String(describing: old), privacy: .public) -> \(String(describing: phase), privacy: .public)")
scrolling = phase.isScrolling
}
.onChange(of: model.builder.pending.count) {
.onChange(of: model.builder.pending.last?.id) { old, new in
guard let new, new > old ?? 0 else {
return
}
scrollLog.notice("pending scrollTo bottom")
model.stuck = true
position.scrollTo(edge: .bottom)
@@ -631,6 +638,7 @@ struct DetailsBox<Summary: View, Content: View>: View {
var boxed = true
var nested = false
var working = false
var status: (text: String, color: Color)? = nil
@ViewBuilder let summary: Summary
@ViewBuilder let content: Content
@Environment(\.paneAccent) private var accent
@@ -651,6 +659,12 @@ struct DetailsBox<Summary: View, Content: View>: View {
.padding(.leading, 4)
}
Spacer(minLength: 0)
if let status {
Text(status.text)
.font(Fonts.font(12))
.foregroundStyle(status.color)
.padding(.leading, 12)
}
}
.contentShape(Rectangle())
}
@@ -738,8 +752,8 @@ struct RowView: View {
case .byrePrompt(let brief, let when, let blocks):
DetailsBox(nested: nested) {
Text("byre").font(Fonts.font(12, weight: 600))
Text(brief).font(Fonts.font(12)).foregroundStyle(Palette.muted).lineLimit(1)
Text(styled([("byre", 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 12)
WhenText(when: when).font(Fonts.font(12)).foregroundStyle(Palette.muted)
} content: {
@@ -790,13 +804,9 @@ struct RowView: View {
}
case .subagent(let title, let description, let prompt):
DetailsBox(nested: nested, working: model.working(row)) {
DetailsBox(nested: nested, working: model.working(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
SubagentTag(show: row.tag)
Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)]))
Spacer(minLength: 12)
Text(row.status)
.font(Fonts.font(12))
.foregroundStyle(row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)
} content: {
VStack(alignment: .leading, spacing: 6) {
MarkdownView(blocks: prompt, ctx: md)
@@ -1014,10 +1024,11 @@ private struct SnapshotRow: View {
let taskId: String?
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.paneAccent) private var accent
@ViewState private var forking = false
var body: some View {
let time = snap.createdAt.formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits))
let time = clockText(snap.createdAt)
HStack(spacing: 8) {
Rectangle().fill(Palette.border).frame(height: 1)
Text("snapshot #\(snap.n)")
@@ -1058,26 +1069,40 @@ private struct SnapshotRow: View {
return Button {
store.showReview(snap.n)
} label: {
HStack(spacing: 2) {
Text("review")
.font(Fonts.font(11, weight: n > 0 ? 700 : 400))
if let review {
switch review.state {
case .running:
Spinner(color: Palette.accent).font(Fonts.font(10)).help("review in progress")
case .failed:
EmptyView()
case .skipped:
Text("●").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.muted).help("skipped: no changes")
case .done:
if n == 0 {
Text("✓").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.ok).help(review.findingStates.additionalProperties.isEmpty ? "no findings" : "every finding addressed")
} else {
Badge(count: n).help("\(n) open findings")
Text("review")
.font(Fonts.font(11, weight: n > 0 ? 700 : 400))
.overlay(alignment: .topTrailing) {
if let review {
switch review.state {
case .running:
Spinner(color: accent).font(Fonts.font(10)).help("review in progress")
.fixedSize()
.offset(x: 13, y: -4)
case .failed:
EmptyView()
case .skipped:
Text("●").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.muted).help("skipped: no changes")
.fixedSize()
.offset(x: 13, y: -7)
case .done:
if n == 0 {
Text("✓").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.ok).help(review.findingStates.additionalProperties.isEmpty ? "no findings" : "every finding addressed")
.fixedSize()
.offset(x: 13, y: -7)
} else {
Text("\(n)")
.font(Fonts.font(10, weight: 700))
.foregroundStyle(Palette.danger)
.frame(height: 14)
.padding(.horizontal, 5)
.background(Palette.dangerTint, in: Capsule())
.help("\(n) open findings")
.fixedSize()
.offset(x: 15, y: -9)
}
}
}
}
}
}
.buttonStyle(SmallLinkButtonStyle(color: n > 0 ? Palette.warn : Palette.accent))
.help("Show the review of snapshot \(snap.n) in the Reviews tab")
+25 -1
View File
@@ -226,7 +226,7 @@ struct Thumbnails: View {
Button("×") {
drafts.remove(key, img.id)
}
.buttonStyle(WebButtonStyle())
.buttonStyle(ThumbRemoveStyle())
.help("Remove image")
.offset(x: 6, y: -6)
}
@@ -237,6 +237,30 @@ struct Thumbnails: View {
}
}
private struct ThumbRemoveStyle: ButtonStyle {
func makeBody(configuration: ButtonStyleConfiguration) -> some View {
ThumbRemove(configuration: configuration)
}
}
private struct ThumbRemove: View {
let configuration: ButtonStyleConfiguration
@ViewState private var hover = false
var body: some View {
configuration.label
.font(Fonts.font(13))
.foregroundStyle(Palette.text)
.padding(.horizontal, 6)
.frame(height: 18)
.padding(1)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(hover ? Palette.muted : Palette.border))
.contentShape(RoundedRectangle(cornerRadius: 9))
.onHover { hover = $0 }
}
}
struct ComposerView: View {
let chat: ChatModel
let key: String
+56 -34
View File
@@ -10,6 +10,7 @@ struct MenuEntry: Identifiable {
struct OverlayMenu {
let anchor: CGRect
let accent: Color
let entries: [MenuEntry]
}
@@ -44,29 +45,42 @@ enum LeftTab {
final class Overlays {
var alerts: [OverlayAlert] = []
var image: NSImage?
var menu: OverlayMenu?
var menu: OverlayMenu? {
didSet {
menuHover = false
}
}
var modal: OverlayModal?
var switcher: SwitcherModel?
var leftTab = LeftTab.lead
var leadFocus = 0
@ObservationIgnored var pickerFrame: CGRect = .zero
@ObservationIgnored var menuHover = false
var paneFrame: CGRect = .zero
@ObservationIgnored private var monitor: Any?
@ObservationIgnored private var mouseMonitor: Any?
var isOpen: Bool {
!alerts.isEmpty || image != nil || menu != nil || modal != nil || switcher != nil
}
var dialogOpen: Bool {
!alerts.isEmpty || modal != nil
}
func showAlert(_ text: String) {
menu = nil
alerts.append(OverlayAlert(content: AnyView(ModalText(text: text))))
}
func showAlert(_ content: some View) {
menu = nil
alerts.append(OverlayAlert(content: AnyView(content)))
}
func showModal(_ content: some View, _ buttons: [ModalButton], anchor: CGRect? = nil, inPane: Bool = false, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String {
modal?.done("cancel")
menu = nil
return await withCheckedContinuation { cont in
modal = OverlayModal(content: AnyView(content), buttons: buttons, anchor: anchor, inPane: inPane, canSubmit: canSubmit) { value in
cont.resume(returning: value)
@@ -103,7 +117,7 @@ final class Overlays {
func closeTop() {
if !alerts.isEmpty {
alerts.removeFirst()
alerts.removeLast()
} else if modal != nil {
answerModal("cancel")
} else if image != nil {
@@ -128,13 +142,21 @@ final class Overlays {
}
return taken ? nil : event
}
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in
MainActor.assumeIsolated {
if let self, self.menu != nil, !self.menuHover {
self.menu = nil
}
}
return event
}
}
private func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags, _ chars: String, _ ctrl: @MainActor (String) -> Bool) -> Bool {
if !dialogOpen && flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) {
return ctrl(chars)
}
if !isOpen {
if flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) {
return ctrl(chars)
}
return false
}
if code == 53 {
@@ -145,7 +167,7 @@ final class Overlays {
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if !alerts.isEmpty {
if enter {
alerts.removeFirst()
alerts.removeLast()
return true
}
return false
@@ -180,7 +202,11 @@ final class Overlays {
if let monitor {
NSEvent.removeMonitor(monitor)
}
if let mouseMonitor {
NSEvent.removeMonitor(mouseMonitor)
}
monitor = nil
mouseMonitor = nil
}
}
@@ -208,27 +234,6 @@ struct OverlayHost: View {
var body: some View {
ZStack(alignment: .topLeading) {
if let menu = overlays.menu {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
overlays.menu = nil
}
VStack(alignment: .leading, spacing: 0) {
ForEach(menu.entries) { entry in
MenuRow(entry: entry) {
overlays.menu = nil
entry.action()
}
}
}
.padding(4)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
.shadow(color: .black.opacity(0.18), radius: 14, y: 8)
.fixedSize()
.offset(x: menu.anchor.minX, y: menu.anchor.maxY + 4)
}
if let switcher = overlays.switcher {
Color.black.opacity(0.35)
.contentShape(Rectangle())
@@ -252,22 +257,39 @@ struct OverlayHost: View {
overlays.image = nil
}
}
if let menu = overlays.menu {
VStack(alignment: .leading, spacing: 0) {
ForEach(menu.entries) { entry in
MenuRow(entry: entry) {
overlays.menu = nil
entry.action()
}
}
}
.padding(4)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
.shadow(color: overlayShadow, radius: 14, y: 8)
.environment(\.paneAccent, menu.accent)
.onHover { inside in
overlays.menuHover = inside
}
.fixedSize()
.offset(x: menu.anchor.minX, y: menu.anchor.maxY + 4)
}
if let m = overlays.modal {
Color.black.opacity(0.35)
.contentShape(Rectangle())
.onTapGesture {
overlays.answerModal("cancel")
}
ModalPlacement(modal: m, pane: overlays.paneFrame) { value in
overlays.answerModal(value)
}
}
if let alert = overlays.alerts.first {
if let alert = overlays.alerts.last {
Color.black.opacity(0.35)
.contentShape(Rectangle())
GeometryReader { g in
AlertBox(alert: alert, maxText: max(60, g.size.height - 48 - 90)) {
overlays.alerts.removeFirst()
overlays.alerts.removeLast()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -399,7 +421,7 @@ private struct ModalBox: View {
.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)
.shadow(color: overlayShadow, radius: 14, y: 8)
}
}
@@ -427,7 +449,7 @@ private struct AlertBox: View {
.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)
.shadow(color: overlayShadow, radius: 14, y: 8)
.padding(24)
}
}
+2 -3
View File
@@ -277,15 +277,14 @@ private struct EntryRow: View {
@ViewState private var hover = false
var body: some View {
let row = HStack(spacing: 12) {
HStack(spacing: 0) {
let row = HStack(alignment: .top, spacing: 12) {
HStack(alignment: .top, 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))
+83 -5
View File
@@ -52,12 +52,82 @@ func jsString(_ v: (any Sendable)??) -> String {
}
func prettyJSON(_ v: (any Sendable)?) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
guard let container = try? OpenAPIValueContainer(unvalidatedValue: v), let data = try? encoder.encode(container) else {
return String(describing: v)
jsonText(v, indent: " ", depth: 0)
}
func compactJSON(_ v: (any Sendable)?) -> String {
jsonText(v, indent: nil, depth: 0)
}
private func jsonText(_ v: (any Sendable)?, indent: String?, depth: Int) -> String {
guard let v else {
return "null"
}
return String(decoding: data, as: UTF8.self)
if let s = v as? String {
return jsonQuote(s)
}
if let b = v as? Bool {
return b ? "true" : "false"
}
if let i = v as? Int {
return String(i)
}
if let d = v as? Double {
return d.isFinite ? jsString(d) : "null"
}
let items: [String]
let open: String
let close: String
if let list = v as? [(any Sendable)?] {
items = list.map { jsonText($0, indent: indent, depth: depth + 1) }
open = "["
close = "]"
} else if let d = v as? JSONDict {
let keys = d.keys.sorted { $0.utf8.lexicographicallyPrecedes($1.utf8) }
items = keys.map { "\(jsonQuote($0)):\(indent == nil ? "" : " ")\(jsonText(d[$0] ?? nil, indent: indent, depth: depth + 1))" }
open = "{"
close = "}"
} else {
return jsonQuote(String(describing: v))
}
if items.isEmpty {
return open + close
}
guard let indent else {
return open + items.joined(separator: ",") + close
}
let inner = "\n" + String(repeating: indent, count: depth + 1)
return open + inner + items.joined(separator: "," + inner) + "\n" + String(repeating: indent, count: depth) + close
}
private func jsonQuote(_ s: String) -> String {
var out = "\""
for u in s.unicodeScalars {
switch u {
case "\"":
out += "\\\""
case "\\":
out += "\\\\"
case "\u{08}":
out += "\\b"
case "\u{0C}":
out += "\\f"
case "\n":
out += "\\n"
case "\r":
out += "\\r"
case "\t":
out += "\\t"
default:
if u.value < 0x20 {
let hex = String(u.value, radix: 16)
out += "\\u" + String(repeating: "0", count: 4 - hex.count) + hex
} else {
out.unicodeScalars.append(u)
}
}
}
return out + "\""
}
func ago(_ date: Date) -> String {
@@ -78,6 +148,10 @@ func isoText(_ date: Date) -> String {
date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true))
}
func clockText(_ d: Date) -> String {
d.formatted(Date.VerbatimFormatStyle(format: "\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits):\(second: .twoDigits)", timeZone: .current, calendar: .current))
}
func trimmed(_ s: String) -> String {
s.trimmingCharacters(in: .whitespacesAndNewlines)
}
@@ -490,3 +564,7 @@ struct FlowLayout: Layout {
return rows
}
}
let overlayShadow = Color(nsColor: NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.5) : NSColor(srgbRed: 16 / 255, green: 24 / 255, blue: 40 / 255, alpha: 0.18)
})
+60 -33
View File
@@ -88,6 +88,7 @@ private final class CardModel {
let context: [MDBlock]
let firstChoice: [String: Int]
var busy = false
@ObservationIgnored var shown: Set<String> = []
init(_ item: InboxItem, _ store: Store, _ overlays: Overlays) {
self.item = item
@@ -144,6 +145,17 @@ private final class CardModel {
store.drafts?.setCheckedAnswer(item.id, options.filter { now.contains($0) })
}
func pick(_ text: String) {
if busy {
return
}
if multiple {
toggle(text, !checked.contains(text))
return
}
submit(InboxResponseCreate(itemId: item.id, _type: .option, options: [text]))
}
func submit(_ response: InboxResponseCreate, withImages: Bool = false) {
if busy {
return
@@ -225,7 +237,7 @@ private struct InboxCard: View {
}
private func move(_ model: CardModel, _ by: Int) -> KeyPress.Result {
var stops = model.options
var stops = model.firstChoice.filter { model.shown.contains($0.key) }.sorted { $0.value < $1.value }.map(\.key)
if !model.editorOpen {
stops.append(otherStop)
}
@@ -240,6 +252,13 @@ private struct InboxCard: View {
private struct CardBody: View {
let model: CardModel
var focus: FocusState<String?>.Binding
@ViewState private var wide = true
private var contextCtx: MDContext {
MDContext(taskId: model.item.taskId, choice: { md, first in
AnyView(ChoiceBox(model: model, item: md, first: first, ctx: self.contextCtx, focus: focus))
}, gridColumns: wide ? 4 : 2)
}
var body: some View {
let p = model.payload
@@ -247,10 +266,9 @@ private struct CardBody: View {
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: model.context, ctx: contextCtx)
.onGeometryChange(for: Bool.self) { $0.size.width > 480 } action: { wide = $0 }
.padding(.vertical, 10)
MarkdownView(blocks: parseMarkdown(jsString(p["question"] ?? "")), ctx: MDContext(taskId: item.taskId))
.padding(.top, 10)
.padding(.bottom, 4)
@@ -308,6 +326,8 @@ private struct WhyLine: View {
private struct ChoiceBox: View {
let model: CardModel
let item: MDItem
let first: Bool
let ctx: MDContext
var focus: FocusState<String?>.Binding
@Environment(Store.self) private var store
@ViewState private var hover = false
@@ -321,7 +341,7 @@ private struct ChoiceBox: View {
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))
ItemBody(item: item, ctx: ctx)
}
}
}
@@ -333,6 +353,7 @@ private struct ChoiceBox: View {
let lit = (hover && !model.multiple) || focused
let hasDetail = !item.inline.isEmpty || !item.blocks.isEmpty
let on = model.checked.contains(text)
let keys: Set<KeyEquivalent> = model.multiple ? [.space] : [.return, .space]
return VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
if model.multiple {
@@ -342,29 +363,42 @@ private struct ChoiceBox: View {
.tint(Palette.accent)
.disabled(model.busy)
}
InlineView(inlines: parseInline(text), ctx: MDContext(taskId: model.item.taskId))
.font(Fonts.font(13, weight: 600))
InlineView(inlines: parseInline(text), ctx: MDContext(taskId: model.item.taskId, weight: 600))
.frame(maxWidth: .infinity, alignment: .leading)
.allowsHitTesting(false)
if rec {
Text("recommended").font(Fonts.font(11)).foregroundStyle(Palette.ok)
Text("recommended")
.font(Fonts.font(11))
.foregroundStyle(Palette.ok)
.allowsHitTesting(false)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(lit ? Palette.accentTint : rec ? Palette.ok.opacity(0.12) : Palette.surface)
.background {
(lit ? Palette.accentTint : rec ? Palette.ok.opacity(0.12) : Palette.surface)
.onTapGesture {
model.pick(text)
}
}
if hasDetail {
ItemBody(item: MDItem(inline: item.inline, blocks: item.blocks), ctx: MDContext(taskId: model.item.taskId))
ItemBody(item: MDItem(inline: item.inline, blocks: item.blocks), ctx: ctx)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.allowsHitTesting(false)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Palette.raised)
.background {
Palette.raised
.onTapGesture {
model.pick(text)
}
}
.environment(\.choiceTap, { [model] in
model.pick(text)
})
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(lit ? Palette.accent : rec ? Palette.ok.opacity(0.55) : Palette.border))
.overlay {
@@ -377,26 +411,18 @@ private struct ChoiceBox: View {
.focusable(!model.busy)
.focusEffectDisabled()
.focused(focus, equals: text)
.onTapGesture {
pick(text)
}
.onKeyPress(keys: [.return, .space]) { _ in
pick(text)
.onKeyPress(keys: keys) { _ in
model.pick(text)
return .handled
}
.onHover { hover = $0 }
.padding(.top, 8)
}
private func pick(_ text: String) {
if model.busy {
return
.onAppear {
model.shown.insert(text)
}
if model.multiple {
model.toggle(text, !model.checked.contains(text))
return
.onDisappear {
model.shown.remove(text)
}
model.submit(InboxResponseCreate(itemId: model.item.id, _type: .option, options: [text]))
.padding(.top, first ? 0 : 6)
}
}
@@ -613,9 +639,7 @@ private struct Attachments: View {
}
private func code(_ s: String) -> Text {
var a = AttributedString(s)
a.backgroundColor = Palette.surface
return Text(a)
Text(verbatim: s)
}
@ViewBuilder
@@ -628,15 +652,18 @@ private struct Attachments: View {
Text("diff \(code(a.ref ?? ""))").textSelection(.enabled)
case .image:
let src = (a.image?.isEmpty == false ? a.image : a.path) ?? ""
let caption = a.caption ?? ""
if let source = worktreeImageSource(src, taskId) {
ByreImage(source: source, src: src)
} else if !src.isEmpty {
InlineView(inlines: [.link(href: src, [.text(caption.isEmpty ? src : caption)])], ctx: MDContext(taskId: taskId))
}
case .diagram:
DiagramView(source: a.mermaid ?? "")
case .task:
let id = a.taskId ?? ""
Button("task \(id)") {
store.focusTask(id)
store.route("#/task/\(id)")
}
.buttonStyle(.plain)
.foregroundStyle(store.taskColor(id) ?? Palette.accent)
+167 -45
View File
@@ -7,11 +7,15 @@ enum MDSegment {
}
enum AppLink {
static func task(_ id: String) -> URL {
static func task(_ id: String, tab: String? = nil) -> URL {
route("#/task/\(id)\(tab.map { "/\($0)" } ?? "")")
}
static func route(_ hash: String) -> URL {
var c = URLComponents()
c.scheme = "byre-app"
c.host = "task"
c.queryItems = [URLQueryItem(name: "id", value: id)]
c.host = "route"
c.queryItems = [URLQueryItem(name: "hash", value: hash)]
return c.url!
}
@@ -39,13 +43,13 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
}
let q = Dictionary((c.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { a, _ in a })
switch c.host {
case "task":
store.focusTask(q["id"] ?? "")
case "route":
store.route(q["hash"] ?? "")
case "todo":
store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays)
case "suggest":
if q["discuss"] == "1" {
store.discussSuggestion(q["title"] ?? "", brief: q["brief"] ?? "", overlays)
store.discussSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays)
} else {
store.startSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays)
}
@@ -55,6 +59,34 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
return .handled
}
private struct ChoiceTapKey: EnvironmentKey {
static let defaultValue: (@MainActor @Sendable () -> Void)? = nil
}
private struct CenterImagesKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var choiceTap: (@MainActor @Sendable () -> Void)? {
get {
self[ChoiceTapKey.self]
}
set {
self[ChoiceTapKey.self] = newValue
}
}
var centerImages: Bool {
get {
self[CenterImagesKey.self]
}
set {
self[CenterImagesKey.self] = newValue
}
}
}
extension MDMark {
var color: Color {
switch self {
@@ -85,6 +117,7 @@ struct InlineRenderer {
let store: Store
let size: CGFloat
let taskId: String?
var weight: CGFloat = 400
private struct Style {
var bold = false
@@ -92,6 +125,7 @@ struct InlineRenderer {
var link: URL?
var color: Color?
var small = false
var tip: String?
}
func segments(_ items: [MDInline]) -> [MDSegment] {
@@ -112,8 +146,8 @@ struct InlineRenderer {
private func run(_ s: String, _ style: Style) -> AttributedString {
var a = AttributedString(s)
a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : 400, italic: style.italic)
a.mergeAttributes(try! AttributeContainer([.font: Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : 400, italic: style.italic)], including: \.appKit))
a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic)
a.mergeAttributes(try! AttributeContainer([.font: Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic)], including: \.appKit))
if let link = style.link {
a.link = link
}
@@ -121,6 +155,9 @@ struct InlineRenderer {
a.foregroundColor = color
a.appKit.foregroundColor = NSColor(color)
}
if let tip = style.tip {
a.appKit.toolTip = tip
}
return a
}
@@ -177,7 +214,7 @@ struct InlineRenderer {
return
}
if let m = taskHref.exec(href) {
s.link = AppLink.task(m[1]!)
s.link = AppLink.route(href)
s.color = store.taskColor(m[1]!) ?? Palette.accent
render(inner, s, &out)
return
@@ -190,6 +227,12 @@ struct InlineRenderer {
todo(m[1]!, title, brief, inner, style, &out)
return
}
if href.hasPrefix("#/") {
s.link = AppLink.route(href)
s.color = Palette.accent
render(inner, s, &out)
return
}
render(inner, style, &out)
}
@@ -201,22 +244,40 @@ struct InlineRenderer {
render(inner, s, &out)
append(run(" ", style), &out)
let state = store.currentSession(task.id)?.state
var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true)
var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true, tip: state?.rawValue ?? "no session")
append(run(state == .running ? "●" : "○", dot), &out)
dot.color = Palette.muted
dot.tip = nil
append(run(" \(state?.rawValue ?? "no agent")", dot), &out)
return
}
render(inner, style, &out)
append(run(" ", style), &out)
if store.todoStarting.contains("\(projectId)\n\(title)") {
append(run("starting…", Style(color: Palette.muted, small: true)), &out)
let busy = store.todoBusy["\(projectId)\n\(title)"]
func action(_ discuss: Bool, _ label: String, _ busyLabel: String, _ tip: String) {
let url = brief.map { AppLink.suggest(projectId, title, $0, discuss: discuss) } ?? AppLink.todo(projectId, title, discuss: discuss)
let mine = busy == (discuss ? "discuss" : "start")
let a = Style(link: busy == nil ? url : nil, color: busy == nil ? Palette.accent : Palette.muted, small: true, tip: tip)
append(run(mine ? busyLabel : label, a), &out)
}
if brief != nil {
action(false, "start", "starting…", "Start a task with the lead's brief")
append(run(" ", style), &out)
action(true, "discuss", "sending…", "Ask your lead to talk it over in chat")
return
}
let link = { (discuss: Bool) in brief.map { AppLink.suggest(projectId, title, $0, discuss: discuss) } ?? AppLink.todo(projectId, title, discuss: discuss) }
append(run("start", Style(link: link(false), color: Palette.accent, small: true)), &out)
action(false, "start", "starting…", "Start a task that gets straight to work")
append(run(" ", style), &out)
append(run("discuss", Style(link: link(true), color: Palette.accent, small: true)), &out)
action(true, "discuss", "starting…", "Start a task that discusses it with you in chat before building anything")
}
}
private func flowColor(_ s: NSMutableAttributedString, _ color: NSColor) {
let all = NSRange(location: 0, length: s.length)
unsafe s.enumerateAttribute(.foregroundColor, in: all) { value, range, _ in
if value == nil {
s.addAttribute(.foregroundColor, value: color, range: range)
}
}
}
@@ -224,7 +285,9 @@ struct MDContext {
var size: CGFloat = 13
var color: Color = Palette.text
var taskId: String?
var choice: ((MDItem) -> AnyView)?
var choice: ((MDItem, Bool) -> AnyView)?
var gridColumns = 4
var weight: CGFloat = 400
}
private enum MDRun {
@@ -356,7 +419,7 @@ private struct FlowBuilder {
}
private func inline(_ inlines: [MDInline], _ size: CGFloat) -> NSAttributedString {
let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId).text(inlines)
let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId, weight: ctx.weight).text(inlines)
return try! NSAttributedString(a, including: \.appKit)
}
@@ -364,12 +427,7 @@ private struct FlowBuilder {
let s = NSMutableAttributedString(attributedString: a)
s.append(NSAttributedString(string: "\n", attributes: [.font: Fonts.ns(ctx.size)]))
let all = NSRange(location: 0, length: s.length)
let color = NSColor(ctx.color)
unsafe s.enumerateAttribute(.foregroundColor, in: all) { value, range, _ in
if value == nil {
s.addAttribute(.foregroundColor, value: color, range: range)
}
}
flowColor(s, NSColor(ctx.color))
let lead = (s.string as NSString).range(of: "\n").location + 1
s.addAttribute(.paragraphStyle, value: style(first, head, 0), range: all)
s.addAttribute(.paragraphStyle, value: style(first, head, gap), range: NSRange(location: 0, length: lead))
@@ -389,10 +447,37 @@ private struct FlowBuilder {
}
private final class FlowTextView: NSTextView {
var onTap: (@MainActor @Sendable () -> Void)?
override func resignFirstResponder() -> Bool {
setSelectedRange(NSRange(location: 0, length: 0))
return super.resignFirstResponder()
}
override func mouseDown(with event: NSEvent) {
let onLink = link(at: convert(event.locationInWindow, from: nil))
super.mouseDown(with: event)
if let onTap, !onLink, selectedRange().length == 0 {
onTap()
}
}
private func link(at point: NSPoint) -> Bool {
guard let lm = unsafe layoutManager, let tc = unsafe textContainer, let ts = unsafe textStorage, ts.length > 0 else {
return false
}
let p = NSPoint(x: point.x - textContainerOrigin.x, y: point.y - textContainerOrigin.y)
let g = lm.glyphIndex(for: p, in: tc)
if !lm.boundingRect(forGlyphRange: NSRange(location: g, length: 1), in: tc).contains(p) {
return false
}
let i = lm.characterIndexForGlyph(at: g)
if i >= ts.length {
return false
}
let value = unsafe ts.attribute(.link, at: i, effectiveRange: nil)
return value != nil
}
}
private struct FlowText: NSViewRepresentable {
@@ -415,6 +500,7 @@ private struct FlowText: NSViewRepresentable {
func updateNSView(_ v: FlowTextView, context: Context) {
context.coordinator.openURL = context.environment.openURL
v.onTap = context.environment.choiceTap
if unsafe v.textStorage!.isEqual(to: text) {
return
}
@@ -480,25 +566,45 @@ struct InlineView: View {
let inlines: [MDInline]
let ctx: MDContext
@Environment(Store.self) private var store
@Environment(\.choiceTap) private var choiceTap
@Environment(\.centerImages) private var centerImages
var body: some View {
let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId).segments(inlines)
let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, weight: ctx.weight).segments(inlines)
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in
switch seg {
case .text(let a):
Text(a)
.font(Fonts.font(ctx.size))
.foregroundStyle(ctx.color)
.lineSpacing(3)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
if choiceTap != nil {
FlowText(text: flowString(a))
} else {
Text(a)
.font(Fonts.font(ctx.size))
.foregroundStyle(ctx.color)
.lineSpacing(3)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
case .image(let source, let src):
ByreImage(source: source, src: src)
if centerImages {
ByreImage(source: source, src: src)
.frame(maxWidth: .infinity)
} else {
ByreImage(source: source, src: src)
}
}
}
}
}
private func flowString(_ a: AttributedString) -> NSAttributedString {
let s = NSMutableAttributedString(attributedString: try! NSAttributedString(a, including: \.appKit))
flowColor(s, NSColor(ctx.color))
let p = NSMutableParagraphStyle()
p.lineSpacing = 3
s.addAttribute(.paragraphStyle, value: p, range: NSRange(location: 0, length: s.length))
return s
}
}
private struct BlockView: View {
@@ -562,16 +668,17 @@ private struct ListBlock: View {
var body: some View {
if grid, let choice = ctx.choice {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: 4), spacing: 8) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: ctx.gridColumns), spacing: 8) {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
choice(item)
choice(item, true)
}
}
.environment(\.centerImages, true)
} else {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(items.enumerated()), id: \.offset) { n, item in
if item.choice != nil, let choice = ctx.choice {
choice(item)
choice(item, n == 0)
} else {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(verbatim: ordered ? "\(start + n)." : "•")
@@ -606,21 +713,28 @@ struct ItemBody: View {
private struct DiffBlock: View {
let lines: [String]
let size: CGFloat
@ViewState private var width: CGFloat = 0
var body: some View {
Copyable(text: lines.map { $0 + "\n" }.joined()) {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(verbatim: line.isEmpty ? " " : line)
.font(Fonts.font(size))
.foregroundStyle(line.hasPrefix("@@") ? Palette.muted : Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.background(background(line))
ScrollView(.horizontal) {
StretchColumn(minWidth: width) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(verbatim: line.isEmpty ? " " : line)
.font(Fonts.font(size))
.foregroundStyle(line.hasPrefix("@@") || line.hasPrefix("+++") || line.hasPrefix("---") ? Palette.muted : Palette.text)
.textSelection(.enabled)
.fixedSize()
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(background(line))
}
}
.padding(.vertical, 6)
}
.padding(.vertical, 6)
.scrollBounceBehavior(.basedOnSize)
.frame(maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
}
@@ -680,6 +794,14 @@ private struct TableBlock: View {
let ctx: MDContext
var body: some View {
ViewThatFits(in: .horizontal) {
grid.fixedSize(horizontal: true, vertical: false)
grid
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var grid: some View {
Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) {
GridRow {
ForEach(Array(head.enumerated()), id: \.offset) { _, cell in
@@ -696,7 +818,7 @@ private struct TableBlock: View {
}
}
}
.overlay(Rectangle().stroke(Palette.border))
.overlay(Rectangle().strokeBorder(Palette.border))
}
private func tint(_ cell: [MDInline]) -> Color? {
+39 -16
View File
@@ -4,7 +4,7 @@ import SwiftUI
enum TabBadge {
case none
case count(Int)
case count(Int, String)
case spinner
}
@@ -16,8 +16,9 @@ struct TabBadgeView: View {
switch badge {
case .none:
EmptyView()
case .count(let n):
case .count(let n, let help):
Badge(count: n)
.help(help)
case .spinner:
Spinner(color: accent)
.help("review in progress")
@@ -109,7 +110,7 @@ struct PaneTabButton: View {
TabBadgeView(badge: badge)
}
.font(Fonts.font(13, weight: attention ? 700 : 400))
.foregroundStyle(attention ? Palette.warn : active || hover ? Palette.text : Palette.muted)
.foregroundStyle(hover ? Palette.text : attention ? Palette.warn : active ? Palette.text : Palette.muted)
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 6)
@@ -149,6 +150,7 @@ private struct LeftPane: View {
if !store.connected {
Text("reconnecting…")
.foregroundStyle(Palette.danger)
.fixedSize()
}
Circle()
.fill(store.connected ? Palette.ok : Palette.danger)
@@ -156,9 +158,9 @@ private struct LeftPane: View {
.help(store.connected ? "live" : "reconnecting")
Text(store.me?.displayName ?? "")
.foregroundStyle(Palette.muted)
.truncationMode(.tail)
}
.lineLimit(1)
.fixedSize()
}
.padding(.horizontal, 12)
.overlay(alignment: .bottom) {
@@ -280,12 +282,7 @@ private struct TaskBar: View {
HStack(spacing: 12) {
Spacer(minLength: 0)
ForEach(previews, id: \.port) { p in
Link(destination: previewURL(store.api.origin, p.port)) {
Text("\(p.name) ↗")
.foregroundStyle(accent)
.lineLimit(1)
}
.help("Open \(p.name) (port \(p.port)) in a new window")
PreviewLink(preview: p, url: previewURL(store.api.origin, p.port))
}
}
.frame(maxWidth: .infinity)
@@ -340,9 +337,28 @@ private struct TaskBar: View {
}
}
private struct PreviewLink: View {
let preview: Components.Schemas.Preview
let url: URL
@Environment(\.paneAccent) private var accent
@ViewState private var hover = false
var body: some View {
Link(destination: url) {
Text("\(preview.name) ↗")
.foregroundStyle(accent)
.underline(hover)
.lineLimit(1)
}
.help("Open \(preview.name) (port \(preview.port)) in a new window")
.onHover { hover = $0 }
}
}
private struct TabStrip: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.paneAccent) private var accent
@ViewState private var widths: [TaskTab: CGFloat] = [:]
@ViewState private var moreWidth: CGFloat = 0
@ViewState private var stripWidth: CGFloat = 0
@@ -363,7 +379,7 @@ private struct TabStrip: View {
if t == .reviews && reviewing {
return .spinner
}
return count(t) > 0 ? .count(count(t)) : .none
return count(t) > 0 ? .count(count(t), t == .inbox ? "inbox items" : "open findings in the latest review") : .none
}
}
@@ -427,8 +443,8 @@ private struct TabStrip: View {
}
}
if !hidden.isEmpty {
moreButton(flagged.map(info.badge)) {
overlays.menu = OverlayMenu(anchor: moreFrame, entries: hidden.map { t in
MoreButton(badges: flagged.map(info.badge)) {
overlays.menu = OverlayMenu(anchor: moreFrame, accent: accent, entries: hidden.map { t in
MenuEntry(id: t.rawValue, label: tabLabels[t]!, badge: info.badge(t)) {
store.focusTab(t)
}
@@ -455,7 +471,7 @@ private struct TabStrip: View {
widths[t] = w
}
}
moreButton([]) {}
MoreButton(badges: []) {}
.onGeometryChange(for: CGFloat.self) { g in
g.size.width
} action: { w in
@@ -474,8 +490,14 @@ private struct TabStrip: View {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
private func moreButton(_ badges: [TabBadge], _ action: @escaping () -> Void) -> some View {
private struct MoreButton: View {
let badges: [TabBadge]
let action: () -> Void
@ViewState private var hover = false
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Text("More ▾")
@@ -484,7 +506,7 @@ private struct TabStrip: View {
}
}
.font(Fonts.font(13, weight: badges.isEmpty ? 400 : 700))
.foregroundStyle(badges.isEmpty ? Palette.muted : Palette.warn)
.foregroundStyle(hover ? Palette.text : badges.isEmpty ? Palette.muted : Palette.warn)
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 6)
@@ -497,6 +519,7 @@ private struct TabStrip: View {
.buttonStyle(.plain)
.fixedSize()
.help("More tabs")
.onHover { hover = $0 }
}
}
+3 -1
View File
@@ -38,8 +38,10 @@ final class VisitRecord {
private let container: ModelContainer = {
let schema = Schema([ServerRecord.self, DraftRecord.self, VisitRecord.self])
let dir = URL.applicationSupportDirectory.appending(path: "run.fc.byre.mac", directoryHint: .isDirectory)
do {
return try ModelContainer(for: schema, configurations: ModelConfiguration(schema: schema, cloudKitDatabase: .private("iCloud.run.fc.byre.mac")))
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return try ModelContainer(for: schema, configurations: ModelConfiguration(schema: schema, url: dir.appending(path: "byre.store"), cloudKitDatabase: .private("iCloud.run.fc.byre.mac")))
} catch {
fatalError("open persistent store: \(error)")
}
+3 -2
View File
@@ -146,7 +146,7 @@ struct HoverRow<Content: View>: View {
HStack(spacing: 10) {
content
}
.padding(.leading, 10 + indent)
.padding(.leading, max(0, 10 + indent))
.padding(.trailing, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -226,10 +226,11 @@ private struct TodoBlock: View {
Text(item.section)
.font(Fonts.font(12, weight: 700))
.foregroundStyle(Palette.muted)
.padding(.top, 10)
.padding(.top, i == 0 ? 10 : 2)
.padding(.bottom, 4)
}
TodoRow(project: project, item: item, task: active.first { $0.todoItem == item.title })
.padding(.bottom, i == t.items.count - 1 || t.items[i + 1].section != item.section ? 8 : 0)
}
}
}
+29
View File
@@ -157,6 +157,7 @@ struct ReviewsView: View {
VStack(alignment: .leading, spacing: 12) {
if let shown {
ReviewCard(review: shown, taskId: taskId)
.id(shown.id)
} else {
Muted(text: "No review of snapshot #\(n) yet.")
}
@@ -200,6 +201,9 @@ struct ReviewCard: View {
Spacer(minLength: 0)
Text(ago(review.createdAt))
.help(isoText(review.createdAt))
if review.state == .running {
StopReviewButton(reviewId: review.id)
}
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
@@ -220,6 +224,30 @@ struct ReviewCard: View {
}
}
private struct StopReviewButton: View {
let reviewId: String
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var busy = false
var body: some View {
Button("Stop") {
busy = true
Task {
do {
_ = try await store.api.client.stopReview(path: .init(reviewId: reviewId)).ok
} catch {
busy = false
overlays.showAlert(errorText(error))
}
}
}
.buttonStyle(WebButtonStyle(danger: true))
.disabled(busy)
.help("End the reviewer and record this review as failed")
}
}
private struct ReviewResult: View {
let review: Review
let taskId: String?
@@ -338,6 +366,7 @@ private struct FindingDiff: View {
}
}
.task(id: "\(taskId)/\(n)/\(path)/\(line)/\(end)") {
failure = nil
do {
let patch = try await SnapshotPatches.patch(store.api, taskId, n, path)
let lines = patch.map { linesAround($0, line, end) } ?? []
+18 -7
View File
@@ -150,16 +150,27 @@ private struct LeadNotes: View {
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
})) {
let label = { (g: (String, String)) in "\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))" }
Menu {
ForEach(groups, id: \.0) { g in
Text("\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))").tag(g.0)
Button(label(g)) {
project = g.0
notesProject = g.0
}
}
} label: {
HStack(spacing: 8) {
Text(label(groups.first { $0.0 == current } ?? groups[0]))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(Palette.text)
.modifier(FieldStyle())
.contentShape(Rectangle())
}
.labelsHidden()
.pickerStyle(.menu)
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.fixedSize()
.help("Which notes to show")
.padding(.bottom, 8)
+8 -3
View File
@@ -352,17 +352,22 @@ private struct PaceBar: View {
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))
.fill(Palette.raised)
.overlay((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)
}
}
.padding(1)
.background {
RoundedRectangle(cornerRadius: 2)
.fill(Palette.surface)
.overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border))
}
.frame(minWidth: 80, maxWidth: .infinity)
.frame(height: 10)
.help(title)
+41 -8
View File
@@ -6,6 +6,7 @@ let taskTabs: [TaskTab] = [.transcript, .changes, .files, .terminal, .inbox, .re
private let historyPage = 1000
private let leadPage = 50
private let routeHash = Re(#"^#/(task|project)/(\w+)(?:/(\w+))?$"#)
struct TaskState {
enum Kind {
@@ -44,7 +45,7 @@ final class Store {
var composerFocus: String?
var summaryReads: [String: Int64] = [:]
private(set) var leadMore = false
var todoStarting: Set<String> = []
var todoBusy: [String: String] = [:]
private(set) var drafts: Drafts?
let images = ImageDrafts()
let events = Listeners<ByreEvent>()
@@ -76,39 +77,48 @@ final class Store {
drafts!.prune(self)
}
var todoStarting: Set<String> {
Set(todoBusy.keys)
}
func startTodo(_ projectId: String, _ title: String, discuss: Bool, _ overlays: Overlays) {
let key = "\(projectId)\n\(title)"
if todoStarting.contains(key) {
if todoBusy[key] != nil {
return
}
todoStarting.insert(key)
todoBusy[key] = discuss ? "discuss" : "start"
Task {
do {
_ = try await api.client.startTodoItem(path: .init(projectId: projectId), body: .json(.init(title: title, model: models[0], discuss: discuss))).created
} catch {
overlays.showAlert(errorText(error))
}
todoStarting.remove(key)
todoBusy[key] = nil
}
}
func startSuggestion(_ projectId: String, _ title: String, brief: String, _ overlays: Overlays) {
let key = "\(projectId)\n\(title)"
if todoStarting.contains(key) {
if todoBusy[key] != nil {
return
}
todoStarting.insert(key)
todoBusy[key] = "start"
Task {
do {
_ = try await api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, prompt: brief, model: models[0]))).created
} catch {
overlays.showAlert(errorText(error))
}
todoStarting.remove(key)
todoBusy[key] = nil
}
}
func discussSuggestion(_ title: String, brief: String, _ overlays: Overlays) {
func discussSuggestion(_ projectId: String, _ title: String, brief: String, _ overlays: Overlays) {
let key = "\(projectId)\n\(title)"
if todoBusy[key] != nil {
return
}
todoBusy[key] = "discuss"
let lead = currentLead()
let live = lead != nil && lead!.state != .completed
Task {
@@ -117,6 +127,7 @@ final class Store {
} catch {
overlays.showAlert(errorText(error))
}
todoBusy[key] = nil
}
}
@@ -310,6 +321,28 @@ final class Store {
}
}
func route(_ hash: String) {
let m = routeHash.exec(hash)
let kind = m?[1] ?? nil
let id = m?[2] ?? nil
let name = m?[3] ?? nil
let tab = taskTabs.first { $0.rawValue == name }
if kind == "task", let id {
if id != focus.taskId || (tab != nil && tab != focus.tab) {
spawn("set focus") {
try await self.setFocus(taskId: id, tab: tab)
}
}
return
}
let projectId = kind == "project" ? id : nil
if focus.taskId != nil || projectId != focus.projectId {
spawn("set focus") {
try await self.setFocus(taskId: .some(nil), projectId: .some(projectId))
}
}
}
private func loadHistory(_ taskId: String, _ feed: Feed) async throws {
var got: [ByreEvent] = []
while true {
+17 -3
View File
@@ -8,12 +8,14 @@ let goKeys: Set<String> = ["k", "p", "l", "s", "/"]
@MainActor
func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
overlays.image = nil
switch key {
case "k":
overlays.openSwitcher(store)
case "p":
store.focusTab(.project)
case "l":
overlays.switcher?.close()
overlays.leftTab = .lead
overlays.leadFocus += 1
case "s":
@@ -380,7 +382,7 @@ private struct SwitcherBox: View {
.padding(10)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
.shadow(color: .black.opacity(0.18), radius: 14, y: 8)
.shadow(color: overlayShadow, radius: 14, y: 8)
.onAppear {
DispatchQueue.main.async {
logFocus("switcher before focus")
@@ -406,7 +408,8 @@ private struct SwitcherRowView: View {
case .task(let project, let task, let count):
let ts = store.taskState(task)
let color = taskPalette[task.color]
StateMark(state: ts, color: color)
StateDot(filled: ts.kind == .working || ts.kind == .merging ? Palette.ok : ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : nil)
.help(ts.title)
Text(taskTitle(project, task, color))
.lineLimit(1)
.truncationMode(.tail)
@@ -429,32 +432,43 @@ private struct SwitcherRowView: View {
StateDot(filled: nil)
Text(p.name)
.foregroundStyle(Palette.muted)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
state("project")
case .newTask(let p, let title):
Text(created([("New task in ", nil), (p.name, Palette.muted), (": \(title)", nil)]))
.foregroundStyle(Palette.accent)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
state(models[0])
.help("Model for the first message")
case .startTask(let p, let title):
Text(created([("Start in ", nil), (p.name, Palette.muted), (": \(title)", nil)]))
.foregroundStyle(Palette.accent)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
state(models[0])
.help("The title is the agent's first message")
case .startWorkflow(let p, let title):
Text(created([("Start workflow… in ", nil), (p.name, Palette.muted), (": \(title)", nil)]))
.foregroundStyle(Palette.accent)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
state("workflow")
case .workflow(let p, let title, let w):
VStack(alignment: .leading, spacing: 0) {
Text(workflowTitle(p, title, w))
.lineLimit(1)
.truncationMode(.tail)
if !w.description.isEmpty {
Text(w.description)
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(1)
.truncationMode(.tail)
}
}
.foregroundStyle(Palette.accent)
+1
View File
@@ -78,6 +78,7 @@ struct TerminalHost: NSViewRepresentable {
func makeNSView(context: Context) -> TerminalView {
let t = TerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400))
t.font = Fonts.ct(13) as NSFont
t.changeScrollback(5000)
t.terminalDelegate = context.coordinator
context.coordinator.view = t
DispatchQueue.main.async {
-9
View File
@@ -780,12 +780,3 @@ private func resultText(_ content: (any Sendable)?) -> String {
}
return prettyJSON(content)
}
private func compactJSON(_ v: (any Sendable)?) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
guard let container = try? OpenAPIValueContainer(unvalidatedValue: v), let data = try? encoder.encode(container) else {
return String(describing: v)
}
return String(decoding: data, as: UTF8.self)
}
+16 -12
View File
@@ -75,9 +75,7 @@ struct WorkflowView: 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 {
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)
@@ -90,22 +88,28 @@ struct WorkflowView: View {
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)
if !error.isEmpty {
EmptyText(text: error, error: true)
} else {
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)
}
}
ForEach(run.stages, id: \.name) { s in
StageCard(run: run, stage: s)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
}
} else if !error.isEmpty {
EmptyText(text: error, error: true)
} else {
EmptyText(text: "Loading…")
}