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
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,21 @@ enum IntegrationNudgeRoute: Equatable, Hashable {
enum IntegrationNudgeTriggerMatch: Equatable {
/// The frontmost application is one of these bundle identifiers.
case application(bundleIdentifiers: [String])
/// The frontmost application is a browser and its window *title* ends with one
/// of these suffixes, case-insensitively.
/// The frontmost application is a browser and its window *title* ends with the
/// site's own name, case-insensitively.
///
/// A window title is the page's `document.title`, never its URL, so a domain
/// keyword matches nothing real. What is reliable is where sites put their own
/// name: at the end. X renders "Home / X", Gmail "Inbox (12) - you@corp.com -
/// Gmail", ChatGPT just "ChatGPT". Anchoring to the end is what separates
/// those from "ChatGPT vs Claude — a comparison", which is the false positive
/// that matters — a wrong nudge is worse than a missing one.
/// name: at the end, behind a separator. X renders "Home / X", Gmail "Inbox
/// (12) - you@corp.com - Gmail", ChatGPT just "ChatGPT".
///
/// The name must be the *whole* title or sit behind a separator — a bare
/// "ends with" test reads any sentence that happens to trail off in the name
/// as the site itself. Measured against 95,577 real titles from this
/// developer's own browser history, the unanchored form claimed "How to Create
/// Studio Ghibli Style Art With ChatGPT" as ChatGPT, "Context for Claude" as
/// Claude, and "World Wealth Report 2024: HNWI Wealth Management | Capgemini"
/// as Gemini. Requiring the separator drops all three and costs no real title.
///
/// An integration whose real titles carry no site name gets no browser trigger
/// at all rather than a guess. Notion web titles are the page name alone, and
Expand All @@ -59,7 +65,17 @@ enum IntegrationNudgeTriggerMatch: Equatable {
/// the same active-window lookup the proactive assistants already use, and
/// reading the address bar would mean driving the Accessibility API across
/// every browser.
case browserTitleSuffix(suffixes: [String])
case browserTitleSite(names: [String])
/// The frontmost application is a browser showing a Gmail mailbox whose
/// account is on a Google Workspace domain.
///
/// Workspace replaces "Gmail" in the title with the organization's own name —
/// "Inbox (3,012) - you@company.com - Acme Mail" — so `browserTitleSite` can
/// never name it. That is not an edge case: in the same 95,577-title corpus it
/// is 51% of all Gmail visits, and every one of them was invisible to the
/// nudge. The shape that identifies it is the account address Gmail puts in
/// the middle segment; " Mail" alone would claim Proton and Yahoo too.
case browserTitleGoogleWorkspaceMailbox
}

struct IntegrationNudgeTrigger: Equatable {
Expand All @@ -71,7 +87,7 @@ struct IntegrationNudgeTrigger: Equatable {
var kind: IntegrationNudgeTelemetry.TriggerKind {
switch match {
case .application: return .nativeApp
case .browserTitleSuffix: return .browserSite
case .browserTitleSite, .browserTitleGoogleWorkspaceMailbox: return .browserSite
}
}
}
Expand Down Expand Up @@ -176,7 +192,11 @@ enum IntegrationNudgeCatalog {
triggers: [
IntegrationNudgeTrigger(
id: "gmail_web",
match: .browserTitleSuffix(suffixes: ["Gmail"])
match: .browserTitleSite(names: ["Gmail"])
),
IntegrationNudgeTrigger(
id: "gmail_workspace_web",
match: .browserTitleGoogleWorkspaceMailbox
),
IntegrationNudgeTrigger(
id: "gmail_client_app",
Expand Down Expand Up @@ -237,7 +257,7 @@ enum IntegrationNudgeCatalog {
triggers: [
IntegrationNudgeTrigger(
id: "x_web",
match: .browserTitleSuffix(suffixes: ["/ X", "/ Twitter"])
match: .browserTitleSite(names: ["X", "Twitter"])
)
]
),
Expand Down Expand Up @@ -295,7 +315,7 @@ enum IntegrationNudgeCatalog {
),
IntegrationNudgeTrigger(
id: "chatgpt_web",
match: .browserTitleSuffix(suffixes: ["ChatGPT"])
match: .browserTitleSite(names: ["ChatGPT"])
),
]
),
Expand All @@ -318,7 +338,7 @@ enum IntegrationNudgeCatalog {
),
IntegrationNudgeTrigger(
id: "claude_web",
match: .browserTitleSuffix(suffixes: ["Claude"])
match: .browserTitleSite(names: ["Claude"])
),
]
),
Expand All @@ -337,7 +357,7 @@ enum IntegrationNudgeCatalog {
triggers: [
IntegrationNudgeTrigger(
id: "gemini_web",
match: .browserTitleSuffix(suffixes: ["Gemini"])
match: .browserTitleSite(names: ["Google Gemini", "Gemini"])
)
]
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,27 +56,87 @@ enum IntegrationNudgeMatcher {
let haystack = normalizedTitle(title)
for entry in catalog {
for trigger in entry.triggers {
guard case .browserTitleSuffix(let suffixes) = trigger.match else { continue }
if suffixes.contains(where: { haystack.hasSuffix($0.lowercased()) }) {
return Match(entry: entry, trigger: trigger)
switch trigger.match {
case .application:
continue
case .browserTitleSite(let names):
if names.contains(where: { endsWithSiteName(haystack, $0.lowercased()) }) {
return Match(entry: entry, trigger: trigger)
}
case .browserTitleGoogleWorkspaceMailbox:
if isGoogleWorkspaceMailbox(haystack) {
return Match(entry: entry, trigger: trigger)
}
}
}
}

return nil
}

/// Lowercased, trimmed, and stripped of the browser's own trailing chrome so
/// a site name the browser appended its product name after still reads as the
/// end of the title.
/// Gmail on a Workspace domain titles the mailbox with the organization's
/// name, not "Gmail" — "Inbox (3,012) - you@company.com - Acme Mail". The
/// account address in the middle segment is what makes this Gmail rather than
/// any other webmail, so both halves are required: a trailing " mail" alone
/// would claim Proton Mail and Yahoo Mail, and an address alone appears in
/// every mail client on the web.
static func isGoogleWorkspaceMailbox(_ normalizedTitle: String) -> Bool {
guard normalizedTitle.hasSuffix(" mail") else { return false }
return
normalizedTitle
.components(separatedBy: " - ")
.dropLast()
.contains { segment in
guard let at = segment.firstIndex(of: "@") else { return false }
let local = segment[segment.startIndex..<at]
let domain = segment[segment.index(after: at)...]
return !local.isEmpty && domain.contains(".") && !domain.hasSuffix(".")
}
}

/// True when the site's own name is the whole title, or is the last segment
/// behind one of the separators sites actually use.
///
/// The separator is what makes this a site name rather than a word the title
/// ended on. "home / x" is X; "x marks the spot" is not, and neither is "how
/// to create studio ghibli style art with chatgpt".
///
/// A one-character name never stands alone. "X" as an entire title carries no
/// evidence of being x.com — a Stripe checkout page in the measured corpus is
/// titled exactly that — so X has to arrive with its separator, as it does in
/// every real x.com title ("Home / X").
static func endsWithSiteName(_ normalizedTitle: String, _ name: String) -> Bool {
if name.count > 1, normalizedTitle == name { return true }
return titleSeparators.contains { normalizedTitle.hasSuffix($0 + name) }
}

/// " / ", " - " and " | " are the three observed in front of these sites' own
/// names in real browser history; the two dashes are the same separator in
/// typographic form and cost nothing to accept.
///
/// ": " is deliberately absent. Sites lead with it — "ChatGPT: Chat, Work,
/// Create & Code with AI" — rather than close with it, so as a *suffix* rule
/// it could only ever fire on prose that happened to end in the name.
private static let titleSeparators = [" - ", " – ", " — ", " | ", " / "]

/// Lowercased, trimmed, stripped of the invisible directionality marks sites
/// emit, and stripped of the browser's own trailing chrome so a site name the
/// browser appended its product name after still reads as the end of the
/// title.
///
/// Gemini is why the invisible characters matter: it titles its pages
/// "\u{200E}Google Gemini", and a leading left-to-right mark is enough to make
/// an exact comparison against "google gemini" fail.
///
/// On macOS the Chromium browsers do *not* append their name — a Chrome window
/// showing Gmail is titled exactly "Inbox (12) - you@corp.com - Gmail". The
/// ones that do are Firefox and its relatives, so those are what this strips.
/// Chromium suffixes stay in the list only because they cost nothing and some
/// builds and window managers do add them.
static func normalizedTitle(_ title: String) -> String {
var value = title.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
var value = String(title.unicodeScalars.filter { !invisibleFormatting.contains($0) })
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
var didStrip = true
while didStrip {
didStrip = false
Expand All @@ -89,6 +149,16 @@ enum IntegrationNudgeMatcher {
return value
}

/// Bidi controls and zero-width characters: invisible, and load-bearing for
/// nothing a title match should care about.
private static let invisibleFormatting: CharacterSet = {
var set = CharacterSet(charactersIn: "\u{200B}"..."\u{200F}")
set.insert(charactersIn: "\u{202A}"..."\u{202E}")
set.insert(charactersIn: "\u{2066}"..."\u{2069}")
set.insert("\u{FEFF}")
return set
}()

private static let browserTitleChrome = [
" — mozilla firefox", " - mozilla firefox",
" — firefox developer edition", " - firefox developer edition",
Expand Down
46 changes: 34 additions & 12 deletions desktop/macos/Desktop/Tests/IntegrationNudgeCatalogTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,37 @@ final class IntegrationNudgeCatalogTests: XCTestCase {
}
}

/// Real window titles, observed from the actual sites. Synthesizing a title
/// out of the keyword ("Some Page — \(keyword)") would match by construction
/// and prove nothing — which is exactly how a round of domain-style keywords
/// (`chatgpt.com`, `claude.ai`, `notion.so`) passed while matching nothing a
/// browser ever puts in a window title.
/// Real window titles, read out of a developer's own Chrome and Arc history
/// (95,577 titles) and filtered to the sites themselves. Only the account
/// addresses are replaced; every shape here is one a browser really produced.
///
/// Synthesizing a title out of the keyword ("Some Page — \(keyword)") would
/// match by construction and prove nothing — which is exactly how a round of
/// domain-style keywords (`chatgpt.com`, `claude.ai`, `notion.so`) passed
/// while matching nothing a browser ever puts in a window title. The same trap
/// caught two entries in the previous version of this fixture: "Reviewing a
/// diff \\ Claude" and "Swift concurrency question - ChatGPT" were invented,
/// and neither shape occurs once in the corpus.
private static let observedBrowserTitles: [String: [String]] = [
"gmail_web": ["Inbox (12) - me@corp.com - Gmail", "Omi launch - me@corp.com - Gmail"],
"x_web": ["Home / X", "(3) Home / X", "Archit on X: \"shipping\" / X"],
"chatgpt_web": ["ChatGPT", "Swift concurrency question - ChatGPT"],
"claude_web": ["Claude", "Reviewing a diff \\ Claude"],
"gemini_web": ["Gemini", "Trip planning - Gemini"],
"gmail_web": ["Gmail", "Inbox (16,993) - me@gmail.com - Gmail"],
// Google Workspace names the mailbox after the organization, so "Gmail"
// never appears. 51% of the corpus's Gmail visits look like this.
"gmail_workspace_web": [
"Inbox (3,012) - me@umn.edu - University of Minnesota Twin Cities Mail"
],
"x_web": ["Home / X", "(3) Home / X", "Sam Altman (@sama) / X"],
// 2,108 of 2,798 chatgpt.com visits (75%) are titled exactly "ChatGPT"; the
// rest carry the conversation's own name and are unrecognizable by title.
"chatgpt_web": ["ChatGPT"],
"claude_web": [
"Claude", "New chat - Claude",
"Monthly recurring revenue to annual projection - Claude",
],
// Gemini prefixes its title with an invisible left-to-right mark.
"gemini_web": [
"Google Gemini", "\u{200E}Google Gemini",
"BCI Input Ideas: Games, Typing, Music - Google Gemini",
],
]

/// Every browser trigger must match a title a browser actually produces, and
Expand All @@ -111,8 +131,10 @@ final class IntegrationNudgeCatalogTests: XCTestCase {
)
}

case .browserTitleSuffix(let suffixes):
XCTAssertFalse(suffixes.isEmpty, "\(trigger.id) declares no suffixes")
case .browserTitleSite, .browserTitleGoogleWorkspaceMailbox:
if case .browserTitleSite(let names) = trigger.match {
XCTAssertFalse(names.isEmpty, "\(trigger.id) declares no site names")
}
guard let titles = Self.observedBrowserTitles[trigger.id] else {
// `continue`, not `return`: returning here would skip every later
// entry's assertions, including the native-app round-trips.
Expand Down
70 changes: 70 additions & 0 deletions desktop/macos/Desktop/Tests/IntegrationNudgeMatcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,76 @@ final class IntegrationNudgeMatcherTests: XCTestCase {
XCTAssertNil(match(bundle: "com.google.Chrome", title: "ChatGPT vs Claude — a comparison"))
}

/// Ending in the site's name is not the same as being the site. Each of these
/// is a real title from browser history that the unanchored "ends with" test
/// claimed: a tutorial *about* ChatGPT, a product whose name ends in "Claude",
/// and a consultancy whose name ends in "gemini". Reading any of them as the
/// site interrupts someone over an app they do not have open.
func testAPageAboutASiteIsNotThatSite() {
for title in [
"How to Create Studio Ghibli Style Art With ChatGPT",
"Context for Claude",
"Breakout — Events, run end to end inside Claude",
"World Wealth Report 2024: HNWI Wealth Management | Capgemini",
"Website Tweak for Gemini",
] {
XCTAssertNil(
match(bundle: "com.google.Chrome", title: title),
"'\(title)' is a page about a site, not the site"
)
}
}

/// "X" is one character, so a title that is nothing but "X" says nothing about
/// x.com — this exact title belongs to a Stripe checkout page in the corpus.
/// The separator is the whole signal, and real x.com titles always have it.
func testASingleCharacterNameIsNeverTheWholeTitle() {
XCTAssertNil(match(bundle: "com.google.Chrome", title: "X"))
XCTAssertEqual(
match(bundle: "com.google.Chrome", title: "Home / X")?.entry.route,
.importConnector("x")
)
}

/// Gmail on a Workspace domain puts the organization's name where "Gmail"
/// would be, which is 51% of the Gmail visits in the corpus this was measured
/// against — every one of them invisible to a rule that looks for "Gmail".
func testWorkspaceMailboxesAreRecognizedAsGmail() {
let result = match(
bundle: "com.google.Chrome",
title: "Inbox (3,012) - me@umn.edu - University of Minnesota Twin Cities Mail"
)
XCTAssertEqual(result?.entry.route, .importConnector("email"))
XCTAssertEqual(result?.trigger.id, "gmail_workspace_web")
}

/// The account address is what makes a mailbox title Gmail's. Without it,
/// " Mail" is just as true of every other webmail, and of a page about one.
func testOtherWebmailIsNotClaimedAsGmail() {
for title in [
"Inbox - Proton Mail",
"Yahoo Mail",
"The best free email in 2026 - Proton Mail",
"Inbox (4) - Outlook",
] {
XCTAssertNil(match(bundle: "com.google.Chrome", title: title), "'\(title)' is not Gmail")
}
}

/// Gemini prefixes its window title with an invisible left-to-right mark, and
/// titles itself "Google Gemini" rather than "Gemini". Both facts are load
/// bearing: without the first the exact comparison fails on a character
/// nobody can see, and without the second the shorter name is what lets
/// "Capgemini" through.
func testGeminiIsMatchedThroughItsInvisibleLeadingMark() {
XCTAssertEqual(
match(bundle: "com.google.Chrome", title: "\u{200E}Google Gemini")?.entry.route,
.exportDestination("gemini")
)
XCTAssertEqual(
IntegrationNudgeMatcher.normalizedTitle("\u{200E}Google Gemini"), "google gemini")
}

/// Browsers append their own product name to the window title; the site name
/// is still the end of the page's title.
func testBrowserChromeSuffixIsStripped() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"change": "Integration suggestions now recognize Gmail on a work or school Google account, and no longer appear when you are reading an article that merely mentions ChatGPT, Claude, or Gemini"
}
Loading