macOS client panes and transcript
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
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 {
|
||||
overlays.install()
|
||||
}
|
||||
.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 enum LeftTab {
|
||||
case lead, status, settings
|
||||
}
|
||||
|
||||
private struct LeftPane: View {
|
||||
@Environment(Store.self) private var store
|
||||
@ViewState private var tab = LeftTab.lead
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
PaneTabButton(label: "Lead", active: tab == .lead) {
|
||||
tab = .lead
|
||||
}
|
||||
PaneTabButton(label: "Status", active: tab == .status) {
|
||||
tab = .status
|
||||
}
|
||||
PaneTabButton(label: "Settings", active: tab == .settings) {
|
||||
tab = .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 != .lead {
|
||||
NotYet(label: tab == .status ? "Status" : "Settings")
|
||||
.background(Palette.bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotYet: View {
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Muted(text: "\(label) isn't in the macOS app yet.")
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TaskPane: View {
|
||||
@Environment(Store.self) private var store
|
||||
@ViewState private var seen: Set<String> = []
|
||||
|
||||
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)
|
||||
}
|
||||
.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 && 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 ?? "")
|
||||
default:
|
||||
NotYet(label: tabLabels[store.focus.tab]!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TaskBar: View {
|
||||
@Environment(Store.self) private var store
|
||||
@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)
|
||||
Menu {
|
||||
ForEach(store.activeTasks(), id: \.task.id) { row in
|
||||
Button("\(row.project.name): \(row.task.title)") {
|
||||
store.focusTask(row.task.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("\(label) ▾")
|
||||
.font(Fonts.font(13, weight: 700))
|
||||
.foregroundStyle(accent)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.buttonStyle(.plain)
|
||||
.menuIndicator(.hidden)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.help("Switch task (⌘K)")
|
||||
.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user