import AppKit import ByreAPI import SwiftUI enum TabBadge { case none case count(Int, String) } struct TabBadgeView: View { let badge: TabBadge var body: some View { switch badge { case .none: EmptyView() case .count(let n, let help): Badge(count: n) .help(help) } } } let tabLabels: [TaskTab: String] = [ .transcript: "Transcript", .board: "Board", .changes: "Changes", .files: "Files", .terminal: "Terminal", .questions: "Questions", .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 overlays.window = window 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(hover ? Palette.text : attention ? Palette.warn : active ? 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: "Notes", active: tab == .notes) { overlays.leftTab = .notes } PaneTabButton(label: "Status", active: tab == .status) { overlays.leftTab = .status } PaneTabButton(label: "Server", active: tab == .server) { overlays.leftTab = .server } PaneTabButton(label: "Settings", active: tab == .settings) { overlays.leftTab = .settings } Spacer(minLength: 0) HStack(spacing: 10) { if !store.connected { Text("reconnecting…") .foregroundStyle(Palette.danger) .fixedSize() } 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) .truncationMode(.tail) } .lineLimit(1) } .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 == .notes { NotesView() } else if tab == .status { StatusView() } else if tab == .server { ServerView() } 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 = [] 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.taskId == store.selectedTaskId }) { store.focusTab(.questions) } } } @ViewBuilder private var tabBody: some View { switch store.focus.tab { case .transcript: ChatView(kind: .task(store.selectedTaskId)) .id("\(store.selectedTaskId ?? "")/\(store.selectedTaskId.flatMap { store.pickedMember($0) } ?? "")") case .board: BoardView() case .changes: ChangesView() case .files: FilesView() case .terminal: TerminalTab() case .questions: QuestionsView() 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 PreviewLink(preview: p, url: previewURL(store.api.origin, p.port)) } } .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 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 @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 == .questions ? waiting : t == .reviews && !reviewing ? findings : 0 } func badge(_ t: TaskTab) -> TabBadge { count(t) > 0 ? .count(count(t), t == .questions ? "open questions" :"open findings in the latest review") : .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 team = store.selectedTaskId.map { !store.team($0).isEmpty } ?? false let order = (first + taskTabs.filter { !first.contains($0) }).filter { t in if t == tab { return true } switch t { case .board: return team case .questions: 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(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) } }) } .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(badges: []) {} .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 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 ▾") ForEach(Array(badges.enumerated()), id: \.offset) { _, b in TabBadgeView(badge: b) } } .font(Fonts.font(13, weight: badges.isEmpty ? 400 : 700)) .foregroundStyle(hover ? Palette.text : 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") .onHover { hover = $0 } } } 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)) } } } }