diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md index fe31d019..d8a157d7 100644 --- a/apps/x/LIVE_NOTE.md +++ b/apps/x/LIVE_NOTE.md @@ -70,7 +70,7 @@ The `once` trigger from the prior model has been **dropped** — it didn't fit t Two paths, both producing identical on-disk YAML: 1. **Hand-written** — type the `live:` block directly into the note's frontmatter. The scheduler picks it up on its next 15-second tick. -2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. +2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `file-editText`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block. @@ -92,8 +92,8 @@ When a trigger fires, the live-note agent receives a short message: - For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it"). The agent's system prompt tells it to: -1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). -2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites. +1. Call `file-readText` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). +2. Make small, **patch-style** edits with `file-editText` — change one region, re-read, change the next region — rather than one-shot rewrites. 3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first. 4. Never modify YAML frontmatter — that's owned by the user and the runtime. 5. End with a 1-2 sentence summary stored as `lastRunSummary`. @@ -115,7 +115,7 @@ Backend (main process) ├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent └─ Builtin tool │ │ run-live-note-agent ────┘ ▼ - workspace-readFile / -edit + file-readText / -edit │ ▼ body region(s) rewritten on disk @@ -249,7 +249,7 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know - Then content organized by sub-topic under H2 headings, freshest/most-important first. - Tightness over decoration. - **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly. -- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. +- **Patch-style updates** — make small, incremental `file-editText` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. - **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1. --- @@ -316,7 +316,7 @@ Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd - **Purpose**: the user message seeded into each agent run. - **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`). - **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`. -- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits. +- **Behavior**: tells the agent to call `file-readText` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits. Three branches by `trigger`: - **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills. diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index ad184451..5c46ca3f 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -2,7 +2,8 @@ import { createServer, Server } from 'http'; import { URL } from 'url'; const OAUTH_CALLBACK_PATH = '/oauth/callback'; -const DEFAULT_PORT = 8080; +export const DEFAULT_PORT = 8080; +export const PORT_RANGE_SIZE = 10; /** Escape HTML special characters to prevent XSS */ function escapeHtml(str: string): string { @@ -19,13 +20,8 @@ export interface AuthServerResult { port: number; } -/** - * Create a local HTTP server to handle OAuth callback - * Listens on http://localhost:8080/oauth/callback - * Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds. - */ -export function createAuthServer( - port: number = DEFAULT_PORT, +function tryBindPort( + port: number, onCallback: (callbackUrl: URL) => void | Promise ): Promise { return new Promise((resolve, reject) => { @@ -37,7 +33,7 @@ export function createAuthServer( } const url = new URL(req.url, `http://localhost:${port}`); - + if (url.pathname === OAUTH_CALLBACK_PATH) { const error = url.searchParams.get('error'); @@ -96,8 +92,10 @@ export function createAuthServer( }); server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - reject(new Error(`Port ${port} is already in use`)); + server.close(); + if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { + // Signal caller to try next port + reject(Object.assign(new Error(err.code), { code: err.code })); } else { reject(err); } @@ -105,3 +103,51 @@ export function createAuthServer( }); } +/** + * Create a local HTTP server to handle OAuth callback. + * + * Defaults to fixed-port behaviour: only `port` is tried, and a clear error is + * thrown if it cannot be bound. This is the right behaviour for any provider + * whose redirect URI is pre-registered (Google BYOK, Composio, etc.) — those + * callers must keep using the exact port they've handed to the provider. + * + * Opt into `{ fallback: true }` only when the caller is prepared to use the + * port returned in `AuthServerResult` (i.e. the redirect URI is built from the + * actual bound port, not hard-coded). With fallback enabled, scans `port` + * through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling + * both EADDRINUSE and EACCES (the latter is common on Windows when + * Hyper-V/WSL2 reserve the port). + */ +export async function createAuthServer( + port: number = DEFAULT_PORT, + onCallback: (callbackUrl: URL) => void | Promise, + opts: { fallback?: boolean } = {}, +): Promise { + const fallback = opts.fallback === true; + const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port; + + for (let p = port; p <= limit; p++) { + try { + return await tryBindPort(p, onCallback); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) { + console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}…`); + continue; + } + if (!fallback) { + const reason = code === 'EACCES' || code === 'EADDRINUSE' + ? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.` + : (err instanceof Error ? err.message : String(err)); + throw new Error(reason); + } + throw new Error( + `No available port found in range ${port}–${limit}. Free a port in that range and try again.` + ); + } + } + + // Unreachable — loop always returns or throws — but satisfies TypeScript + throw new Error(`No available port found in range ${port}–${limit}.`); +} + diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 638af656..2f5730ce 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -31,6 +31,9 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; +import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; +import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; +import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; @@ -48,7 +51,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; -import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -494,6 +497,24 @@ export function setupIpcHandlers() { triggerGmailSync(); return {}; }, + 'gmail:sendReply': async (_event, args) => { + return sendThreadReply(args); + }, + 'gmail:getConnectionStatus': async () => { + return getGmailConnectionStatus(); + }, + 'gmail:getAccountEmail': async () => { + return { email: await getAccountEmail() }; + }, + 'gmail:archiveThread': async (_event, args) => { + return archiveThread(args.threadId); + }, + 'gmail:trashThread': async (_event, args) => { + return trashThread(args.threadId); + }, + 'gmail:markThreadRead': async (_event, args) => { + return markThreadRead(args.threadId); + }, 'gmail:saveMessageHeight': async (_event, args) => { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; @@ -508,7 +529,7 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); @@ -612,6 +633,20 @@ export function setupIpcHandlers() { const config = await repo.getConfig(); return { enabled: config.enabled }; }, + 'codeMode:getConfig': async () => { + const repo = container.resolve('codeModeConfigRepo'); + const config = await repo.getConfig(); + return { enabled: config.enabled }; + }, + 'codeMode:setConfig': async (_event, args) => { + const repo = container.resolve('codeModeConfigRepo'); + await repo.setConfig({ enabled: args.enabled }); + invalidateCopilotInstructionsCache(); + return { success: true }; + }, + 'codeMode:checkAgentStatus': async () => { + return await checkCodeModeAgentStatus(); + }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index ab026fff..81d43553 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -51,6 +51,7 @@ import { extractDeepLinkFromArgv, setMainWindowForDeepLinks, } from "./deeplink.js"; +import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; const execAsync = promisify(exec); @@ -351,6 +352,11 @@ app.whenReady().then(async () => { registerConsumer(backgroundTaskEventConsumer); initEventProcessor(); + // If the stored Google grant predates a scope change (only old scopes), + // disconnect it now so the user re-connects with the current scopes before + // any Google sync runs against the stale grant. + await disconnectGoogleIfScopesStale(); + // start gmail sync initGmailSync(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index f61b59cc..1048d9b8 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,6 +1,7 @@ import { shell } from 'electron'; import type { Server } from 'http'; import { createAuthServer } from './auth-server.js'; +import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js'; @@ -17,7 +18,9 @@ import { isSignedIn } from '@x/core/dist/account/account.js'; import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; -const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; +function buildRedirectUri(port: number): string { + return `http://localhost:${port}/oauth/callback`; +} /** Top-level openid-client messages that often wrap a more specific cause. */ const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']); @@ -114,9 +117,15 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { } /** - * Get or create OAuth configuration for a provider + * Get or create OAuth configuration for a provider. + * `redirectUri` is required for DCR providers — it is the actual callback URI + * (including port) that was just bound, so the registration and auth URL stay in sync. */ -async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise { +async function getProviderConfiguration( + provider: string, + redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT), + credentialsOverride?: { clientId: string; clientSecret: string }, +): Promise { const config = await getProviderConfig(provider); const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => { if (config.client.mode === 'static' && config.client.clientId) { @@ -148,7 +157,7 @@ async function getProviderConfiguration(provider: string, credentialsOverride?: console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`); const clientRepo = getClientRegistrationRepo(); const existingRegistration = await clientRepo.getClientRegistration(provider); - + if (existingRegistration) { console.log(`[OAuth] ${provider}: Using existing DCR registration`); return await oauthClient.discoverConfiguration( @@ -157,18 +166,21 @@ async function getProviderConfiguration(provider: string, credentialsOverride?: ); } - // Register new client + // Register new client with the actual redirect URI (port already bound) const scopes = config.scopes || []; const { config: oauthConfig, registration } = await oauthClient.registerClient( config.discovery.issuer, - [REDIRECT_URI], + [redirectUri], scopes ); - - // Save registration for future use - await clientRepo.saveClientRegistration(provider, registration); - console.log(`[OAuth] ${provider}: DCR registration saved`); - + + // Parse port from redirectUri (e.g. "http://localhost:8081/...") and save + const boundPort = new URL(redirectUri).port + ? parseInt(new URL(redirectUri).port, 10) + : DEFAULT_CALLBACK_PORT; + await clientRepo.saveClientRegistration(provider, registration, boundPort); + console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`); + return oauthConfig; } } else { @@ -176,7 +188,7 @@ async function getProviderConfiguration(provider: string, credentialsOverride?: if (config.client.mode !== 'static') { throw new Error('DCR requires discovery mode "issuer", not "static"'); } - + console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`); const { clientId, clientSecret } = await resolveClientCredentials(); return oauthClient.createStaticConfiguration( @@ -189,6 +201,37 @@ async function getProviderConfiguration(provider: string, credentialsOverride?: } } +/** + * Determine which port to start the OAuth callback server on for a DCR provider. + * + * If the provider has an existing registration, probes the port it was registered + * on. If that port is still available, returns it so the existing client_id keeps + * working. If it is blocked, clears the stale registration (forcing re-registration + * on the next available port) and returns DEFAULT_CALLBACK_PORT as the scan base. + * + * Exported for unit testing. + */ +export async function resolveStartPort( + provider: string, + clientRepo: IClientRegistrationRepo, +): Promise { + const existingReg = await clientRepo.getClientRegistration(provider); + if (!existingReg) return DEFAULT_CALLBACK_PORT; + + const registeredPort = await clientRepo.getRegisteredPort(provider); + try { + // Probe — fixed-port (no fallback) so we know whether the exact registered port is free + const probe = await createAuthServer(registeredPort, () => { /* probe */ }); + probe.server.close(); + console.log(`[OAuth] ${provider}: registered port ${registeredPort} still available`); + return registeredPort; + } catch { + console.log(`[OAuth] ${provider}: registered port ${registeredPort} blocked, clearing DCR registration`); + await clientRepo.clearClientRegistration(provider); + return DEFAULT_CALLBACK_PORT; + } +} + /** * Initiate OAuth flow for a provider */ @@ -225,154 +268,188 @@ export async function connectProvider(provider: string, credentials?: { clientId } } - // Get or create OAuth configuration - const config = await getProviderConfiguration(provider, credentials); + // For static-client providers (Google BYOK) the redirect URI is pre-registered + // at the OAuth provider console on a fixed port — we must not scan. + // For DCR providers, resolveStartPort handles the re-registration trap. + const isStaticClient = providerConfig.client.mode === 'static'; + const startPort = isStaticClient + ? DEFAULT_CALLBACK_PORT + : await resolveStartPort(provider, getClientRegistrationRepo()); - // Generate PKCE codes - const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); - const state = oauthClient.generateState(); - - // Get scopes from config - const scopes = providerConfig.scopes || []; - - // Store flow state - activeFlows.set(state, { codeVerifier, provider, config }); - - // Build authorization URL - const authUrl = oauthClient.buildAuthorizationUrl(config, { - redirect_uri: REDIRECT_URI, - scope: scopes.join(' '), - code_challenge: codeChallenge, - state, - }); - - // Create callback server + // --- Callback server --- + // Declare `state` before the closure so the callback can close over its binding. + // The variable is assigned below, before shell.openExternal, so it is always + // set by the time any browser request arrives. + let state = ''; let callbackHandled = false; - const { server } = await createAuthServer(8080, async (callbackUrl) => { - // Guard against duplicate callbacks (browser may send multiple requests) - if (callbackHandled) return; - callbackHandled = true; - const receivedState = callbackUrl.searchParams.get('state'); - if (receivedState == null || receivedState === '') { - throw new Error( - 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.' - ); - } - if (receivedState !== state) { - throw new Error('Invalid state parameter - possible CSRF attack'); - } - const flow = activeFlows.get(state); - if (!flow || flow.provider !== provider) { - throw new Error('Invalid OAuth flow state'); - } - - try { - // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds - console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); - const tokens = await oauthClient.exchangeCodeForTokens( - flow.config, - callbackUrl, - flow.codeVerifier, - state - ); - - // Save tokens and credentials. For Google, BYOK is the only path - // that reaches this token exchange (rowboat path returns above - // before any local server runs); stamp mode: 'byok' so a future - // refresh / reconnect can't get confused with a rowboat entry. - console.log(`[OAuth] Token exchange successful for ${provider}`); - await oauthRepo.upsert(provider, { - tokens, - ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), - ...(provider === 'google' ? { mode: 'byok' as const } : {}), - error: null, - }); - - // Trigger immediate sync for relevant providers - if (provider === 'google') { - triggerGmailSync(); - triggerCalendarSync(); - } else if (provider === 'fireflies-ai') { - triggerFirefliesSync(); + const { server, port: boundPort } = await createAuthServer( + startPort, + async (callbackUrl) => { + // Guard against duplicate callbacks (browser may send multiple requests) + if (callbackHandled) return; + callbackHandled = true; + const receivedState = callbackUrl.searchParams.get('state'); + if (receivedState == null || receivedState === '') { + throw new Error( + 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.' + ); + } + if (receivedState !== state) { + throw new Error('Invalid state parameter - possible CSRF attack'); } - // For Rowboat sign-in, ensure user + Stripe customer exist before - // notifying the renderer. Without this, parallel API calls from - // multiple renderer hooks race to create the user, causing duplicates. - let signedInUserId: string | undefined; - if (provider === 'rowboat') { - try { - const billing = await getBillingInfo(); - if (billing.userId) { - signedInUserId = billing.userId; - analyticsIdentify(billing.userId, { - ...(billing.userEmail ? { email: billing.userEmail } : {}), - plan: billing.subscriptionPlan, - status: billing.subscriptionStatus, - }); - analyticsCapture('user_signed_in', { - plan: billing.subscriptionPlan, - status: billing.subscriptionStatus, - }); + const flow = activeFlows.get(state); + if (!flow || flow.provider !== provider) { + throw new Error('Invalid OAuth flow state'); + } + + try { + // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds + console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); + const tokens = await oauthClient.exchangeCodeForTokens( + flow.config, + callbackUrl, + flow.codeVerifier, + state + ); + + // Save tokens and credentials. For Google, BYOK is the only path + // that reaches this token exchange (rowboat path returns above + // before any local server runs); stamp mode: 'byok' so a future + // refresh / reconnect can't get confused with a rowboat entry. + console.log(`[OAuth] Token exchange successful for ${provider}`); + await oauthRepo.upsert(provider, { + tokens, + ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), + ...(provider === 'google' ? { mode: 'byok' as const } : {}), + error: null, + }); + + // Trigger immediate sync for relevant providers + if (provider === 'google') { + triggerGmailSync(); + triggerCalendarSync(); + } else if (provider === 'fireflies-ai') { + triggerFirefliesSync(); + } + + // For Rowboat sign-in, ensure user + Stripe customer exist before + // notifying the renderer. Without this, parallel API calls from + // multiple renderer hooks race to create the user, causing duplicates. + let signedInUserId: string | undefined; + if (provider === 'rowboat') { + try { + const billing = await getBillingInfo(); + if (billing.userId) { + signedInUserId = billing.userId; + analyticsIdentify(billing.userId, { + ...(billing.userEmail ? { email: billing.userEmail } : {}), + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + analyticsCapture('user_signed_in', { + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + } + } catch (meError) { + console.error('[OAuth] Failed to initialize user via /v1/me:', meError); } - } catch (meError) { - console.error('[OAuth] Failed to initialize user via /v1/me:', meError); } - } - // Emit success event to renderer - emitOAuthEvent({ - provider, - success: true, - ...(signedInUserId ? { userId: signedInUserId } : {}), - }); - } catch (error) { - console.error('OAuth token exchange failed:', error); - // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError) - let cause: unknown = error; - while (cause != null && typeof cause === 'object' && 'cause' in cause) { - cause = (cause as { cause?: unknown }).cause; - if (cause != null) { - console.error('[OAuth] Caused by:', cause); + // Emit success event to renderer + emitOAuthEvent({ + provider, + success: true, + ...(signedInUserId ? { userId: signedInUserId } : {}), + }); + } catch (error) { + console.error('OAuth token exchange failed:', error); + // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError) + let cause: unknown = error; + while (cause != null && typeof cause === 'object' && 'cause' in cause) { + cause = (cause as { cause?: unknown }).cause; + if (cause != null) { + console.error('[OAuth] Caused by:', cause); + } + } + const errorMessage = getOAuthErrorMessage(error); + emitOAuthEvent({ provider, success: false, error: errorMessage }); + throw error; + } finally { + // Clean up + activeFlows.delete(state); + if (activeFlow && activeFlow.state === state) { + clearTimeout(activeFlow.cleanupTimeout); + activeFlow.server.close(); + activeFlow = null; } } - const errorMessage = getOAuthErrorMessage(error); - emitOAuthEvent({ provider, success: false, error: errorMessage }); - throw error; - } finally { - // Clean up + }, + // Static providers (Google BYOK) keep fixed-port behaviour to match the + // pre-registered redirect URI at the provider's console. DCR providers + // can fall back since we register the actual bound port below. + { fallback: !isStaticClient }, + ); + + // Server is bound. Any throw between here and `activeFlow = ...` would + // leak the port — `cancelActiveFlow` only closes it once activeFlow is set. + try { + // TOCTOU guard: resolveStartPort probed the registered port and found it + // free, but the port could have been grabbed between probe and real bind, + // causing fallback to a different port. The cached client_id is registered + // for the old port — clear it so getProviderConfiguration re-registers + // with the actual bound port. + if (!isStaticClient && boundPort !== startPort) { + console.log(`[OAuth] ${provider}: bound port ${boundPort} differs from start port ${startPort}, clearing stale DCR registration`); + await getClientRegistrationRepo().clearClientRegistration(provider); + } + + const redirectUri = buildRedirectUri(boundPort); + const config = await getProviderConfiguration(provider, redirectUri, credentials); + + const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); + state = oauthClient.generateState(); + + const scopes = providerConfig.scopes || []; + activeFlows.set(state, { codeVerifier, provider, config }); + + const authUrl = oauthClient.buildAuthorizationUrl(config, { + redirect_uri: redirectUri, + scope: scopes.join(' '), + code_challenge: codeChallenge, + state, + }); + + // Set timeout to clean up abandoned flows (2 minutes) + const cleanupTimeout = setTimeout(() => { + if (activeFlow?.state === state) { + console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); + cancelActiveFlow('timed_out'); + } + }, 2 * 60 * 1000); + + activeFlow = { + provider, + state, + server, + cleanupTimeout, + }; + + // Open in system browser (shares cookies/sessions with user's regular browser) + shell.openExternal(authUrl.toString()); + + return { success: true }; + } catch (setupError) { + // Post-bind setup failed — close the server so the port is released and + // a retry isn't blocked by our own zombie listener. + server.close(); + if (state) { activeFlows.delete(state); - if (activeFlow && activeFlow.state === state) { - clearTimeout(activeFlow.cleanupTimeout); - activeFlow.server.close(); - activeFlow = null; - } } - }); - - // Set timeout to clean up abandoned flows (2 minutes) - // This prevents memory leaks if user never completes the OAuth flow - const cleanupTimeout = setTimeout(() => { - if (activeFlow?.state === state) { - console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); - cancelActiveFlow('timed_out'); - } - }, 2 * 60 * 1000); // 2 minutes - - // Store complete flow state for cleanup - activeFlow = { - provider, - state, - server, - cleanupTimeout, - }; - - // Open in system browser (shares cookies/sessions with user's regular browser) - shell.openExternal(authUrl.toString()); - - // Wait for callback (server will handle it) - return { success: true }; + throw setupError; + } } catch (error) { console.error('OAuth connection failed:', error); return { @@ -431,7 +508,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b if (connection.mode === 'rowboat' && connection.tokens?.access_token) { try { const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; - const res = await fetch(revokeUrl, { method: 'POST' }); + const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }); if (!res.ok) { console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`); } @@ -455,6 +532,81 @@ export async function disconnectProvider(provider: string): Promise<{ success: b } } +/** + * Startup migration for Google scope changes. When a connected Google grant was + * issued before a scope was added (e.g. old installs on gmail.readonly that + * never received gmail.modify), invalidate it so the user is prompted to + * reconnect and re-grant with the current scopes. The currently-requested + * scopes in the provider config are the source of truth: a grant missing any + * of them is treated as stale. + * + * We revoke + clear the stale token but DELIBERATELY keep the provider entry + * with an `error` set rather than calling disconnectProvider (which deletes the + * whole entry). The renderer's reconnect prompts — the sidebar "Reconnect your + * accounts" alert and the connectors "Reconnect" row — key off this `error` + * field, not off the connected flag. A fully deleted entry has no error and is + * indistinguishable from "never connected", so no prompt would ever appear. + * + * Tokens with no recorded scopes (very old installs that never persisted them) + * are also treated as stale. Safe to call on every startup — it's a no-op once + * the grant covers all current scopes, and once invalidated the early return on + * the missing token keeps it from re-running until the user reconnects. + */ +export async function disconnectGoogleIfScopesStale(): Promise { + try { + const oauthRepo = getOAuthRepo(); + const connection = await oauthRepo.read('google'); + + // Not connected (or already invalidated) — nothing to migrate. + if (!connection.tokens) { + return; + } + + const providerConfig = await getProviderConfig('google'); + const requiredScopes = providerConfig.scopes ?? []; + if (requiredScopes.length === 0) { + return; + } + + const granted = new Set(connection.tokens.scopes ?? []); + const missingScopes = requiredScopes.filter((scope) => !granted.has(scope)); + if (missingScopes.length === 0) { + return; + } + + console.log( + `[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` + + 'invalidating it so the user is prompted to reconnect with the new scopes.' + ); + + // Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider). + if (connection.mode === 'rowboat' && connection.tokens.access_token) { + try { + const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; + const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }); + if (!res.ok) { + console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`); + } + } catch (error) { + console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error); + } + } + + // Drop the stale token but keep the entry with an error so the reconnect + // prompt fires (see the note above). + await oauthRepo.upsert('google', { + tokens: null, + error: 'Google permissions changed. Please reconnect to continue.', + }); + + // Nudge any already-open window to re-read state. The renderer's initial + // mount also re-reads, so the prompt shows even if no window is up yet. + emitOAuthEvent({ provider: 'google', success: false }); + } catch (error) { + console.error('[OAuth] Google scope migration check failed:', error); + } +} + /** * Get access token for a provider (internal use only) * Refreshes token if expired diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 6c6a2e25..86c6535d 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -35,6 +35,30 @@ } } +/* Radix Collapsible expand/collapse — animate height (via the radix CSS var) + plus a subtle fade. Used by the web search card. */ +@keyframes collapsible-down { + from { + height: 0; + opacity: 0; + } + to { + height: var(--radix-collapsible-content-height); + opacity: 1; + } +} + +@keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height); + opacity: 1; + } + to { + height: 0; + opacity: 0; + } +} + @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; @@ -232,6 +256,10 @@ color: var(--gm-text-faint); } +.gmail-row-shell { + position: relative; +} + .gmail-row { display: grid; grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px; @@ -249,20 +277,56 @@ transition: background 120ms ease; } +.gmail-row-actions { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 2px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.gmail-row-shell:hover .gmail-row-actions { + opacity: 1; + pointer-events: auto; +} + +.gmail-row-shell:hover .gmail-row-date { + visibility: hidden; +} + +.gmail-row-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-row-action:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + +.gmail-row-action-danger:hover { + color: #e8453c; +} + .gmail-row:hover { background: var(--gm-bg-row-hover); box-shadow: none; } -.upcoming-event-row { - background-color: transparent; - transition: background-color 120ms ease; -} - -.upcoming-event-row:hover { - background-color: var(--gm-bg-pill-hover); -} - .gmail-row-selected { background: var(--gm-bg-row-selected); box-shadow: inset 2px 0 0 var(--gm-accent); @@ -496,6 +560,7 @@ .gmail-message-from span, .gmail-message-to, +.gmail-message-cc, .gmail-message-date { color: var(--gm-text-muted); font-size: 12px; @@ -694,6 +759,126 @@ font: inherit; } +.gmail-compose-label { + flex: none; + min-width: 28px; + color: var(--gm-text-muted); +} + +.gmail-compose-subject-input { + min-width: 0; + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font: inherit; +} + +/* Recipient (To / Cc / Bcc) rows with editable chips */ +.gmail-recipient-row { + display: flex; + align-items: flex-start; + gap: 8px; + min-height: 34px; + padding: 5px 12px; + border-bottom: 1px solid var(--gm-border); + font-size: 13px; +} + +.gmail-recipient-label { + flex: none; + min-width: 28px; + padding-top: 5px; + color: var(--gm-text-muted); +} + +.gmail-recipient-field { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.gmail-recipient-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + height: 24px; + padding: 0 4px 0 10px; + border-radius: 12px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font-size: 12px; + line-height: 1; +} + +.gmail-recipient-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; +} + +.gmail-recipient-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--gm-text-muted); + font-size: 14px; + line-height: 1; + cursor: pointer; +} + +.gmail-recipient-chip-remove:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text); +} + +.gmail-recipient-input { + flex: 1 1 80px; + min-width: 80px; + height: 24px; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font: inherit; + font-size: 13px; +} + +.gmail-recipient-trailing { + flex: none; + padding-top: 5px; +} + +.gmail-recipient-toggles { + display: flex; + gap: 10px; +} + +.gmail-recipient-toggles button { + border: none; + background: transparent; + color: var(--gm-text-muted); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.gmail-recipient-toggles button:hover { + color: var(--gm-text); + text-decoration: underline; +} + .gmail-compose-toolbar { display: flex; align-items: center; @@ -1015,6 +1200,10 @@ --scrollbar-track: oklch(0.95 0 0); --scrollbar-thumb: oklch(0.75 0 0); --scrollbar-thumb-hover: oklch(0.65 0 0); + /* Subtle raised-card surface: tints toward foreground, so it reads a hair + darker than the background in light mode and a hair lighter in dark mode. + Shared by the web search card and tool-call group. */ + --card-surface: color-mix(in oklab, var(--background) 98.5%, var(--foreground)); --rowboat-panel: oklch(0.97 0 0); --rowboat-raised: oklch(1 0 0); --rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index cd668fa2..5c072b2a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,10 +5,12 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, SquarePen, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; +import { ChatHeader } from './components/chat-header'; +import { ChatEmptyState } from './components/chat-empty-state'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; @@ -25,12 +27,15 @@ import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; +import { WorkspaceView } from '@/components/workspace-view'; +import { KnowledgeView } from '@/components/knowledge-view'; +import { ChatHistoryView } from '@/components/chat-history-view'; +import { HomeView } from '@/components/home-view'; import { MeetingsView } from '@/components/meetings-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, ConversationContent, - ConversationEmptyState, ConversationScrollButton, } from '@/components/ai-elements/conversation'; import { @@ -52,7 +57,6 @@ import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-c import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; -import { Suggestions } from '@/components/ai-elements/suggestions'; import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, @@ -61,20 +65,16 @@ import { } from "@/components/ui/sidebar" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" -import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' +import { BillingErrorDialog } from "@/components/billing-error-dialog" +import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" +import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal' -import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog' +import { CommandPalette, type CommandPaletteMention, type SearchType } from '@/components/search-dialog' import { LiveNoteSidebar } from '@/components/live-note-sidebar' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BrowserPane } from '@/components/browser-pane/BrowserPane' @@ -188,6 +188,11 @@ const MEETINGS_TAB_PATH = '__rowboat_meetings__' const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__' const EMAIL_TAB_PATH = '__rowboat_email__' +const WORKSPACE_TAB_PATH = '__rowboat_workspace__' +const WORKSPACE_ROOT = 'knowledge/Workspace' +const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__' +const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__' +const HOME_TAB_PATH = '__rowboat_home__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -321,6 +326,10 @@ const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH +const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH +const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH +const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH +const isHomeTabPath = (path: string) => path === HOME_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -571,12 +580,18 @@ type ViewState = | { type: 'meetings' } | { type: 'live-notes' } | { type: 'email' } + | { type: 'workspace'; path?: string } + | { type: 'knowledge-view'; folderPath?: string } + | { type: 'chat-history' } + | { type: 'home' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false if (a.type === 'chat' && b.type === 'chat') return a.runId === b.runId if (a.type === 'file' && b.type === 'file') return a.path === b.path 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 ?? '') return true // both graph } @@ -620,6 +635,18 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'meetings' } case 'live-notes': return { type: 'live-notes' } + case 'workspace': { + const path = params.get('path') + return { type: 'workspace', path: path ?? undefined } + } + case 'knowledge-view': { + const folderPath = params.get('folderPath') + return { type: 'knowledge-view', folderPath: folderPath ?? undefined } + } + case 'chat-history': + return { type: 'chat-history' } + case 'home': + return { type: 'home' } default: return null } @@ -633,7 +660,7 @@ function FixedSidebarToggle({ }) { const { toggleSidebar } = useSidebar() return ( -
+