Skip to content
Merged
7 changes: 7 additions & 0 deletions src/gaia/apps/webui/src/components/ConnectorsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
// Human-readable labels for well-known OAuth scope URIs.
// Unrecognised scopes fall back to the last path segment of the URI.
const SCOPE_LABELS: Record<string, string> = {
// Google / Gmail
'https://www.googleapis.com/auth/gmail.readonly': 'Read emails',
'https://www.googleapis.com/auth/gmail.modify': 'Organize emails (archive, label, trash)',
'https://www.googleapis.com/auth/gmail.send': 'Send emails on your behalf',
Expand All @@ -35,6 +36,12 @@ const SCOPE_LABELS: Record<string, string> = {
'openid': 'Identify you',
'email': 'See your email address',
'profile': 'See your basic profile info',
// Microsoft Graph — email agent scopes (#1770)
'https://graph.microsoft.com/Mail.ReadWrite': 'Read & organize Outlook mail (archive, label, trash)',
'https://graph.microsoft.com/Mail.Send': 'Send Outlook mail on your behalf',
'https://graph.microsoft.com/Calendars.ReadWrite': 'Create & respond to Outlook calendar events',
'https://graph.microsoft.com/Calendars.Read': 'View Outlook calendar events',
'https://graph.microsoft.com/User.Read': 'Read your basic Microsoft profile',
};

function scopeLabel(scope: string): string {
Expand Down
2 changes: 1 addition & 1 deletion src/gaia/apps/webui/src/components/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ export function MessageBubble({ message, isStreaming, showTerminalCursor, agentS
{message.role === 'assistant'
&& !isStreaming
&& isAuthRequiredMessage(cleanedContent) && (
<EmailConnectCta />
<EmailConnectCta content={cleanedContent} />
)}
{message.role === 'assistant' && !isStreaming && (message.stats || latencyMs != null || message.created_at) && (
<div className="msg-stats" aria-label="Message performance stats">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: MIT

import { describe, it, expect } from 'vitest';
import { isAuthRequiredMessage } from '../email/EmailConnectCta';
import { detectProvider, isAuthRequiredMessage } from '../email/EmailConnectCta';

/**
* Tests for isAuthRequiredMessage — the CTA detection function that decides
Expand Down Expand Up @@ -81,4 +81,53 @@ describe('isAuthRequiredMessage', () => {
isAuthRequiredMessage('Failed to connect to Lemonade server at port 13305.')
).toBe(false);
});

// Microsoft-side detection (#1770)
it('returns true for NOT_CONNECTED: microsoft prefix', () => {
expect(
isAuthRequiredMessage('NOT_CONNECTED: microsoft is not currently connected.')
).toBe(true);
});

it('returns true when message mentions connectors → microsoft', () => {
expect(
isAuthRequiredMessage('Please go to Settings → Connectors → Microsoft and reconnect.')
).toBe(true);
});

it('returns true when message mentions connections → microsoft (case-insensitive)', () => {
expect(
isAuthRequiredMessage('Visit Settings → Connections → Microsoft to fix this.')
).toBe(true);
});
});

describe('detectProvider', () => {
it('returns google when only google is mentioned', () => {
expect(detectProvider('NOT_CONNECTED: google is not currently connected.')).toBe('google');
});

it('returns microsoft when only microsoft is mentioned', () => {
expect(detectProvider('NOT_CONNECTED: microsoft is not currently connected.')).toBe('microsoft');
});

it('returns google when gmail is mentioned', () => {
expect(detectProvider('Please reconnect Gmail to continue.')).toBe('google');
});

it('returns microsoft when outlook is mentioned', () => {
expect(detectProvider('Please reconnect Outlook to continue.')).toBe('microsoft');
});

it('returns both when both providers are mentioned', () => {
expect(detectProvider('Google and Microsoft accounts need reconnecting.')).toBe('both');
});

it('returns both for empty string', () => {
expect(detectProvider('')).toBe('both');
});

it('returns both for an ambiguous message without provider names', () => {
expect(detectProvider('AGENT_NOT_GRANTED: this agent is not granted.')).toBe('both');
});
});
25 changes: 20 additions & 5 deletions src/gaia/apps/webui/src/components/email/EmailConnectCta.css
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/* EmailConnectCta — inline Connect Google button rendered next to an
/* EmailConnectCta — inline Connect button(s) rendered next to an
* assistant error message when the email agent surfaces a connectors
* auth-required state. Visually distinct from the normal error banner
* so the user sees there's something they can act on, not just a wall
* of text. */
* auth-required state (Google and/or Microsoft). Visually distinct
* from the normal error banner so the user sees there's something
* they can act on, not just a wall of text. */

.email-connect-cta {
display: flex;
flex-wrap: wrap;
align-items: center;
align-items: flex-start;
gap: 12px;
margin-top: 8px;
padding: 10px 12px;
Expand All @@ -26,6 +26,21 @@
min-width: 0;
}

/* Container for one or two provider buttons laid out side by side. */
.email-connect-cta__buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}

/* Wrapper for a single provider button + its per-provider error. */
.email-connect-cta__provider-slot {
display: flex;
flex-direction: column;
gap: 4px;
}

.email-connect-cta__icon {
color: var(--color-accent, #d96b22);
flex-shrink: 0;
Expand Down
151 changes: 125 additions & 26 deletions src/gaia/apps/webui/src/components/email/EmailConnectCta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@
/**
* EmailConnectCta
*
* Inline "Connect Google" button rendered next to an assistant message
* when the email agent surfaces a connectors auth-required error
* (``NOT_CONNECTED:`` or ``AGENT_NOT_GRANTED:`` from
* ``gaia.connectors.formatting.format_connector_error``). The CTA
* triggers the same OAuth flow the user would otherwise reach via
* Settings → Connectors → Google → Connect — without forcing them to
* navigate away from the chat.
* Inline "Connect" button(s) rendered next to an assistant message when the
* email agent surfaces a connectors auth-required error (``NOT_CONNECTED:``,
* ``AGENT_NOT_GRANTED:``, or ``AUTH_REQUIRED:`` from
* ``gaia.connectors.formatting.format_connector_error``). The CTA triggers
* the same OAuth flow the user would otherwise reach via
* Settings → Connectors → (Google|Microsoft) → Connect — without forcing
* them to navigate away from the chat.
*
* Detection lives in ``isAuthRequiredMessage`` so MessageBubble can
* mount this component conditionally on assistant content.
*
* Provider detection: when the error message mentions "microsoft" the CTA
* offers a Microsoft connect button; when it mentions "google" (or the
* ``installed:email`` upgrade message) it offers Google; when the provider
* is ambiguous both buttons are shown so the user can pick.
*/

import { useCallback, useState } from 'react';
Expand All @@ -25,8 +30,7 @@ import './EmailConnectCta.css';

/** Match the canonical prefixes the connectors framework emits. The
* prefixes are stable (see ``connectors/formatting.py``); fuzzy
* fallbacks like "Open Settings → Connectors → Google" handle the
* agent-specific override message for ``installed:email``.
* fallbacks handle agent-specific override messages.
*/
export function isAuthRequiredMessage(content: string): boolean {
if (!content) return false;
Expand All @@ -44,9 +48,39 @@ export function isAuthRequiredMessage(content: string): boolean {
) {
return true;
}
// Microsoft-side error messages from format_connector_error
if (
lower.includes('connectors → microsoft') ||
lower.includes('connections → microsoft') ||
lower.includes('microsoft is not currently connected')
) {
return true;
}
return false;
}

/**
* Detect which provider(s) an error message references.
*
* Returns:
* - ``'google'`` — only Google connector mentioned
* - ``'microsoft'`` — only Microsoft connector mentioned
* - ``'both'`` — ambiguous / no specific provider found → show both
*/
export function detectProvider(content: string): 'google' | 'microsoft' | 'both' {
if (!content) return 'both';
const lower = content.toLowerCase();
const mentionsGoogle =
lower.includes('google') ||
lower.includes('gmail');
const mentionsMicrosoft =
lower.includes('microsoft') ||
lower.includes('outlook');
if (mentionsGoogle && !mentionsMicrosoft) return 'google';
if (mentionsMicrosoft && !mentionsGoogle) return 'microsoft';
return 'both';
}

// ── OAuth helpers (mirror ConnectorsSection.openAuthUrl) ─────────────────────

function openAuthUrl(url: string): void {
Expand All @@ -60,16 +94,21 @@ function openAuthUrl(url: string): void {
}
}

// ── Component ────────────────────────────────────────────────────────────────
// ── Single-provider connect button ────────────────────────────────────────────

export function EmailConnectCta({
connectorId = 'google',
function ProviderButton({
connectorId,
label,
done,
onDone,
}: {
connectorId?: string;
connectorId: string;
label: string;
done: boolean;
onDone: () => void;
}) {
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [done, setDone] = useState(false);

const handleConnect = useCallback(async () => {
setBusy(true);
Expand All @@ -82,35 +121,28 @@ export function EmailConnectCta({
: connector.default_scopes;
const r = await api.authorizeConnector(connectorId, scopes);
openAuthUrl(r.authorization_url);
setDone(true);
onDone();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}, [connectorId]);
}, [connectorId, onDone]);

return (
<div className="email-connect-cta" role="region" aria-label="Connect Google">
<div className="email-connect-cta__text">
<AlertCircle size={14} className="email-connect-cta__icon" />
<span>
{done
? 'A browser tab opened for Google sign-in. Return here when finished.'
: 'Connect your Google account to use Email Triage.'}
</span>
</div>
<div className="email-connect-cta__provider-slot">
<button
className="email-connect-cta__button"
onClick={() => void handleConnect()}
disabled={busy}
aria-label={done ? `Reopen ${label} sign-in` : `Connect ${label}`}
>
{busy ? (
<Loader2 size={12} className="email-connect-cta__spinner" />
) : (
<ExternalLink size={12} />
)}
<span>{done ? 'Reopen Google sign-in' : 'Connect Google'}</span>
<span>{done ? `Reopen ${label} sign-in` : `Connect ${label}`}</span>
</button>
{err && (
<div className="email-connect-cta__error" role="alert">
Expand All @@ -120,3 +152,70 @@ export function EmailConnectCta({
</div>
);
}

// ── Component ────────────────────────────────────────────────────────────────

export function EmailConnectCta({
content = '',
connectorId,
}: {
/** The assistant message content — used to detect which provider to surface. */
content?: string;
/**
* Optional explicit connector override. When provided, only this connector's
* button is shown regardless of content detection. Kept for back-compat with
* callers that hardcode ``connectorId="google"``.
*/
connectorId?: string;
}) {
const [googleDone, setGoogleDone] = useState(false);
const [microsoftDone, setMicrosoftDone] = useState(false);

// Resolve which provider(s) to surface. Honor an explicit google/microsoft
// override; any other value (or none) falls back to content detection so we
// never render zero connect buttons (no silent dead-end).
const provider =
connectorId === 'google' || connectorId === 'microsoft'
? connectorId
: detectProvider(content);

const showGoogle = provider === 'google' || provider === 'both';
const showMicrosoft = provider === 'microsoft' || provider === 'both';

const anyDone = googleDone || microsoftDone;

return (
<div
className="email-connect-cta"
role="region"
aria-label="Connect email account"
>
<div className="email-connect-cta__text">
<AlertCircle size={14} className="email-connect-cta__icon" />
<span>
{anyDone
? 'A browser tab opened for sign-in. Return here when finished.'
: 'Connect your email account to use Email Triage.'}
</span>
</div>
<div className="email-connect-cta__buttons">
{showGoogle && (
<ProviderButton
connectorId="google"
label="Google"
done={googleDone}
onDone={() => setGoogleDone(true)}
/>
)}
{showMicrosoft && (
<ProviderButton
connectorId="microsoft"
label="Microsoft"
done={microsoftDone}
onDone={() => setMicrosoftDone(true)}
/>
)}
</div>
</div>
);
}
Loading
Loading