Files
byre/mac/Sources/Byre/Panes.swift
T
2026-10-17 08:51:59 -07:00

557 lines
18 KiB
Swift

import AppKit
import SwiftUI
enum TabBadge {
case none
case count(Int)
case spinner
}
struct TabBadgeView: View {
let badge: TabBadge
@Environment(\.paneAccent) private var accent
var body: some View {
switch badge {
case .none:
EmptyView()
case .count(let n):
Badge(count: n)
case .spinner:
Spinner(color: accent)
.help("review in progress")
}
}
}
let tabLabels: [TaskTab: String] = [
.transcript: "Transcript",
.changes: "Changes",
.files: "Files",
.terminal: "Terminal",
.inbox: "Inbox",
.reviews: "Reviews",
.workflow: "Workflow",
.project: "Project",
]
struct HalfSplit: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let half = bounds.width / 2
for (i, sub) in subviews.enumerated() {
sub.place(at: CGPoint(x: bounds.minX + half * CGFloat(i), y: bounds.minY), proposal: ProposedViewSize(width: half, height: bounds.height))
}
}
}
struct MainView: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
VStack(spacing: 0) {
IdentityBanner()
HalfSplit {
LeftPane()
TaskPane()
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
}
}
.overlay {
OverlayHost()
}
.coordinateSpace(.named("window"))
.environment(\.openURL, OpenURLAction { url in
openAppLink(url, store, overlays)
})
.background {
WindowReader { window in
store.paneFocus.attach(window)
}
}
.onAppear {
let store = store
let overlays = overlays
overlays.install { key in
ctrlGo(key, store, overlays)
}
}
.onDisappear {
overlays.uninstall()
let store = store
Task {
await store.paneFocus.leave()
}
}
}
}
struct PaneTabButton: View {
let label: String
var active = false
var attention = false
var badge = TabBadge.none
let action: () -> Void
@Environment(\.paneAccent) private var accent
@ViewState private var hover = false
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Text(label)
TabBadgeView(badge: badge)
}
.font(Fonts.font(13, weight: attention ? 700 : 400))
.foregroundStyle(attention ? Palette.warn : active || hover ? Palette.text : Palette.muted)
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 6)
.background(attention ? Palette.warnTint : .clear)
.overlay(alignment: .bottom) {
Rectangle()
.fill(active ? accent : attention ? Palette.warn : .clear)
.frame(height: 2)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.fixedSize()
.onHover { hover = $0 }
}
}
private struct LeftPane: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
let tab = overlays.leftTab
VStack(spacing: 0) {
HStack(spacing: 4) {
PaneTabButton(label: "Lead", active: tab == .lead) {
overlays.leftTab = .lead
}
PaneTabButton(label: "Status", active: tab == .status) {
overlays.leftTab = .status
}
PaneTabButton(label: "Settings", active: tab == .settings) {
overlays.leftTab = .settings
}
Spacer(minLength: 0)
HStack(spacing: 10) {
if !store.connected {
Text("reconnecting…")
.foregroundStyle(Palette.danger)
}
Circle()
.fill(store.connected ? Palette.ok : Palette.danger)
.frame(width: 8, height: 8)
.help(store.connected ? "live" : "reconnecting")
Text(store.me?.displayName ?? "")
.foregroundStyle(Palette.muted)
}
.lineLimit(1)
.fixedSize()
}
.padding(.horizontal, 12)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
ZStack {
ChatView(kind: .lead)
.opacity(tab == .lead ? 1 : 0)
.allowsHitTesting(tab == .lead)
.accessibilityHidden(tab != .lead)
if tab == .status {
StatusView()
} else if tab == .settings {
SettingsView()
}
}
}
}
}
private struct TaskPane: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var seen: Set<String> = []
var body: some View {
let selected = store.selectedTaskId.flatMap { store.findTask($0) }
let accent = selected.map { taskPalette[$0.color] } ?? Palette.accent
VStack(spacing: 0) {
TaskBar()
TabStrip()
tabBody
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.environment(\.paneAccent, accent)
.environment(\.inRightPane, true)
.onGeometryChange(for: CGRect.self) { g in
g.frame(in: .global)
} action: { frame in
store.paneFocus.setFrame(frame)
}
.onGeometryChange(for: CGRect.self) { g in
g.frame(in: .named("window"))
} action: { frame in
overlays.paneFrame = frame
}
.onAppear {
seen = Set(store.inbox.map(\.id))
}
.onChange(of: store.inbox.map(\.id)) {
let fresh = store.inbox.filter { !seen.contains($0.id) }
for item in fresh {
seen.insert(item.id)
}
if store.focus.tab == .transcript && !store.paneFocus.drafting && fresh.contains(where: { $0.kind == .question && $0.taskId == store.selectedTaskId }) {
store.focusTab(.inbox)
}
}
}
@ViewBuilder
private var tabBody: some View {
switch store.focus.tab {
case .transcript:
ChatView(kind: .task(store.selectedTaskId))
.id(store.selectedTaskId ?? "")
case .changes:
ChangesView()
case .files:
FilesView()
case .terminal:
TerminalTab()
case .inbox:
InboxView()
case .reviews:
ReviewsView()
case .workflow:
WorkflowView()
case .project:
ProjectView()
}
}
}
private struct TaskBar: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.paneAccent) private var accent
@ViewState private var previews: [Components.Schemas.Preview] = []
@ViewState private var target = 0
@ViewState private var off: (() -> Void)?
var body: some View {
let task = store.selectedTaskId.flatMap { store.findTask($0) }
let project = store.focusedProject()
let label = task.map { "\(project?.name ?? "") ▸ \($0.title)" } ?? project?.name ?? "No task"
HStack(spacing: 0) {
Color.clear.frame(maxWidth: .infinity, maxHeight: 1)
Button {
overlays.openSwitcher(store)
} label: {
Text("\(label) ▾")
.font(Fonts.font(13, weight: 700))
.foregroundStyle(accent)
.lineLimit(1)
.truncationMode(.tail)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.help("Switch task (⌘K)")
.onGeometryChange(for: CGRect.self) { g in
g.frame(in: .named("window"))
} action: { frame in
overlays.pickerFrame = frame
}
.layoutPriority(1)
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")
}
}
.frame(maxWidth: .infinity)
}
.padding(.horizontal, 12)
.background {
ZStack {
Palette.bg
accent.opacity(0.12)
}
}
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
.task(id: store.selectedTaskId) {
previews = []
await load()
}
.onAppear {
off = store.events.add { ev in
if ev.kind == "preview.exposed" && ev.taskId == store.selectedTaskId {
Task {
await load()
}
}
}
}
.onDisappear {
off?()
off = nil
}
}
private func load() async {
target += 1
let started = target
guard let taskId = store.selectedTaskId else {
previews = []
return
}
do {
let list = try await store.api.client.listPreviews(path: .init(taskId: taskId)).ok.body.json
if started == target {
previews = list
}
} catch {
if started == target {
previews = []
}
log.error("list previews: \(errorText(error), privacy: .public)")
}
}
}
private struct TabStrip: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var widths: [TaskTab: CGFloat] = [:]
@ViewState private var moreWidth: CGFloat = 0
@ViewState private var stripWidth: CGFloat = 0
@ViewState private var moreFrame: CGRect = .zero
private struct Info {
var order: [TaskTab]
var tab: TaskTab
var waiting: Int
var findings: Int
var reviewing: Bool
func count(_ t: TaskTab) -> Int {
t == .inbox ? waiting : t == .reviews && !reviewing ? findings : 0
}
func badge(_ t: TaskTab) -> TabBadge {
if t == .reviews && reviewing {
return .spinner
}
return count(t) > 0 ? .count(count(t)) : .none
}
}
private func info() -> Info {
var first: [TaskTab] = []
for t in store.focus.tabs where taskTabs.contains(t) && !first.contains(t) {
first.append(t)
}
let tab = store.focus.tab
let waiting = store.inbox.filter { $0.taskId == store.selectedTaskId }.count
let transcript = store.transcript
let latest = latestReview(transcript)
let reviewing = latest?.state == .running
let findings = latest.map(findingCount) ?? 0
let hasReviews = transcript.contains(where: isReviewEvent)
let workflow = store.selectedTaskId.flatMap { store.findTask($0) }?.workflowRun != nil
let order = (first + taskTabs.filter { !first.contains($0) }).filter { t in
if t == tab {
return true
}
switch t {
case .inbox: return waiting > 0
case .reviews: return hasReviews
case .workflow: return workflow
default: return true
}
}
return Info(order: order, tab: tab, waiting: waiting, findings: findings, reviewing: reviewing)
}
private func kept(_ info: Info) -> [TaskTab] {
let gap: CGFloat = 4
let inner = stripWidth - 24
func total(_ ts: [TaskTab]) -> CGFloat {
ts.reduce(0) { $0 + (widths[$1] ?? 0) + gap }
}
let all = info.order
if stripWidth == 0 || total(all) <= inner {
return all
}
let budget = inner - moreWidth - gap
var kept: [TaskTab] = []
for t in [info.tab] + all.filter({ $0 != info.tab }) {
if !kept.isEmpty && total(kept + [t]) > budget {
break
}
kept.append(t)
}
return kept
}
var body: some View {
let info = info()
let kept = kept(info)
let hidden = info.order.filter { !kept.contains($0) }
let flagged = hidden.filter { info.count($0) > 0 }
HStack(spacing: 4) {
ForEach(info.order.filter { kept.contains($0) }, id: \.self) { t in
PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) {
store.focusTab(t)
}
}
if !hidden.isEmpty {
moreButton(flagged.map(info.badge)) {
overlays.menu = OverlayMenu(anchor: moreFrame, entries: hidden.map { t in
MenuEntry(id: t.rawValue, label: tabLabels[t]!, badge: info.badge(t)) {
store.focusTab(t)
}
})
}
.onGeometryChange(for: CGRect.self) { g in
g.frame(in: .named("window"))
} action: { frame in
moreFrame = frame
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.clipped()
.background {
HStack(spacing: 4) {
ForEach(info.order, id: \.self) { t in
PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) {}
.onGeometryChange(for: CGFloat.self) { g in
g.size.width
} action: { w in
widths[t] = w
}
}
moreButton([]) {}
.onGeometryChange(for: CGFloat.self) { g in
g.size.width
} action: { w in
moreWidth = w
}
}
.fixedSize()
.hidden()
}
.onGeometryChange(for: CGFloat.self) { g in
g.size.width
} action: { w in
stripWidth = w
}
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
private func moreButton(_ badges: [TabBadge], _ action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 6) {
Text("More ▾")
ForEach(Array(badges.enumerated()), id: \.offset) { _, b in
TabBadgeView(badge: b)
}
}
.font(Fonts.font(13, weight: badges.isEmpty ? 400 : 700))
.foregroundStyle(badges.isEmpty ? Palette.muted : Palette.warn)
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 6)
.background(badges.isEmpty ? .clear : Palette.warnTint)
.overlay(alignment: .bottom) {
Rectangle().fill(badges.isEmpty ? .clear : Palette.warn).frame(height: 2)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.fixedSize()
.help("More tabs")
}
}
private let emailShape = Re(#"^[^@\s]+@[^@\s]+$"#)
private struct IdentityBanner: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var name: String?
@ViewState private var email = ""
var body: some View {
if let me = store.me, me.gitName.isEmpty || me.gitEmail.isEmpty {
let gitName = name ?? me.displayName
let valid = !gitName.isEmpty && emailShape.test(email)
HStack(spacing: 6) {
Text("Set the identity agents commit as: ")
TextField("Git name", text: Binding(get: { gitName }, set: { name = $0 }))
.modifier(FieldStyle())
.frame(width: 220)
.onSubmit {
save(gitName, valid)
}
TextField("Git email", text: $email)
.modifier(FieldStyle())
.frame(width: 260)
.onSubmit {
save(gitName, valid)
}
Button("Save") {
save(gitName, valid)
}
.buttonStyle(WebButtonStyle())
.disabled(!valid)
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Palette.accentTint)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
private func save(_ gitName: String, _ valid: Bool) {
if !valid {
return
}
Task {
do {
store.me = try await store.api.client.updateMe(body: .json(.init(gitName: gitName, gitEmail: email))).ok.body.json
} catch {
overlays.showAlert(errorText(error))
}
}
}
}