diff --git a/README.md b/README.md index 46be6f49..361b87a0 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,18 @@ Rowboat connects to your email and meeting notes, builds a long-lived knowledge You can do things like: - `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph - `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note) +- Track a person, company or topic through live notes - Visualize, edit, and update your knowledge graph anytime (it’s just Markdown) - Record voice memos that automatically capture and update key takeaways in the graph Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads) +⭐ If you find Rowboat useful, please star the repo. It helps more people find it. ## Demo +[![Demo](https://github.com/user-attachments/assets/8b9a859b-d4f1-47ca-9d1d-9d26d982e15d)](https://www.youtube.com/watch?v=7xTpciZCfpw) - -[![Demo](https://github.com/user-attachments/assets/3f560bcf-d93c-4064-81eb-75a9fae31742)](https://www.youtube.com/watch?v=5AWoGo-L16I) - -[Watch the full video](https://www.youtube.com/watch?v=5AWoGo-L16I) +[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw) --- @@ -60,23 +60,27 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/do To connect Google services (Gmail, Calendar, and Drive), follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md). ### Voice input -To enable voice input and voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json: +To enable voice input and voice notes (optional), add a Deepgram API key in `~/.rowboat/config/deepgram.json` + +### Voice output + +To enable voice output (optional), add an ElevenLabs API key in `~/.rowboat/config/elevenlabs.json` + +### Web search + +To use Exa research search (optional), add the Exa API key in `~/.rowboat/config/exa-search.json` + +### External tools + +To enable external tools (optional), you can add any MCP server or use Composio tools by adding an API key in `~/.rowboat/config/composio.json` + +All API key files use the same format: ``` { "apiKey": "" } ``` -### Voice output - -To enable voice output (optional), add a Elevenlabs API key in ~/.rowboat/config/elevenlabs.json - -### Web search - -To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json. - -(same format as above) - ## What it does Rowboat is a **local-first AI coworker** that can: @@ -90,8 +94,10 @@ Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Mark Rowboat builds memory from the work you already do, including: - **Gmail** (email) -- **Granola** (meeting notes) -- **Fireflies** (meeting notes) +- **Google Calendar** +- **Rowboat meeting notes** or **Fireflies** + +It also contains a library of product integrations through Composio.dev ## How it’s different @@ -113,17 +119,15 @@ The result is memory that compounds, rather than retrieval that starts cold ever - **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped - **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions) -## Background agents +## Live notes -Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time. +Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note. -Examples: -- Draft email replies in the background (grounded in your past context and commitments) -- Generate a daily voice note each morning (agenda, priorities, upcoming meetings) -- Create recurring project updates from the latest emails/notes -- Keep your knowledge graph up to date as new information comes in +- Track a competitor or market topic across X, Reddit, and the news +- Monitor a person, project, or deal across web or your communications +- Keep a running summary of any subject you care about -You control what runs, when it runs, and what gets written back into your local Markdown vault. +Everything is written back into your local Markdown vault. You control what runs and when. ## Bring your own model diff --git a/apps/docs/docs/img/google-setup/07-enter-credentials.png b/apps/docs/docs/img/google-setup/07-enter-credentials.png new file mode 100644 index 00000000..9ab73334 Binary files /dev/null and b/apps/docs/docs/img/google-setup/07-enter-credentials.png differ diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index 78e519d0..ad184451 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -22,10 +22,11 @@ export interface AuthServerResult { /** * 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, - onCallback: (params: Record) => void | Promise + onCallback: (callbackUrl: URL) => void | Promise ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -38,8 +39,6 @@ export function createAuthServer( const url = new URL(req.url, `http://localhost:${port}`); if (url.pathname === OAUTH_CALLBACK_PATH) { - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { @@ -65,9 +64,8 @@ export function createAuthServer( return; } - // Handle callback - either traditional OAuth with code/state or Composio-style notification - // Composio callbacks may not have code/state, just a notification that the flow completed - onCallback(Object.fromEntries(url.searchParams.entries())); + // Handle callback - pass full URL so params like iss (OpenID Connect) are preserved for token exchange + onCallback(url); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 59532373..111eb5a5 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -151,7 +151,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ // Set up callback server const timeoutRef: { current: NodeJS.Timeout | null } = { current: null }; let callbackHandled = false; - const { server } = await createAuthServer(8081, async () => { + const { server } = await createAuthServer(8081, async (_callbackUrl) => { // Guard against duplicate callbacks (browser may send multiple requests) if (callbackHandled) return; callbackHandled = true; diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e05b57b3..74388f65 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -110,6 +110,18 @@ function markdownToHtml(markdown: string, title: string): string { ${html}` } +function resolveShellPath(filePath: string): string { + if (filePath.startsWith('~')) { + return path.join(os.homedir(), filePath.slice(1)); + } + + if (path.isAbsolute(filePath)) { + return filePath; + } + + return workspace.resolveWorkspacePath(filePath); +} + type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -271,7 +283,7 @@ function handleWorkspaceChange(event: z.infer { - return await connectProvider(args.provider, args.clientId?.trim()); + const credentials = args.clientId && args.clientSecret + ? { clientId: args.clientId.trim(), clientSecret: args.clientSecret.trim() } + : undefined; + return await connectProvider(args.provider, credentials); }, 'oauth:disconnect': async (_event, args) => { return await disconnectProvider(args.provider); @@ -604,24 +619,12 @@ export function setupIpcHandlers() { }, // Shell integration handlers 'shell:openPath': async (_event, args) => { - let filePath = args.path; - if (filePath.startsWith('~')) { - filePath = path.join(os.homedir(), filePath.slice(1)); - } else if (!path.isAbsolute(filePath)) { - // Workspace-relative path — resolve against ~/.rowboat/ - filePath = path.join(os.homedir(), '.rowboat', filePath); - } + const filePath = resolveShellPath(args.path); const error = await shell.openPath(filePath); return { error: error || undefined }; }, 'shell:readFileBase64': async (_event, args) => { - let filePath = args.path; - if (filePath.startsWith('~')) { - filePath = path.join(os.homedir(), filePath.slice(1)); - } else if (!path.isAbsolute(filePath)) { - // Workspace-relative path — resolve against ~/.rowboat/ - filePath = path.join(os.homedir(), '.rowboat', filePath); - } + const filePath = resolveShellPath(args.path); const stat = await fs.stat(filePath); if (stat.size > 10 * 1024 * 1024) { throw new Error('File too large (>10MB)'); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 7ade72e3..42c9f3fd 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -112,6 +112,8 @@ function createWindow() { const win = new BrowserWindow({ width: 1280, height: 800, + minWidth: 600, + minHeight: 480, show: false, // Don't show until ready backgroundColor: "#252525", // Prevent white flash (matches dark mode) titleBarStyle: "hiddenInset", diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index dde2246d..483f25ee 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -14,6 +14,43 @@ import { emitOAuthEvent } from './ipc.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; +/** Top-level openid-client messages that often wrap a more specific cause. */ +const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']); + +function firstCauseMessage(error: unknown): string | undefined { + if (error == null || typeof error !== 'object' || !('cause' in error)) { + return undefined; + } + const cause = (error as { cause?: unknown }).cause; + if (cause instanceof Error && cause.message.trim()) { + return cause.message; + } + if (typeof cause === 'string' && cause.trim()) { + return cause; + } + return undefined; +} + +/** + * User-facing message for token-exchange failures. Prefer the first cause message when + * the top-level message is opaque (common for openid-client) or when code is OAUTH_INVALID_RESPONSE. + * The catch block below still logs the full cause chain for any error; this helper stays conservative. + */ +function getOAuthErrorMessage(error: unknown): string { + const msg = error instanceof Error ? error.message : 'Unknown error'; + const code = error != null && typeof error === 'object' && 'code' in error + ? (error as { code?: string }).code + : undefined; + const causeMsg = firstCauseMessage(error); + if (code === 'OAUTH_INVALID_RESPONSE' && causeMsg) { + return causeMsg; + } + if (causeMsg && OPAQUE_OAUTH_TOP_MESSAGES.has(msg.trim().toLowerCase())) { + return causeMsg; + } + return msg; +} + // Store active OAuth flows (state -> { codeVerifier, provider, config }) const activeFlows = new Map { +async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise { const config = await getProviderConfig(provider); - const resolveClientId = async (): Promise => { + const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => { if (config.client.mode === 'static' && config.client.clientId) { - return config.client.clientId; + return { clientId: config.client.clientId, clientSecret: credentialsOverride?.clientSecret }; } - if (clientIdOverride) { - return clientIdOverride; + if (credentialsOverride) { + return { clientId: credentialsOverride.clientId, clientSecret: credentialsOverride.clientSecret }; } const oauthRepo = getOAuthRepo(); - const { clientId } = await oauthRepo.read(provider); - if (clientId) { - return clientId; + const connection = await oauthRepo.read(provider); + if (connection.clientId) { + return { clientId: connection.clientId, clientSecret: connection.clientSecret ?? undefined }; } throw new Error(`${provider} client ID not configured. Please provide a client ID.`); }; @@ -95,10 +132,11 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str if (config.client.mode === 'static') { // Discover endpoints, use static client ID console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`); - const clientId = await resolveClientId(); + const { clientId, clientSecret } = await resolveClientCredentials(); return await oauthClient.discoverConfiguration( config.discovery.issuer, - clientId + clientId, + clientSecret ); } else { // DCR mode - check for existing registration or register new @@ -135,12 +173,13 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str } console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`); - const clientId = await resolveClientId(); + const { clientId, clientSecret } = await resolveClientCredentials(); return oauthClient.createStaticConfiguration( config.discovery.authorizationEndpoint, config.discovery.tokenEndpoint, clientId, - config.discovery.revocationEndpoint + config.discovery.revocationEndpoint, + clientSecret ); } } @@ -148,7 +187,7 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str /** * Initiate OAuth flow for a provider */ -export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> { +export async function connectProvider(provider: string, credentials?: { clientId: string; clientSecret: string }): Promise<{ success: boolean; error?: string }> { try { console.log(`[OAuth] Starting connection flow for ${provider}...`); @@ -159,13 +198,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom const providerConfig = await getProviderConfig(provider); if (provider === 'google') { - if (!clientId) { - return { success: false, error: 'Google client ID is required to connect.' }; + if (!credentials?.clientId || !credentials?.clientSecret) { + return { success: false, error: 'Google client ID and client secret are required to connect.' }; } } // Get or create OAuth configuration - const config = await getProviderConfiguration(provider, clientId); + const config = await getProviderConfiguration(provider, credentials); // Generate PKCE codes const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); @@ -187,12 +226,17 @@ export async function connectProvider(provider: string, clientId?: string): Prom // Create callback server let callbackHandled = false; - const { server } = await createAuthServer(8080, async (params: Record) => { + const { server } = await createAuthServer(8080, async (callbackUrl) => { // Guard against duplicate callbacks (browser may send multiple requests) if (callbackHandled) return; callbackHandled = true; - // Validate state - if (params.state !== state) { + 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'); } @@ -202,10 +246,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom } try { - // Build callback URL for token exchange - const callbackUrl = new URL(`${REDIRECT_URI}?${new URLSearchParams(params).toString()}`); - - // Exchange code for tokens + // 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, @@ -214,13 +255,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom state ); - // Save tokens + // Save tokens and credentials console.log(`[OAuth] Token exchange successful for ${provider}`); - await oauthRepo.upsert(provider, { tokens }); - if (provider === 'google' && clientId) { - await oauthRepo.upsert(provider, { clientId }); - } - await oauthRepo.upsert(provider, { error: null }); + await oauthRepo.upsert(provider, { + tokens, + ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), + error: null, + }); // Trigger immediate sync for relevant providers if (provider === 'google') { @@ -234,7 +275,15 @@ export async function connectProvider(provider: string, clientId?: string): Prom emitOAuthEvent({ provider, success: true }); } catch (error) { console.error('OAuth token exchange failed:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown 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 { @@ -302,8 +351,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b export async function getAccessToken(provider: string): Promise { try { const oauthRepo = getOAuthRepo(); - - const { tokens } = await oauthRepo.read(provider); + + let { tokens } = await oauthRepo.read(provider); if (!tokens) { return null; } @@ -319,11 +368,12 @@ export async function getAccessToken(provider: string): Promise { try { // Get configuration for refresh const config = await getProviderConfiguration(provider); - + // Refresh token, preserving existing scopes const existingScopes = tokens.scopes; const refreshedTokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes); - await oauthRepo.upsert(provider, { tokens }); + await oauthRepo.upsert(provider, { tokens: refreshedTokens }); + tokens = refreshedTokens; } catch (error) { const message = error instanceof Error ? error.message : 'Token refresh failed'; await oauthRepo.upsert(provider, { error: message }); diff --git a/apps/x/apps/main/src/test-agent.ts b/apps/x/apps/main/src/test-agent.ts index 836deea7..738d861a 100644 --- a/apps/x/apps/main/src/test-agent.ts +++ b/apps/x/apps/main/src/test-agent.ts @@ -3,7 +3,7 @@ import { bus } from '@x/core/dist/runs/bus.js'; async function main() { const { id } = await runsCore.createRun({ - // this will expect an agent file to exist at ~/.rowboat/agents/test-agent.md + // this expects an agent file to exist at WorkDir/agents/test-agent.md agentId: 'test-agent', }); console.log(`created run: ${id}`); @@ -16,4 +16,4 @@ async function main() { console.log(`created message: ${msgId}`); } -main(); \ No newline at end of file +main(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 9107189a..17e49f6e 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -124,8 +124,8 @@ const TITLEBAR_BUTTON_PX = 32 const TITLEBAR_BUTTON_GAP_PX = 4 const TITLEBAR_HEADER_GAP_PX = 8 const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 -const TITLEBAR_BUTTONS_COLLAPSED = 5 -const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4 +const TITLEBAR_BUTTONS_COLLAPSED = 4 +const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3 const GRAPH_TAB_PATH = '__rowboat_graph_view__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' @@ -446,12 +446,8 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { return true // both graph } -/** Sidebar toggle + back/forward nav */ +/** Sidebar toggle + utility buttons (fixed position, top-left) */ function FixedSidebarToggle({ - onNavigateBack, - onNavigateForward, - canNavigateBack, - canNavigateForward, onNewChat, onOpenSearch, meetingState, @@ -460,10 +456,6 @@ function FixedSidebarToggle({ onToggleMeeting, leftInsetPx, }: { - onNavigateBack: () => void - onNavigateForward: () => void - canNavigateBack: boolean - canNavigateForward: boolean onNewChat: () => void onOpenSearch: () => void meetingState: MeetingTranscriptionState @@ -472,8 +464,7 @@ function FixedSidebarToggle({ onToggleMeeting: () => void leftInsetPx: number }) { - const { toggleSidebar, state } = useSidebar() - const isCollapsed = state === "collapsed" + const { toggleSidebar } = useSidebar() return (
) } @@ -584,15 +551,14 @@ function ContentHeader({ const isCollapsed = state === "collapsed" return (
- {!isCollapsed && onNavigateBack && onNavigateForward ? ( + {onNavigateBack && onNavigateForward ? (
+
+ + setClientSecret(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + className="font-mono text-xs" + /> +

Need help?{" "} { - window.open("https://discord.gg/htdKpBZF", "_blank") + window.open("https://discord.com/invite/wajrgmJQ6b", "_blank") } return ( diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 82064205..c7f723ac 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -23,7 +23,7 @@ import { } from "@/components/ui/select" import { cn } from "@/lib/utils" import { GoogleClientIdModal } from "@/components/google-client-id-modal" -import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" +import { setGoogleCredentials } from "@/lib/google-credentials-store" import { toast } from "sonner" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" @@ -589,14 +589,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { }, []) - const startConnect = useCallback(async (provider: string, clientId?: string) => { + const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => { setProviderStates(prev => ({ ...prev, [provider]: { ...prev[provider], isConnecting: true } })) try { - const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) + const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret }) if (!result.success) { toast.error(result.error || `Failed to connect to ${provider}`) @@ -618,22 +618,17 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Connect to a provider const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { - const existingClientId = getGoogleClientId() - if (!existingClientId) { - setGoogleClientIdOpen(true) - return - } - await startConnect(provider, existingClientId) + setGoogleClientIdOpen(true) return } await startConnect(provider) }, [startConnect]) - const handleGoogleClientIdSubmit = useCallback((clientId: string) => { - setGoogleClientId(clientId) + const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { + setGoogleCredentials(clientId, clientSecret) setGoogleClientIdOpen(false) - startConnect('google', clientId) + startConnect('google', { clientId, clientSecret }) }, [startConnect]) // Step indicator - dynamic based on path diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index 7cc50a90..a55b23fe 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react" -import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" +import { setGoogleCredentials } from "@/lib/google-credentials-store" import { toast } from "sonner" export interface ProviderState { @@ -576,14 +576,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { return cleanup }, []) - const startConnect = useCallback(async (provider: string, clientId?: string) => { + const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => { setProviderStates(prev => ({ ...prev, [provider]: { ...prev[provider], isConnecting: true } })) try { - const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) + const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret }) if (!result.success) { toast.error(result.error || `Failed to connect to ${provider}`) @@ -605,22 +605,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { // Connect to a provider const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { - const existingClientId = getGoogleClientId() - if (!existingClientId) { - setGoogleClientIdOpen(true) - return - } - await startConnect(provider, existingClientId) + setGoogleClientIdOpen(true) return } await startConnect(provider) }, [startConnect]) - const handleGoogleClientIdSubmit = useCallback((clientId: string) => { - setGoogleClientId(clientId) + const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { + setGoogleCredentials(clientId, clientSecret) setGoogleClientIdOpen(false) - startConnect('google', clientId) + startConnect('google', { clientId, clientSecret }) }, [startConnect]) // Switch to rowboat path from BYOK inline callout diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 7e41783e..3fcb1acc 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -205,7 +205,7 @@ function formatRunTime(ts: string): string { } function SyncStatusBar() { - const { state, isMobile } = useSidebar() + const { state } = useSidebar() const [activeServices, setActiveServices] = useState>(new Map()) const [popoverOpen, setPopoverOpen] = useState(false) const [logEvents, setLogEvents] = useState([]) @@ -301,7 +301,7 @@ function SyncStatusBar() { return ( <> - {!isMobile && isCollapsed && isSyncing && ( + {isCollapsed && isSyncing && (

void - openMobile: boolean - setOpenMobile: (open: boolean) => void - isMobile: boolean toggleSidebar: () => void sidebarWidth: number setSidebarWidth: (width: number) => void @@ -70,8 +59,6 @@ function SidebarProvider({ open?: boolean onOpenChange?: (open: boolean) => void }) { - const isMobile = useIsMobile() - const [openMobile, setOpenMobile] = React.useState(false) const [sidebarWidth, setSidebarWidth] = React.useState(SIDEBAR_WIDTH) const [isResizing, setIsResizing] = React.useState(false) @@ -94,10 +81,20 @@ function SidebarProvider({ [setOpenProp, open] ) + // Auto-collapse sidebar when window crosses below the threshold + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${SIDEBAR_AUTO_COLLAPSE_WIDTH - 1}px)`) + const onChange = () => { + if (mql.matches) setOpen(false) + } + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) + }, [setOpen]) + // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) - }, [isMobile, setOpen, setOpenMobile]) + return setOpen((open) => !open) + }, [setOpen]) // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. @@ -108,16 +105,13 @@ function SidebarProvider({ state, open, setOpen, - isMobile, - openMobile, - setOpenMobile, toggleSidebar, sidebarWidth, setSidebarWidth, isResizing, setIsResizing, }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, sidebarWidth, isResizing] + [state, open, setOpen, toggleSidebar, sidebarWidth, isResizing] ) return ( @@ -161,7 +155,7 @@ function Sidebar({ variant?: "sidebar" | "floating" | "inset" collapsible?: "offcanvas" | "icon" | "none" }) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + const { state } = useSidebar() if (collapsible === "none") { return ( @@ -178,34 +172,9 @@ function Sidebar({ ) } - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - -
{children}
-
-
- ) - } - return (