Merge pull request #669 from rowboatlabs/native_gmail

use native gmail in copilot
This commit is contained in:
arkml 2026-07-05 23:01:00 +05:30 committed by GitHub
commit 9503682629
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 104 additions and 27 deletions

View file

@ -622,6 +622,9 @@ function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
}
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
// Native connection status (e.g. Google) is baked into the Copilot system
// prompt, so any OAuth state change must rebuild it.
invalidateCopilotInstructionsCache();
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {

View file

@ -631,7 +631,7 @@ type ViewState =
| { type: 'suggested-topics' }
| { type: 'meetings' }
| { type: 'live-notes' }
| { type: 'email'; threadId?: string }
| { type: 'email'; threadId?: string; searchQuery?: string }
| { type: 'workspace'; path?: string }
| { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode }
| { type: 'chat-history' }
@ -646,7 +646,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type === 'task' && b.type === 'task') return a.name === b.name
if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '')
if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '')
if (a.type === 'email' && b.type === 'email') return (a.threadId ?? '') === (b.threadId ?? '')
if (a.type === 'email' && b.type === 'email') return (a.threadId ?? '') === (b.threadId ?? '') && (a.searchQuery ?? '') === (b.searchQuery ?? '')
return true // both graph
}
@ -842,6 +842,10 @@ function App() {
const [isHomeOpen, setIsHomeOpen] = useState(true)
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
// Search query pushed into the email view's search box (e.g. the assistant's
// read-view email query), so threads outside the synced inbox get real rows.
const [emailInitialSearchQuery, setEmailInitialSearchQuery] = useState<string | null>(null)
const [emailSearchQueryVersion, setEmailSearchQueryVersion] = useState(0)
const [expandedFrom, setExpandedFrom] = useState<{
path: string | null
graph: boolean
@ -4258,6 +4262,10 @@ function App() {
setEmailInitialThreadId(view.threadId)
setEmailThreadIdVersion((v) => v + 1)
}
if (view.searchQuery) {
setEmailInitialSearchQuery(view.searchQuery)
setEmailSearchQueryVersion((v) => v + 1)
}
ensureEmailFileTab()
return
case 'workspace':
@ -4606,7 +4614,15 @@ function App() {
break
case 'open-view':
case 'read-view':
navigateToNamedView(result.view as string)
// A read-view email search runs against the whole mailbox, so drive
// the email view's own search box with the same query — matched
// threads get real rows even when they're outside the synced inbox
// (and a follow-up open-item can then select them).
if (result.action === 'read-view' && result.view === 'email' && typeof result.query === 'string' && result.query.trim()) {
void navigateToView({ type: 'email', searchQuery: result.query.trim() })
} else {
navigateToNamedView(result.view as string)
}
break
case 'open-item': {
switch (result.kind) {
@ -6185,7 +6201,7 @@ function App() {
</div>
) : isEmailOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<EmailView initialThreadId={emailInitialThreadId} threadIdVersion={emailThreadIdVersion} />
<EmailView initialThreadId={emailInitialThreadId} threadIdVersion={emailThreadIdVersion} initialSearchQuery={emailInitialSearchQuery} searchQueryVersion={emailSearchQueryVersion} />
</div>
) : isWorkspaceOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">

View file

@ -2383,9 +2383,13 @@ export type EmailViewProps = {
initialThreadId?: string | null
/** Bump to re-focus on the same threadId after navigating away inside the view. */
threadIdVersion?: number
/** Query to load into the search box (e.g. the assistant's read-view email search). */
initialSearchQuery?: string | null
/** Bump to re-apply the same search query. */
searchQueryVersion?: number
}
export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {}) {
export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery, searchQueryVersion }: EmailViewProps = {}) {
const [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant))
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
@ -2404,6 +2408,13 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
// Externally-driven search (assistant read-view email query): load it into
// the search box so the debounced full-mailbox search below runs — matched
// threads get real rows even when they're not in the synced inbox.
useEffect(() => {
const q = initialSearchQuery?.trim()
if (q) setQuery(q)
}, [initialSearchQuery, searchQueryVersion])
const [composeOpen, setComposeOpen] = useState(false)
// Stable so the open composer isn't re-rendered on every inbox sync tick.
const closeCompose = useCallback(() => setComposeOpen(false), [])

View file

@ -6,6 +6,7 @@ import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
import container from "../../di/container.js";
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
import type { ISlackConfigRepo } from "../../slack/repo.js";
import type { IOAuthRepo } from "../../auth/repo.js";
import { knowledgeSourcesRepo } from "../../knowledge/sources/repo.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
@ -14,7 +15,7 @@ const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
* Generate dynamic instructions section for Composio integrations.
* Lists connected toolkits and explains the meta-tool discovery flow.
*/
async function getComposioToolsPrompt(slackConnected: boolean = false): Promise<string> {
async function getComposioToolsPrompt(slackConnected: boolean = false, googleConnected: boolean = false): Promise<string> {
if (!(await isComposioConfigured())) {
return '';
}
@ -29,30 +30,50 @@ async function getComposioToolsPrompt(slackConnected: boolean = false): Promise<
? ` Exception: **Slack is connected natively** — use the \`slack\` skill for Slack, not Composio.`
: '';
// Google is connected natively, so email reading must not route to Composio.
const googleException = googleConnected
? ` Exception: **Gmail is connected natively** — read/check/search email with the \`app-navigation\` tool (\`read-view\`, \`view: "email"\`), not Composio.`
: '';
return `
## Composio Integrations
${connectedSection}
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.${slackException}
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.${slackException}${googleException}
`;
}
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true, slackConnected: boolean = false, slackChannelsHint: string = ''): string {
// Conditionally include Composio-related instruction sections
const emailDraftSuffix = composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true, slackConnected: boolean = false, slackChannelsHint: string = '', googleConnected: boolean = false): string {
// Conditionally include Composio-related instruction sections.
// When Google is connected natively, email reading routes to the native
// app-navigation email view — never to Composio.
const emailDraftSuffix = googleConnected
? ` Do NOT load this skill for reading, fetching, or checking emails — Gmail is connected natively; use the \`app-navigation\` tool (\`read-view\`, \`view: "email"\`) for that instead.`
: composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
// When Slack is connected natively (desktop/cURL auth, not Composio), keep it
// out of the Composio routing examples so the Copilot doesn't try to connect
// it through Composio and wrongly report it as unavailable.
const composioServiceExamples = slackConnected
? 'Gmail, GitHub, LinkedIn, Notion, Google Sheets, Jira, etc.'
: 'Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.';
// When Slack or Google is connected natively (not via Composio), keep them
// out of the Composio routing examples so the Copilot doesn't route their
// requests through Composio or wrongly report them as unavailable.
const composioServiceExamples = ['Gmail', 'GitHub', 'Slack', 'LinkedIn', 'Notion', 'Google Sheets', 'Jira']
.filter(service => !(slackConnected && service === 'Slack') && !(googleConnected && service === 'Gmail'))
.join(', ') + ', etc.';
const thirdPartyExamples = googleConnected
? 'listing issues, sending messages, fetching profiles'
: 'reading emails, listing issues, sending messages, fetching profiles';
const thirdPartyBlock = composioEnabled
? `\n**Third-Party Services:** When users ask to interact with any external service (${composioServiceExamples}) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
? `\n**Third-Party Services:** When users ask to interact with any external service (${composioServiceExamples}) — ${thirdPartyExamples} — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
: '';
// Google is connected directly in Rowboat (native OAuth + background sync),
// independent of Composio. Route email reading to the native app-navigation
// email view so the Copilot never sends it through Composio.
const gmailBlock = googleConnected
? `\n**Gmail (connected natively):** The user's Google account is connected directly in Rowboat, and their email is synced continuously. For ANY request to read, fetch, check, or search emails — "get my last few emails", "any new emails?", "find the email from X", "search my gmail for Y" — load the \`app-navigation\` skill and use the \`app-navigation\` tool's \`read-view\` action with \`view: "email"\`. Its \`query\` parameter runs a LIVE Gmail search over the entire mailbox via the Gmail API with full Gmail search operators (\`from:\`, \`subject:\`, \`before:\`, etc.) — it IS Gmail's real search, so use it even when the user explicitly asks to "search Gmail directly". NEVER route email reading through the \`composio-integration\` skill or Composio Gmail tools, and NEVER tell the user Gmail isn't connected. Email *drafting* still goes through the \`draft-emails\` skill.\n`
: '';
// Slack is connected directly in Rowboat (agent-slack CLI), independent of
@ -69,9 +90,15 @@ function buildStaticInstructions(composioEnabled: boolean, catalog: string, code
? ` For Slack specifically, load the \`slack\` skill and use the agent-slack CLI — Slack is connected natively, not via Composio.`
: '';
const googleToolPriority = googleConnected
? ` For reading email specifically, use the \`app-navigation\` tool (\`read-view\`, \`view: "email"\`) — Gmail is connected natively, not via Composio.`
: '';
const toolPriorityServiceExamples = googleConnected ? 'GitHub, Notion, etc.' : 'GitHub, Gmail, etc.';
const toolPriority = composioEnabled
? `For third-party services (GitHub, Gmail, etc.), load the \`composio-integration\` skill.${slackToolPriority} For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.${slackToolPriority}`;
? `For third-party services (${toolPriorityServiceExamples}), load the \`composio-integration\` skill.${slackToolPriority}${googleToolPriority} For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.${slackToolPriority}${googleToolPriority}`;
const slackToolsLine = composioEnabled
? `- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.\n`
@ -104,7 +131,7 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
${thirdPartyBlock}${slackBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
${thirdPartyBlock}${gmailBlock}${slackBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
@ -371,6 +398,14 @@ export async function buildCopilotInstructions(): Promise<string> {
} catch {
// repo unavailable — default to not connected
}
let googleConnected = false;
try {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const googleConnection = await oauthRepo.read('google');
googleConnected = !!googleConnection.tokens;
} catch {
// repo unavailable — default to not connected
}
if (slackConnected) {
try {
// Surface the channels the user selected for sync so the Copilot
@ -395,8 +430,8 @@ export async function buildCopilotInstructions(): Promise<string> {
const catalog = excludeIds.length > 0
? buildSkillCatalog({ excludeIds })
: skillCatalog;
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint);
const composioPrompt = await getComposioToolsPrompt(slackConnected);
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint, googleConnected);
const composioPrompt = await getComposioToolsPrompt(slackConnected, googleConnected);
cachedInstructions = composioPrompt
? baseInstructions + '\n' + composioPrompt
: baseInstructions;

View file

@ -30,7 +30,17 @@ Returns the same data the view renders; the app simultaneously navigates to
that view so the user sees it.
- ` + "`view: \"email\"`" + ` → latest important inbox threads: ` + "`{ threadId, subject, from, date, unread, summary }`" + `.
Pass ` + "`query`" + ` to search instead (sender name, subject words e.g. ` + "`query: \"from Arjun\"`" + ` or just ` + "`\"Arjun\"`" + `).
Pass ` + "`query`" + ` to search instead. **This is a LIVE Gmail search over the
user's ENTIRE mailbox via the Gmail API** (not a local/semantic search) and
supports full Gmail search operators ` + "`from:`, `to:`, `subject:`, `before:`/`after:`, `has:attachment`" + `,
quoted phrases, ` + "`OR`" + ` e.g. ` + "`query: \"from:arjun subject:deck\"`" + ` or plain words like ` + "`\"Arjun\"`" + `.
When the user says "search my gmail" or wants Gmail's real search, THIS is
it do not reach for any other integration. Gmail matches whole words
literally, so prefer broad queries (one or two distinctive words, or
` + "`OR`" + ` variants like ` + "`\"intern OR internship\"`" + `) over long phrases.
A ` + "`query`" + ` search also fills the email view's search box on the user's
screen, so they see the same results and a follow-up open-item works for
any thread the search returned, including old threads outside the inbox.
- ` + "`view: \"bg-tasks\"`" + ` → background agents: ` + "`{ name, slug, active, triggers, lastRunAt, lastRunSummary, lastRunError }`" + `.
- ` + "`view: \"chat-history\"`" + ` → past chats: ` + "`{ sessionId, title, updatedAt, turnCount }`" + `.
- ` + "`limit`" + ` (optional, default 15).

View file

@ -3,6 +3,8 @@ export const skill = String.raw`
**Load this skill** when the user asks to interact with ANY third-party service email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.
**Native connections win over Composio.** If the system prompt says a service is connected natively in Rowboat (e.g. "Gmail is connected natively" or "Slack is connected natively"), do NOT use Composio for that service follow the native routing in the system prompt instead (Gmail reading \`app-navigation\` \`read-view\` \`view: "email"\`; Slack → the \`slack\` skill).
## Available Tools
| Tool | Purpose |
@ -91,7 +93,7 @@ User says: "Get me the open issues on rowboatlabs/rowboat"
### Example: Gmail Fetch
User says: "What's my latest email?"
User says: "What's my latest email?" (only when Gmail is connected via Composio if the system prompt says Gmail is connected natively, use \`app-navigation\` instead)
1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\`
finds \`GMAIL_FETCH_EMAILS\`

View file

@ -1076,7 +1076,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// open-view / read-view
view: z.enum(["home", "email", "meetings", "live-notes", "bg-tasks", "chat-history", "knowledge", "workspace", "code", "bases", "graph"]).optional().describe("Which view to open (open-view) or read (read-view; supported for read: email, bg-tasks, chat-history)"),
// read-view (email)
query: z.string().optional().describe("For read-view on email: search query (sender name, subject words, etc.). Omit to list the latest important inbox threads."),
query: z.string().optional().describe("For read-view on email: runs a LIVE Gmail search over the user's ENTIRE mailbox (not just synced mail) via the Gmail API. Supports full Gmail search operators: from:, to:, subject:, before:/after:, has:attachment, quoted phrases, OR, etc. Omit to list the latest important inbox threads."),
limit: z.number().int().min(1).max(50).optional().describe("For read-view: max items to return (default 15)"),
// open-item
kind: z.enum(["email-thread", "note", "bg-task", "session"]).optional().describe("What to open (for open-item)"),