Files
gib edc4b88990
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway: an approval push for a file change says which files it would edit
2026-11-02 14:45:34 -05:00

287 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done,
// red means broken, and violet means snoozed. Green and red also count lines added and removed.
import SwiftUI
enum Theme {
static let you = Color(red: 1.0, green: 0.690, blue: 0.180) // #ffb02e
static let work = Color(red: 0.310, green: 0.820, blue: 0.773) // #4fd1c5
static let done = Color(red: 0.420, green: 0.420, blue: 0.439) // #6b6b70
static let card = Color(red: 0.090, green: 0.090, blue: 0.102) // #17171a
static let control = Color(red: 0.173, green: 0.173, blue: 0.180) // #2c2c2e
static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e
static let add = Color(red: 0.353, green: 0.820, blue: 0.478) // #5ad17a
static let snooze = Color(red: 0.608, green: 0.549, blue: 1.0) // #9b8cff
}
extension Text {
/// Text marked as inline code, which Text sets in monospace. A monospaced Font loses to the app's
/// rounded design.
init(code: String) {
var attributed = AttributedString(code)
attributed.inlinePresentationIntent = .code
self.init(attributed)
}
/// "+42 −8 · 3 files" in monospace, the additions green, the deletions red and the rest in the
/// current style. Without `files`, just "+42 −8", as on a changed file's row.
init(changes: ChangeTotals, files: Bool = true) {
let parts = changes.parts
let counts = Text("\(Text(code: parts.additions).foregroundStyle(Theme.add))\(Text(code: " "))\(Text(code: parts.deletions).foregroundStyle(Theme.bad))")
self = files ? Text("\(counts)\(Text(code: " · \(parts.files)"))") : counts
}
}
extension Activity.Kind {
/// The symbol in front of an activity line.
var symbol: String {
switch self {
case .command: "terminal"
case .edit: "pencil"
case .search: "magnifyingglass"
case .web: "globe"
case .subagent: "arrow.triangle.branch"
case .thinking: "ellipsis"
default: "wrench.adjustable"
}
}
}
extension Phase {
var color: Color {
if needsYou { return Theme.you }
if isWorking { return Theme.work }
return Theme.done
}
}
extension ThreadRow {
/// The status dot: amber waiting, teal working, red failed, grey otherwise.
var color: Color { phase == .failed ? Theme.bad : phase?.color ?? Theme.done }
}
/// A dark rounded card with a 4 pt colored left edge.
struct Card<Content: View>: View {
var edge: Color
@ViewBuilder var content: Content
var body: some View {
VStack(alignment: .leading, spacing: 0) {
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 9)
.padding(.leading, 15)
.padding(.trailing, 11)
.background(alignment: .leading) {
ZStack(alignment: .leading) {
Theme.card
edge.frame(width: 4)
}
}
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
}
/// A card holding one switch, like the mocks' setting rows. `tint` fills the switch when it is on.
/// The whole card toggles it.
struct SwitchCard: View {
var title: String
@Binding var isOn: Bool
var tint: Color = Theme.work
var edge: Color = Theme.card
var body: some View {
Toggle(isOn: $isOn) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
}
.toggleStyle(CardSwitchStyle(tint: tint, edge: edge))
}
}
/// The mocks' switch drawn by hand: watchOS's own renders as a placeholder in snapshots.
private struct CardSwitchStyle: ToggleStyle {
var tint: Color
var edge: Color
func makeBody(configuration: Configuration) -> some View {
Button { configuration.isOn.toggle() } label: {
Card(edge: edge) {
HStack {
configuration.label
Spacer(minLength: 4)
Capsule()
.fill(configuration.isOn ? tint : Color(white: 0.23))
.frame(width: 40, height: 24)
.overlay(alignment: configuration.isOn ? .trailing : .leading) {
Circle().fill(.white).padding(2.5)
}
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityRepresentation {
Toggle(isOn: configuration.$isOn) { configuration.label }
}
}
}
/// A full-width pill button, like mock B's Reply and Say the task.
struct PillButtonStyle: ButtonStyle {
var fill: Color
var foreground: Color = .black
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(foreground)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: Capsule())
}
}
/// A smaller button for choices on a card, such as Allow and Deny.
struct ChoiceButtonStyle: ButtonStyle {
var fill: Color = Theme.control
var foreground: Color = .white
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(foreground)
.lineLimit(2)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.padding(.horizontal, 4)
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
enum Format {
/// A short age for a card, such as "now", "4 min", "2 h" or "3 d".
static func age(of date: Date, now: Date = .now) -> String {
let minutes = Int(now.timeIntervalSince(date) / 60)
if minutes < 1 { return "now" }
if minutes < 60 { return "\(minutes) min" }
let hours = minutes / 60
if hours < 24 { return "\(hours) h" }
return "\(hours / 24) d"
}
/// A compact time since a date, such as "42s", "3m", "1h" or "2d", for an activity's elapsed time
/// and a thread row's age.
static func elapsed(since date: Date, now: Date = .now) -> String {
let seconds = max(0, Int(now.timeIntervalSince(date)))
if seconds < 60 { return "\(seconds)s" }
if seconds < 3600 { return "\(seconds / 60)m" }
if seconds < 86_400 { return "\(seconds / 3600)h" }
return "\(seconds / 86_400)d"
}
/// How long until a date, such as "40 s", "3 min" or "2 h".
static func countdown(to date: Date, now: Date = .now) -> String {
let seconds = max(0, Int(date.timeIntervalSince(now).rounded()))
if seconds < 60 { return "\(seconds) s" }
if seconds < 3600 { return "\(seconds / 60) min" }
return "\(seconds / 3600) h"
}
/// "expires in 29 days", "expires today" or "expired 2 days ago", for a token.
static func expiry(_ date: Date, now: Date = .now) -> String {
let days = Int((date.timeIntervalSince(now) / 86_400).rounded(.down))
if date < now {
let ago = Int(now.timeIntervalSince(date) / 86_400)
return ago < 1 ? "expired today" : "expired \(ago) day\(ago == 1 ? "" : "s") ago"
}
if days < 1 { return "expires today" }
return "expires in \(days) day\(days == 1 ? "" : "s")"
}
/// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5".
static func modelName(_ id: String) -> String {
var words: [String] = []
var number: [String] = []
func flush() {
if !number.isEmpty { words.append(number.joined(separator: ".")) }
number = []
}
for part in id.split(whereSeparator: { $0 == "-" || $0 == "_" }).map(String.init) {
if part.allSatisfy(\.isNumber) {
number.append(part)
continue
}
flush()
switch part.lowercased() {
case "gpt": words.append("GPT")
default: words.append(part.prefix(1).uppercased() + part.dropFirst())
}
}
flush()
return words.joined(separator: " ")
}
}
/// Lays children out left to right and wraps them onto new lines, like the mock's reply chips.
struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews)
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: rows.last.map { $0.y + $0.height } ?? 0)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
for row in arrange(width: bounds.width, subviews: subviews) {
var x = bounds.minX
for index in row.indices {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(at: CGPoint(x: x, y: bounds.minY + row.y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
}
}
private struct Row {
var indices: [Int] = []
var y: CGFloat = 0
var width: CGFloat = 0
var height: CGFloat = 0
}
private func arrange(width: CGFloat, subviews: Subviews) -> [Row] {
var rows: [Row] = []
var row = Row()
for index in subviews.indices {
let size = subviews[index].sizeThatFits(.unspecified)
if !row.indices.isEmpty, row.width + spacing + size.width > width {
rows.append(row)
row = Row(y: row.y + row.height + spacing)
}
row.width += (row.indices.isEmpty ? 0 : spacing) + size.width
row.height = max(row.height, size.height)
row.indices.append(index)
}
if !row.indices.isEmpty { rows.append(row) }
return rows
}
}
/// A content-sized chip, for one-tap replies.
struct ChipButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Theme.card.opacity(configuration.isPressed ? 0.6 : 1), in: Capsule())
}
}