macOS client parity review
This commit is contained in:
@@ -87,6 +87,6 @@ struct ByreApp: App {
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(KeyEquivalent(Character(key)))
|
||||
.disabled(connection.store == nil || overlays.isOpen)
|
||||
.disabled(connection.store == nil || overlays.dialogOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) } ?? []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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…")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user