Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **iOS: real brand marks for PagerDuty, Pylon, Datadog, Alertmanager and AWS.** The four event sources left on SF Symbols (a bell, a life preserver, an ECG line, a dog) show the same marks as the web and Android apps everywhere a trigger is drawn — the New work form's Starts menu, scheduled Task, Job and agent trigger lists, Local automations — and `Brand(url:)` recognizes PagerDuty, Pylon, Datadog and Grafana / Alertmanager links. The Connections hub draws each catalog provider, the new-connection sheet and every connection row with the provider's brand (Notion, GitHub, Slack, Linear, Sentry, AWS, Pylon, PagerDuty) instead of a stand-in symbol; the generic providers (database, filesystem, terminal, HTTP API) keep theirs.

### Fixed

- iOS builds again from a fresh checkout: SwiftTerm is pinned to 1.20.0 in `project.yml`, since 1.99.0 (released 2026-10-08) removed `TerminalView.getTerminal()`, which the terminal theme and the local terminal stream call.

## [0.15.0] - 2026-10-09

### Added
Expand Down
22 changes: 7 additions & 15 deletions apps/ios/Optio/Features/More/Connections/ConnectionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,10 @@ final class ConnectionsModel {
}

enum ConnectionIcons {
static func symbol(for provider: ConnectionProviderRow?) -> String {
switch provider?.icon {
case "notion": return "doc.text"
case "github": return "chevron.left.forwardslash.chevron.right"
case "slack": return "bubble.left.and.bubble.right"
case "linear": return "chart.bar"
case "database": return "cylinder"
case "sentry": return "ant"
case "folder": return "folder"
case "terminal": return "terminal"
case "globe": return "globe"
default: return "powerplug"
}
/// A provider's mark: its brand logo (Notion, GitHub, Slack, Linear, Sentry,
/// AWS, Pylon, PagerDuty) or a generic symbol (`Glyph.provider(icon:)`).
static func glyph(for provider: ConnectionProviderRow?) -> Glyph {
.provider(icon: provider?.icon)
}

static func statusColor(_ status: String?) -> Color {
Expand Down Expand Up @@ -136,7 +127,7 @@ struct ConnectionsView: View {
newProvider = p
} label: {
HStack(spacing: 12) {
Image(systemName: ConnectionIcons.symbol(for: p))
GlyphView(glyph: ConnectionIcons.glyph(for: p), size: 17)
.frame(width: 24)
.foregroundStyle(AppTheme.accent)
VStack(alignment: .leading, spacing: 2) {
Expand Down Expand Up @@ -245,7 +236,8 @@ struct ConnectionsView: View {
conn.lastCheckedAt.map { "checked \($0.relativeDescription)" },
]),
trailing: conn.enabled == false ? "Paused" : nil,
titleLineLimit: 1
titleLineLimit: 1,
glyph: provider.map { ConnectionIcons.glyph(for: $0) }
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ struct NewConnectionSheet: View {
Form {
Section {
HStack(spacing: 10) {
Image(systemName: ConnectionIcons.symbol(for: provider)).foregroundStyle(AppTheme.accent)
GlyphView(glyph: ConnectionIcons.glyph(for: provider), size: 20).foregroundStyle(AppTheme.accent)
VStack(alignment: .leading, spacing: 2) {
Text(provider.name ?? provider.slug ?? "").font(.headline)
if let d = provider.description {
Expand Down
12 changes: 8 additions & 4 deletions apps/ios/Optio/Features/Work/Feed/New/WorkFormModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ enum WorkForm {
}
}

/// The menu / row mark: GitHub, GitLab, Slack, Linear, Jira and Sentry
/// show their brands; a ticket trigger shows its source's (see
/// `ticketGlyph`). PagerDuty, Pylon, Alertmanager and Datadog have no
/// brand asset yet, so they keep their symbols.
/// The menu / row mark: the event sources (GitHub, GitLab, Slack,
/// Linear, Jira, Sentry, PagerDuty, Pylon, Alertmanager, Datadog) show
/// their brands; a ticket trigger shows its source's (see
/// `ticketGlyph`); Now / Schedule / Webhook keep their symbols.
var glyph: Glyph {
switch self {
case .github: return .brand(.github)
Expand All @@ -105,6 +105,10 @@ enum WorkForm {
case .linear: return .brand(.linear)
case .jira: return .brand(.jira)
case .sentry: return .brand(.sentry)
case .pagerduty: return .brand(.pagerduty)
case .pylon: return .brand(.pylon)
case .alertmanager: return .brand(.alertmanager)
case .datadog: return .brand(.datadog)
default: return .symbol(systemImage)
}
}
Expand Down
29 changes: 25 additions & 4 deletions apps/ios/OptioTests/BrandMarkTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ final class BrandMarkTests: XCTestCase {
XCTAssertEqual(Glyph.trigger("github"), .brand(.github))
XCTAssertEqual(Glyph.trigger("slack"), .brand(.slack))
XCTAssertEqual(Glyph.trigger("linear"), .brand(.linear))
XCTAssertEqual(Glyph.trigger("pagerduty"), .symbol("bell.badge"))
XCTAssertEqual(Glyph.trigger("pylon"), .symbol("lifepreserver"))
XCTAssertEqual(Glyph.trigger("pagerduty"), .brand(.pagerduty))
XCTAssertEqual(Glyph.trigger("pylon"), .brand(.pylon))
XCTAssertEqual(Glyph.trigger("gitlab"), .brand(.gitlab))
XCTAssertEqual(Glyph.trigger("jira"), .brand(.jira))
XCTAssertEqual(Glyph.trigger("sentry"), .brand(.sentry))
XCTAssertEqual(Glyph.trigger("alertmanager"), .symbol("waveform.path.ecg"))
XCTAssertEqual(Glyph.trigger("datadog"), .symbol("dog"))
XCTAssertEqual(Glyph.trigger("alertmanager"), .brand(.alertmanager))
XCTAssertEqual(Glyph.trigger("datadog"), .brand(.datadog))
XCTAssertEqual(Glyph.trigger("ticket", source: "jira"), .brand(.jira))
XCTAssertEqual(Glyph.trigger("ticket", source: "gitlab"), .brand(.gitlab))
XCTAssertEqual(Glyph.trigger("ticket"), .symbol("ticket"))
Expand Down Expand Up @@ -65,10 +65,31 @@ final class BrandMarkTests: XCTestCase {
XCTAssertEqual(Brand.copilot.label, "GitHub Copilot")
}

/// The Connections hub: a provider's catalog `icon` key → its brand mark, the
/// generic keys → symbols, anything else → a plug.
func testProviderGlyphs() {
for (icon, brand) in [("notion", Brand.notion), ("github", .github), ("slack", .slack), ("linear", .linear),
("sentry", .sentry), ("aws", .aws), ("pylon", .pylon), ("pagerduty", .pagerduty)] {
XCTAssertEqual(Glyph.provider(icon: icon), .brand(brand), icon)
}
XCTAssertEqual(Glyph.provider(icon: "database"), .symbol("cylinder"))
XCTAssertEqual(Glyph.provider(icon: "folder"), .symbol("folder"))
XCTAssertEqual(Glyph.provider(icon: "terminal"), .symbol("terminal"))
XCTAssertEqual(Glyph.provider(icon: "globe"), .symbol("globe"))
XCTAssertEqual(Glyph.provider(icon: "custom"), .symbol("powerplug"))
XCTAssertEqual(Glyph.provider(icon: nil), .symbol("powerplug"))
XCTAssertEqual(Brand.aws.label, "AWS")
XCTAssertEqual(Brand.pagerduty.label, "PagerDuty")
}

func testBrandFromURLAndPRState() {
XCTAssertEqual(Brand(url: "https://github.com/a/b/pull/1"), .github)
XCTAssertEqual(Brand(url: "https://gitlab.example.com/g/p/-/merge_requests/2"), .gitlab)
XCTAssertEqual(Brand(url: "https://linear.app/acme/issue/ENG-1"), .linear)
XCTAssertEqual(Brand(url: "https://acme.pagerduty.com/incidents/P1"), .pagerduty)
XCTAssertEqual(Brand(url: "https://app.usepylon.com/issues/1"), .pylon)
XCTAssertEqual(Brand(url: "https://app.datadoghq.eu/monitors/7"), .datadog)
XCTAssertEqual(Brand(url: "https://grafana.example.com/alerting/list"), .alertmanager)
XCTAssertNil(Brand(url: "https://example.com"))
XCTAssertEqual(PRGlyphState("MERGED"), .merged)
XCTAssertEqual(PRGlyphState(nil), .open)
Expand Down
6 changes: 4 additions & 2 deletions apps/ios/OptioTests/WorkFormModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,10 @@ final class WorkFormModelTests: XCTestCase {
XCTAssertEqual(F.WhenType.gitlab.glyph, .brand(.gitlab))
XCTAssertEqual(F.WhenType.jira.glyph, .brand(.jira))
XCTAssertEqual(F.WhenType.sentry.glyph, .brand(.sentry))
XCTAssertEqual(F.WhenType.alertmanager.glyph, .symbol("waveform.path.ecg"))
XCTAssertEqual(F.WhenType.datadog.glyph, .symbol("dog"))
XCTAssertEqual(F.WhenType.alertmanager.glyph, .brand(.alertmanager))
XCTAssertEqual(F.WhenType.datadog.glyph, .brand(.datadog))
XCTAssertEqual(F.WhenType.pagerduty.glyph, .brand(.pagerduty))
XCTAssertEqual(F.WhenType.pylon.glyph, .brand(.pylon))

XCTAssertEqual(F.defaultEventConfig(.gitlab), ["events": .array([.string("review_requested"), .string("mentioned")]), "username": .string("")])
XCTAssertEqual(F.defaultEventConfig(.jira), ["events": .array([.string("assigned"), .string("mentioned")]), "user": .string("")])
Expand Down
60 changes: 60 additions & 0 deletions apps/ios/OptioUITests/BrandMarksUITests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import XCTest

/// Walks the surfaces that draw provider and trigger brand marks and saves
/// screenshots of them: the Connections hub scrolled to the Cloud / Other
/// providers (AWS, Pylon, PagerDuty) and the New work form's Starts row on
/// each event source. Needs a dev server:
/// OPTIO_UITEST_SERVER_URL, OPTIO_UITEST_TOKEN
/// The screenshots land in the test attachments (and, when
/// OPTIO_UITEST_SHOT_DIR is set, as PNG files in that directory).
final class BrandMarksUITests: XCTestCase {
func testConnectionsCatalogMarks() throws {
let app = try launch(openURL: "optio://section/connections")
XCTAssertTrue(app.staticTexts["Notion"].waitForExistence(timeout: 10), "catalog did not load")
// Scroll until the last catalog group is on screen.
for _ in 0..<8 where !app.staticTexts["PagerDuty"].exists {
app.swipeUp()
}
XCTAssertTrue(app.staticTexts["PagerDuty"].waitForExistence(timeout: 4), "PagerDuty provider not reached")
sleep(1)
save(app.screenshot(), "connections-catalog")
}

func testWorkFormStartsRowMarks() throws {
for source in ["pylon", "datadog", "alertmanager"] {
let app = try launch(openURL: "optio://work/new", extra: [
"OPTIO_DEV_NEW_SESSION": "schedule",
"OPTIO_DEV_NEW_SESSION_TWEAKS": "when=\(source),scroll=when",
])
XCTAssertTrue(app.staticTexts["Starts"].waitForExistence(timeout: 10), "form did not open for \(source)")
sleep(1)
save(app.screenshot(), "form-\(source)")
app.terminate()
}
}

private func launch(openURL: String, extra: [String: String] = [:]) throws -> XCUIApplication {
let env = ProcessInfo.processInfo.environment
guard let server = env["OPTIO_UITEST_SERVER_URL"], let token = env["OPTIO_UITEST_TOKEN"] else {
throw XCTSkip("set OPTIO_UITEST_SERVER_URL / _TOKEN")
}
let app = XCUIApplication()
app.launchEnvironment["OPTIO_DEV_SERVER_URL"] = server
app.launchEnvironment["OPTIO_DEV_TOKEN"] = token
app.launchEnvironment["OPTIO_DEV_NO_PUSH_PROMPT"] = "1"
app.launchEnvironment["OPTIO_DEV_OPEN_URL"] = openURL
for (k, v) in extra { app.launchEnvironment[k] = v }
app.launch()
return app
}

private func save(_ shot: XCUIScreenshot, _ name: String) {
let a = XCTAttachment(screenshot: shot)
a.name = name
a.lifetime = .keepAlways
add(a)
if let dir = ProcessInfo.processInfo.environment["OPTIO_UITEST_SHOT_DIR"] {
try? shot.pngRepresentation.write(to: URL(fileURLWithPath: dir).appendingPathComponent("\(name).png"))
}
}
}
63 changes: 43 additions & 20 deletions apps/ios/Shared/BrandMark.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import SwiftUI
import UIKit

// Brand marks for the things work comes from and links to — GitHub, GitLab,
// Slack, Linear, Jira, Notion, Sentry — the agent runtimes that do it — Claude
// Code, OpenAI Codex, GitHub Copilot, Google Gemini, Cursor, OpenCode — plus
// GitHub's pull-request and issue glyphs. The iOS twin of the web's `components/brand-icon.tsx`.
// Slack, Linear, Jira, Notion, Sentry, PagerDuty, Pylon, Datadog, Alertmanager,
// AWS — the agent runtimes that do it — Claude Code, OpenAI Codex, GitHub
// Copilot, Google Gemini, Cursor, OpenCode — plus GitHub's pull-request and
// issue glyphs. The iOS twin of the web's `components/brand-icon.tsx`.
//
// Vector imagesets live in `Assets.xcassets/Brands` (SVG, preserved vector
// data): brand marks from Simple Icons (CC0), PR / issue glyphs from GitHub
// Primer Octicons (MIT). Every brand mark is a single-colour template image,
// Vector imagesets live in `Shared/Brands.xcassets` (SVG, preserved vector
// data): brand marks from Simple Icons (CC0) — except AWS and Pylon, which
// have none and are drawn in `brand-icon.tsx`, and Alertmanager, which borrows
// Prometheus's torch — PR / issue glyphs from GitHub Primer Octicons (MIT).
// Every brand mark is a single-colour template image,
// so it takes `foregroundStyle` like an SF Symbol and reads in the surrounding
// text colour — no brand colours (Slack's four, Claude's orange). Only PR /
// issue glyphs carry a colour, and that is state, not brand. Marks are
Expand All @@ -17,6 +20,8 @@ import UIKit

enum Brand: String, CaseIterable, Hashable, Sendable {
case github, gitlab, slack, linear, jira, notion, sentry
// Event sources and connection providers (the catalog's `icon` keys).
case pagerduty, pylon, datadog, alertmanager, aws
// Agent runtimes (`AgentMark`). OpenClaw has no Simple Icons mark, so it keeps a symbol.
case claude, openai, copilot, gemini, cursor, opencode

Expand All @@ -29,6 +34,11 @@ enum Brand: String, CaseIterable, Hashable, Sendable {
case .jira: return "Jira"
case .notion: return "Notion"
case .sentry: return "Sentry"
case .pagerduty: return "PagerDuty"
case .pylon: return "Pylon"
case .datadog: return "Datadog"
case .alertmanager: return "Alertmanager"
case .aws: return "AWS"
case .claude: return "Claude"
case .openai: return "OpenAI"
case .copilot: return "GitHub Copilot"
Expand Down Expand Up @@ -67,6 +77,11 @@ enum Brand: String, CaseIterable, Hashable, Sendable {
else if host.hasSuffix("notion.so") || host.hasSuffix("notion.site") { self = .notion }
else if host.hasSuffix("slack.com") { self = .slack }
else if host.hasSuffix("sentry.io") { self = .sentry }
else if host.hasSuffix("pagerduty.com") { self = .pagerduty }
else if host.hasSuffix("usepylon.com") { self = .pylon }
else if host.hasSuffix("datadoghq.com") || host.hasSuffix("datadoghq.eu") { self = .datadog }
else if host.contains("grafana") || host.contains("alertmanager") { self = .alertmanager }
else if host.hasSuffix("amazonaws.com") || host.hasSuffix("aws.amazon.com") { self = .aws }
else { return nil }
}

Expand Down Expand Up @@ -153,12 +168,10 @@ enum Glyph: Hashable, Sendable {
case "linear": return .brand(.linear)
case "jira": return .brand(.jira)
case "sentry": return .brand(.sentry)
// No brand assets yet: PagerDuty pages, Pylon supports, Alertmanager
// (Prometheus / Grafana) and Datadog monitor.
case "pagerduty": return .symbol("bell.badge")
case "pylon": return .symbol("lifepreserver")
case "alertmanager": return .symbol("waveform.path.ecg")
case "datadog": return .symbol("dog")
case "pagerduty": return .brand(.pagerduty)
case "pylon": return .brand(.pylon)
case "alertmanager": return .brand(.alertmanager)
case "datadog": return .brand(.datadog)
case "ticket": return Brand(provider: source).map(Glyph.brand) ?? .symbol("ticket")
case "manual": return .symbol("hand.tap")
case "schedule": return .symbol("clock")
Expand All @@ -173,6 +186,21 @@ enum Glyph: Hashable, Sendable {
if runtime == "terminal" { return .symbol("terminal") }
return Brand(agentType: runtime).map(Glyph.brand) ?? .symbol(fallback)
}

/// A connection provider's `icon` key → its mark: the brand's logo when it
/// has one (notion, github, slack, linear, sentry, aws, pylon, pagerduty),
/// a generic symbol for the generic keys (database, folder, terminal,
/// globe), else a plug — the web's `connection-mark.tsx`.
static func provider(icon: String?) -> Glyph {
if let brand = Brand(provider: icon) { return .brand(brand) }
switch (icon ?? "").lowercased() {
case "database": return .symbol("cylinder")
case "folder": return .symbol("folder")
case "terminal": return .symbol("terminal")
case "globe": return .symbol("globe")
default: return .symbol("powerplug")
}
}
}

/// A glyph sized like an SF Symbol at `size` points (scales with Dynamic Type).
Expand Down Expand Up @@ -230,7 +258,8 @@ struct AgentMark: View {
}

/// `TriggerIcon(type:)` — github / gitlab / slack / linear / jira / sentry /
/// ticket → brand; manual / schedule / webhook and the rest keep SF Symbols.
/// pagerduty / pylon / alertmanager / datadog / ticket → brand; manual /
/// schedule / webhook and the rest keep SF Symbols.
struct TriggerIcon: View {
let type: String
var source: String? = nil
Expand All @@ -240,13 +269,7 @@ struct TriggerIcon: View {

/// "GitHub", "Slack", "PagerDuty", "Alertmanager", "Schedule", … for a trigger type.
static func label(_ type: String) -> String {
switch type.lowercased() {
case "pagerduty": return "PagerDuty"
case "pylon": return "Pylon"
case "alertmanager": return "Alertmanager"
case "datadog": return "Datadog"
default: return Brand(provider: type)?.label ?? type.capitalized
}
Brand(provider: type)?.label ?? type.capitalized
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "brand-alertmanager.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions apps/ios/Shared/Brands.xcassets/brand.aws.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "brand-aws.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "brand-datadog.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
Loading
Loading