Merge remote-tracking branch 'origin/dev' into notes_feedback

# Conflicts:
#	apps/x/apps/renderer/src/App.tsx
#	apps/x/apps/renderer/src/components/help-popover.tsx
This commit is contained in:
Gagancreates 2026-05-24 23:31:11 +05:30
commit cbcdd10228
28 changed files with 4048 additions and 1705 deletions

View file

@ -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<void>
): Promise<AuthServerResult> {
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<void>,
opts: { fallback?: boolean } = {},
): Promise<AuthServerResult> {
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}.`);
}

View file

@ -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<Configuration> {
async function getProviderConfiguration(
provider: string,
redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT),
credentialsOverride?: { clientId: string; clientSecret: string },
): Promise<Configuration> {
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<number> {
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 {

File diff suppressed because it is too large Load diff

View file

@ -1418,6 +1418,18 @@ export interface BgTasksViewProps {
* "Edit with Copilot" button in the detail-view sidebar footer.
*/
onEditWithCopilot?: (slug: string) => void
/**
* If provided, the view opens with this task already selected. Updates to
* this prop sync into internal state so the sidebar can swap which task is
* focused without remounting the view.
*/
initialSlug?: string | null
/**
* Bump this counter to force a re-focus on `initialSlug` even when the
* slug value itself didn't change (e.g. user clicks the same task in the
* sidebar twice after navigating away inside the view).
*/
slugVersion?: number
}
function formatLastRanLabel(iso: string | null | undefined): string {
@ -1425,9 +1437,12 @@ function formatLastRanLabel(iso: string | null | undefined): string {
return formatRelativeTime(iso) || 'Never'
}
export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot }: BgTasksViewProps = {}) {
export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlug, slugVersion }: BgTasksViewProps = {}) {
const [items, setItems] = useState<BackgroundTaskSummary[]>([])
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
const [selectedSlug, setSelectedSlug] = useState<string | null>(initialSlug ?? null)
useEffect(() => {
setSelectedSlug(initialSlug ?? null)
}, [initialSlug, slugVersion])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showNewDialog, setShowNewDialog] = useState(false)

View file

@ -0,0 +1,61 @@
import { useEffect, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import type { BillingErrorMatch } from "@/lib/billing-error"
interface BillingRowboatAccount {
config?: {
appUrl?: string | null
} | null
}
interface BillingErrorDialogProps {
open: boolean
match: BillingErrorMatch | null
onOpenChange: (open: boolean) => void
}
export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) {
const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => {
if (!open) return
window.ipc
.invoke('account:getRowboat', null)
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}, [open])
if (!match) return null
const handleUpgrade = () => {
if (appUrl) window.open(`${appUrl}?intent=upgrade`)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{match.title}</DialogTitle>
<DialogDescription>{match.subtitle}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss
</Button>
<Button onClick={handleUpgrade} disabled={!appUrl}>
{match.cta}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,106 @@
import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatRelativeTime } from '@/lib/relative-time'
export interface ChatEmptyStateRun {
id: string
title?: string
createdAt: string
}
interface ChatEmptyStateProps {
recentRuns?: ChatEmptyStateRun[]
onSelectRun?: (runId: string) => void
onOpenChatHistory?: () => void
/** Fill the composer with a starter prompt (does not submit). */
onPickPrompt: (prompt: string) => void
/** Use a wider column — for the full-screen chat where the narrow column looks cramped. */
wide?: boolean
}
const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [
{ icon: Mail, title: 'Draft a reply', sub: 'to an email', prompt: "Let's draft a reply to [name]'s email" },
{ icon: Bot, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' },
{ icon: Telescope, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' },
]
/**
* Empty-state body for the chat surface: greeting, recent chats, and starter
* action cards. Shown in both the side-pane copilot and full-screen chat.
*/
export function ChatEmptyState({
recentRuns = [],
onSelectRun,
onOpenChatHistory,
onPickPrompt,
wide = false,
}: ChatEmptyStateProps) {
return (
<div className={cn('mx-auto flex w-full flex-col gap-6 px-2 py-6', wide ? 'max-w-2xl' : 'max-w-md')}>
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-border bg-background text-foreground">
<Sparkles className="size-[17px]" />
</div>
<div>
<div className="text-base font-semibold tracking-tight">How can I help?</div>
<div className="text-xs text-muted-foreground">Ask anything, or pick up where you left off.</div>
</div>
</div>
{recentRuns.length > 0 && (
<div>
<div className="flex items-center px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
<span className="flex-1">Recent chats</span>
{onOpenChatHistory && (
<button
type="button"
onClick={onOpenChatHistory}
className="inline-flex items-center gap-0.5 text-[11px] font-medium normal-case tracking-normal text-primary hover:underline"
>
View all
<ArrowUpRight className="size-3" />
</button>
)}
</div>
<div className="flex flex-col gap-0.5">
{recentRuns.slice(0, 4).map((run) => (
<button
key={run.id}
type="button"
onClick={() => onSelectRun?.(run.id)}
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-left hover:bg-accent"
>
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[13px]">{run.title || '(Untitled chat)'}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{formatRelativeTime(run.createdAt)}</span>
</button>
))}
</div>
</div>
)}
<div>
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
{recentRuns.length > 0 ? 'Or start fresh' : 'Get started'}
</div>
<div className="flex flex-col gap-2">
{SUGGESTED_ACTIONS.map((action) => (
<button
key={action.title}
type="button"
onClick={() => onPickPrompt(action.prompt)}
className="flex items-start gap-2.5 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors hover:bg-accent"
>
<action.icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-[12.8px] font-medium">{action.title}</div>
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{action.sub}</div>
</div>
</button>
))}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,114 @@
import { ArrowUpRight, ChevronDown, MessageSquare, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { formatRelativeTime } from '@/lib/relative-time'
export interface ChatHeaderRecentRun {
id: string
title?: string
createdAt: string
}
export interface ChatHeaderProps {
activeTitle: string
onNewChatTab: () => void
recentRuns?: ChatHeaderRecentRun[]
activeRunId?: string | null
onSelectRun?: (runId: string) => void
onOpenChatHistory?: () => void
}
/**
* Header controls for the copilot/chat surface: the active-chat title with a
* recent-chats history dropdown, plus the new-chat button. Rendered identically
* whether the chat lives in the side pane (ChatSidebar) or full screen (App
* content header). There is a single chat conversation at a time switching
* between chats happens through the history dropdown.
*/
export function ChatHeader({
activeTitle,
onNewChatTab,
recentRuns = [],
activeRunId,
onSelectRun,
onOpenChatHistory,
}: ChatHeaderProps) {
const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory)
return (
<>
{hasHistory ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-2 rounded-md px-3 text-sm font-medium text-foreground outline-none hover:bg-accent/60"
aria-label="Chat history"
>
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{activeTitle}</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{recentRuns.length > 0 && (
<DropdownMenuLabel className="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
Recent
</DropdownMenuLabel>
)}
{recentRuns.slice(0, 6).map((run) => (
<DropdownMenuItem
key={run.id}
onClick={() => onSelectRun?.(run.id)}
className={cn('gap-2', activeRunId === run.id && 'bg-accent')}
>
<span className="min-w-0 flex-1 truncate">{run.title || '(Untitled chat)'}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatRelativeTime(run.createdAt)}
</span>
</DropdownMenuItem>
))}
{onOpenChatHistory && (
<>
{recentRuns.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onOpenChatHistory} className="gap-2 text-primary">
<ArrowUpRight className="size-4" />
View all chats
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 text-sm font-medium text-foreground">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{activeTitle}</span>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onNewChatTab}
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="New chat"
>
<Plus className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
</>
)
}

View file

@ -0,0 +1,177 @@
import { useCallback, useMemo, useState } from 'react'
import { ExternalLink, MessageSquare, SearchIcon, SquarePen, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { formatRelativeTime } from '@/lib/relative-time'
type Run = {
id: string
title?: string
createdAt: string
agentId: string
}
type ChatHistoryViewProps = {
runs: Run[]
currentRunId?: string | null
processingRunIds?: Set<string>
onSelectRun: (runId: string) => void
onOpenInNewTab?: (runId: string) => void
onDeleteRun: (runId: string) => Promise<void> | void
onNewChat?: () => void
onOpenSearch?: () => void
}
export function ChatHistoryView({
runs,
currentRunId,
processingRunIds,
onSelectRun,
onOpenInNewTab,
onDeleteRun,
onNewChat,
onOpenSearch,
}: ChatHistoryViewProps) {
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const sortedRuns = useMemo(() => {
return [...runs].sort((a, b) => {
const at = new Date(a.createdAt).getTime()
const bt = new Date(b.createdAt).getTime()
return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at)
})
}, [runs])
const handleConfirmDelete = useCallback(async () => {
if (!pendingDeleteId) return
const id = pendingDeleteId
setPendingDeleteId(null)
await onDeleteRun(id)
}, [pendingDeleteId, onDeleteRun])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
<h1 className="text-2xl font-bold tracking-tight">Chat history</h1>
<div className="flex items-center gap-2">
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<SearchIcon className="size-4" />
<span>Search</span>
</button>
)}
{onNewChat && (
<Button size="sm" onClick={onNewChat}>
<SquarePen className="size-4" />
New chat
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Title</div>
<div className="w-32 shrink-0">Created</div>
</div>
{sortedRuns.length === 0 ? (
<div className="px-6 py-8 text-sm text-muted-foreground">No chats yet.</div>
) : (
sortedRuns.map((run) => {
const isActive = currentRunId === run.id
const isProcessing = processingRunIds?.has(run.id)
return (
<ContextMenu key={run.id}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={(e) => {
if (e.metaKey && onOpenInNewTab) {
onOpenInNewTab(run.id)
} else {
onSelectRun(run.id)
}
}}
className={[
'flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent',
isActive ? 'bg-accent/60' : '',
].join(' ')}
>
<div className="flex flex-1 items-center gap-2 min-w-0">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{run.title || '(Untitled chat)'}</span>
</div>
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.createdAt)}
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => onOpenInNewTab(run.id)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isProcessing && (
<ContextMenuItem
variant="destructive"
onClick={() => setPendingDeleteId(run.id)}
>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
})
)}
</div>
</div>
<Dialog open={!!pendingDeleteId} onOpenChange={(open) => { if (!open) setPendingDeleteId(null) }}>
<DialogContent showCloseButton={false} className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete chat</DialogTitle>
<DialogDescription>
Are you sure you want to delete this chat?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDeleteId(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDelete()}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -274,9 +274,21 @@ function ChatInputInner({
const handleSetWorkDir = useCallback(async () => {
try {
let defaultPath: string | undefined = workDir ?? undefined
try {
const { root } = await window.ipc.invoke('workspace:getRoot', null)
const workspaceRel = 'knowledge/Workspace'
const exists = await window.ipc.invoke('workspace:exists', { path: workspaceRel })
if (!exists.exists) {
await window.ipc.invoke('workspace:mkdir', { path: workspaceRel, recursive: true })
}
defaultPath = `${root.replace(/\/$/, '')}/${workspaceRel}`
} catch (err) {
console.error('Failed to resolve Workspace path; falling back to current workDir', err)
}
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
title: 'Choose work directory',
defaultPath: workDir ?? undefined,
defaultPath,
})
if (!chosen) return
await window.ipc.invoke('workspace:writeFile', {

View file

@ -1,13 +1,14 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
import { ArrowRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ChatHeader } from '@/components/chat-header'
import { ChatEmptyState } from '@/components/chat-empty-state'
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import {
@ -22,13 +23,12 @@ 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 { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { defaultRemarkPlugins } from 'streamdown'
import remarkBreaks from 'remark-breaks'
import { TabBar, type ChatTab } from '@/components/tab-bar'
import { type ChatTab } from '@/components/tab-bar'
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { useSidebar } from '@/components/ui/sidebar'
@ -52,6 +52,7 @@ import {
parseAttachedFiles,
toToolState,
} from '@/lib/chat-conversation'
import { matchBillingError } from '@/lib/billing-error'
const streamdownComponents = { pre: MarkdownPreOverride }
@ -85,60 +86,6 @@ function AutoScrollPre({ className, children }: { className?: string; children:
)
}
/* ─── Billing error helpers ─── */
const BILLING_ERROR_PATTERNS = [
{
pattern: /upgrade required/i,
title: 'A subscription is required',
subtitle: 'Get started with a plan to access AI features in Rowboat.',
cta: 'Subscribe',
},
{
pattern: /not enough credits/i,
title: 'You\'ve run out of credits',
subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.',
cta: 'Upgrade plan',
},
{
pattern: /subscription not active/i,
title: 'Your subscription is inactive',
subtitle: 'Reactivate your subscription to continue using AI features.',
cta: 'Reactivate',
},
] as const
function matchBillingError(message: string) {
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
}
interface BillingRowboatAccount {
config?: {
appUrl?: string | null
} | null
}
function BillingErrorCTA({ label }: { label: string }) {
const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => {
window.ipc.invoke('account:getRowboat', null)
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}, [])
if (!appUrl) return null
return (
<button
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
>
{label}
</button>
)
}
const MIN_WIDTH = 360
const MAX_WIDTH = 1600
const MIN_MAIN_PANE_WIDTH = 420
@ -173,11 +120,12 @@ interface ChatSidebarProps {
chatTabs: ChatTab[]
activeChatTabId: string
getChatTabTitle: (tab: ChatTab) => string
isChatTabProcessing: (tab: ChatTab) => boolean
onSwitchChatTab: (tabId: string) => void
onCloseChatTab: (tabId: string) => void
onNewChatTab: () => void
recentRuns?: { id: string; title?: string; createdAt: string }[]
onSelectRun?: (runId: string) => void
onOpenChatHistory?: () => void
onOpenFullScreen?: () => void
onCloseChat?: () => void
conversation: ConversationItem[]
currentAssistantMessage: string
chatTabStates?: Record<string, ChatTabViewState>
@ -228,11 +176,12 @@ export function ChatSidebar({
chatTabs,
activeChatTabId,
getChatTabTitle,
isChatTabProcessing,
onSwitchChatTab,
onCloseChatTab,
onNewChatTab,
recentRuns = [],
onSelectRun,
onOpenChatHistory,
onOpenFullScreen,
onCloseChat,
conversation,
currentAssistantMessage,
chatTabStates = {},
@ -380,7 +329,6 @@ export function ChatSidebar({
if (tabId === activeChatTabId) return activeTabState
return chatTabStates[tabId] ?? emptyTabState
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
@ -491,19 +439,8 @@ export function ChatSidebar({
}
if (isErrorMessage(item)) {
const billingError = matchBillingError(item.message)
if (billingError) {
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
<div className="space-y-2">
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
<BillingErrorCTA label={billingError.cta} />
</div>
</MessageContent>
</Message>
)
if (matchBillingError(item.message)) {
return null
}
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>
@ -563,45 +500,51 @@ export function ChatSidebar({
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
}}
>
<TabBar
tabs={chatTabs}
activeTabId={activeChatTabId}
getTabTitle={getChatTabTitle}
getTabId={(tab) => tab.id}
isProcessing={isChatTabProcessing}
onSwitchTab={onSwitchChatTab}
onCloseTab={onCloseChatTab}
<ChatHeader
activeTitle={(() => {
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
})()}
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onNewChatTab}
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
>
<SquarePen className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
{onOpenFullScreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onOpenFullScreen}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
>
{isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
</TooltipContent>
</Tooltip>
{isMaximized ? (
onOpenFullScreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onOpenFullScreen}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Dock chat to side pane"
>
<ArrowRight className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Dock to side pane</TooltipContent>
</Tooltip>
)
) : (
onCloseChat && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onCloseChat}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Close chat"
>
<X className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close chat</TooltipContent>
</Tooltip>
)
)}
</header>
@ -629,11 +572,19 @@ export function ChatSidebar({
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
className="relative flex-1"
>
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
<ConversationContent className={cn(
'mx-auto w-full max-w-4xl px-3',
tabHasConversation ? 'pb-28' : 'pb-0',
!tabHasConversation && isMaximized && 'min-h-full items-center justify-center',
)}>
{!tabHasConversation ? (
<ConversationEmptyState className="h-auto">
<div className="text-sm text-muted-foreground">Ask anything...</div>
</ConversationEmptyState>
<ChatEmptyState
wide={isMaximized}
recentRuns={recentRuns}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
onPickPrompt={setLocalPresetMessage}
/>
) : (
<>
{groupConversationItems(
@ -711,9 +662,6 @@ export function ChatSidebar({
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
<div className="mx-auto w-full max-w-4xl px-3">
{!hasConversation && (
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
)}
{chatTabs.map((tab) => {
const isActive = tab.id === activeChatTabId
const tabState = getTabState(tab.id)

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react'
import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -8,6 +8,7 @@ import type { blocks } from '@x/shared'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { useTheme } from '@/contexts/theme-context'
import { SettingsDialog } from '@/components/settings-dialog'
type GmailThread = blocks.GmailThread
type GmailThreadMessage = blocks.GmailThreadMessage
@ -817,15 +818,52 @@ function clearLoadingFlag(state: SectionState | null): SectionState {
return { ...state, loadingPage: false }
}
export function EmailView() {
export type EmailViewProps = {
/** If provided, the view opens with this thread already expanded. */
initialThreadId?: string | null
/** Bump to re-focus on the same threadId after navigating away inside the view. */
threadIdVersion?: number
}
export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {}) {
const [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant))
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>([])
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(initialThreadId ?? null)
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>(initialThreadId ? [initialThreadId] : [])
useEffect(() => {
setSelectedThreadId(initialThreadId ?? null)
if (initialThreadId) {
setOpenedThreadIds((prev) => {
const without = prev.filter((id) => id !== initialThreadId)
return [...without, initialThreadId].slice(-MAX_KEPT_OPEN)
})
}
}, [initialThreadId, threadIdVersion])
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
// Gmail sync uses the native Google OAuth connection.
const [emailConnected, setEmailConnected] = useState<boolean | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
useEffect(() => {
let cancelled = false
const check = async () => {
try {
const oauthState = await window.ipc.invoke('oauth:getState', null)
if (!cancelled) setEmailConnected(oauthState.config?.google?.connected ?? false)
} catch {
if (!cancelled) setEmailConnected(false)
}
}
void check()
const cleanupOAuthConnect = window.ipc.on('oauth:didConnect', () => { void check() })
return () => {
cancelled = true
cleanupOAuthConnect()
}
}, [])
useEffect(() => { persistedImportant = important }, [important])
useEffect(() => { persistedOther = other }, [other])
@ -1185,12 +1223,26 @@ export function EmailView() {
</section>
)}
</div>
) : emailConnected === false ? (
<div className="gmail-empty-state flex flex-col items-center gap-3 py-16 text-center">
<Mail size={28} className="opacity-50" />
<p>Connect your email to see your inbox here.</p>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
<Mail size={15} />
Connect your email
</button>
</div>
) : (
<div className="gmail-empty-state">
{initialLoading ? 'Loading Gmail threads…' : 'No Gmail threads in your inbox cache yet.'}
</div>
)}
</div>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
</div>
)
}

View file

@ -1,119 +0,0 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { Bug, MessageCircle } from "lucide-react"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"
interface HelpPopoverProps {
children: React.ReactNode
tooltip?: string
}
export function HelpPopover({ children, tooltip }: HelpPopoverProps) {
const [open, setOpen] = useState(false)
const handleDiscordClick = () => {
window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")
}
const handleReportBugClick = () => {
window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")
}
return (
<Popover open={open} onOpenChange={setOpen}>
{tooltip ? (
<Tooltip open={open ? false : undefined}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
)}
<PopoverContent
side="right"
align="end"
sideOffset={4}
className="w-80 p-0"
>
<div className="p-4 border-b">
<h4 className="font-semibold text-sm">Help & Support</h4>
<p className="text-xs text-muted-foreground mt-1">
Get help from our community
</p>
</div>
<div className="p-2">
<Button
variant="ghost"
className="w-full justify-start gap-3 h-auto py-3"
onClick={handleReportBugClick}
>
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
<Bug className="size-4 text-destructive" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Report a bug</span>
<span className="text-xs text-muted-foreground">
Send feedback to the Rowboat team
</span>
</div>
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-3 h-auto py-3"
onClick={handleDiscordClick}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
<MessageCircle className="size-4 text-white" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Join our Discord</span>
<span className="text-xs text-muted-foreground">
Chat with the community
</span>
</div>
</Button>
</div>
<div className="px-4 py-3 border-t flex justify-center gap-3 text-xs text-muted-foreground">
<a
href="https://www.rowboatlabs.com/terms-of-service"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Terms of Service
</a>
<span>·</span>
<a
href="https://www.rowboatlabs.com/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</a>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,470 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plus, Video } from 'lucide-react'
import { extractConferenceLink } from '@/lib/calendar-event'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
stat?: { size: number; mtimeMs: number }
}
type RunItem = { id: string; title?: string; createdAt: string }
type TaskItem = { slug: string; name: string; active: boolean; lastRunAt?: string; lastAttemptAt?: string }
type HomeViewProps = {
tree: TreeNode[]
runs: RunItem[]
bgTaskSummaries: TaskItem[]
onOpenEmail: () => void
onOpenMeetings: () => void
onOpenAgents: () => void
onOpenAgent: (slug: string) => void
onOpenNote: (path: string) => void
onOpenRun: (runId: string) => void
onTakeMeetingNotes: () => void
onOpenChat?: () => void
}
type CalEvent = {
id: string
summary: string
start: Date
end: Date | null
isAllDay: boolean
conferenceLink: string | null
rawStart: { dateTime?: string; date?: string } | undefined
rawEnd: { dateTime?: string; date?: string } | undefined
location: string | null
htmlLink: string | null
source: string
}
type RawCalEvent = {
id?: string
summary?: string
start?: { dateTime?: string; date?: string }
end?: { dateTime?: string; date?: string }
location?: string
htmlLink?: string
status?: string
attendees?: Array<{ self?: boolean; responseStatus?: string }>
}
type EmailThread = { threadId: string; subject: string; from: string }
function greeting(): string {
const h = new Date().getHours()
if (h < 12) return 'Good morning'
if (h < 18) return 'Good afternoon'
return 'Good evening'
}
function todayLabel(): string {
return new Date().toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' })
}
function timeOfDay(d: Date): string {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function relativeFromNow(start: Date): string {
const ms = start.getTime() - Date.now()
if (ms <= 0) return 'now'
const min = Math.round(ms / 60000)
if (min < 60) return `in ${min}m`
const hr = Math.round(min / 60)
if (hr < 24) return `in ${hr}h`
return start.toLocaleDateString([], { weekday: 'short' })
}
function relativeAgo(iso?: string): string {
if (!iso) return ''
const t = new Date(iso).getTime()
if (Number.isNaN(t)) return ''
const min = Math.round((Date.now() - t) / 60000)
if (min < 1) return 'just now'
if (min < 60) return `${min}m ago`
const hr = Math.round(min / 60)
if (hr < 24) return `${hr}h ago`
const d = Math.round(hr / 24)
return `${d}d ago`
}
function parseAllDay(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
if (!m) return null
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
}
function normalizeCalEvent(raw: RawCalEvent, sourcePath: string): CalEvent | null {
if (raw.status === 'cancelled') return null
const declined = raw.attendees?.find((a) => a.self)?.responseStatus === 'declined'
if (declined) return null
const timed = raw.start?.dateTime
const allDay = raw.start?.date
const isAllDay = !timed && Boolean(allDay)
let start: Date | null = null
let end: Date | null = null
if (timed) {
start = new Date(timed)
end = raw.end?.dateTime ? new Date(raw.end.dateTime) : null
} else if (allDay) {
start = parseAllDay(allDay)
end = raw.end?.date ? parseAllDay(raw.end.date) : null
}
if (!start || Number.isNaN(start.getTime())) return null
return {
id: raw.id ?? sourcePath,
summary: raw.summary?.trim() || '(No title)',
start,
end,
isAllDay,
conferenceLink: extractConferenceLink(raw as unknown as Record<string, unknown>) ?? null,
rawStart: raw.start,
rawEnd: raw.end,
location: raw.location?.trim() || null,
htmlLink: raw.htmlLink ?? null,
source: sourcePath,
}
}
function noteLabel(node: TreeNode): string {
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) return node.name.slice(0, -3)
return node.name
}
function triggerMeetingCapture(event: CalEvent, openConference: boolean) {
window.__pendingCalendarEvent = {
summary: event.summary,
start: event.rawStart,
end: event.rawEnd,
location: event.location ?? undefined,
htmlLink: event.htmlLink ?? undefined,
conferenceLink: event.conferenceLink ?? undefined,
source: event.source,
}
if (openConference && event.conferenceLink) {
window.open(event.conferenceLink, '_blank')
}
window.dispatchEvent(new Event('calendar-block:join-meeting'))
}
const CARD = 'rounded-xl border border-border bg-card p-4'
export function HomeView({
tree,
runs,
bgTaskSummaries,
onOpenEmail,
onOpenMeetings,
onOpenAgents,
onOpenAgent,
onOpenNote,
onOpenRun,
onTakeMeetingNotes,
onOpenChat,
}: HomeViewProps) {
const [events, setEvents] = useState<CalEvent[]>([])
const [emails, setEmails] = useState<EmailThread[]>([])
const loadEvents = useCallback(async () => {
try {
const exists = await window.ipc.invoke('workspace:exists', { path: 'calendar_sync' })
if (!exists.exists) { setEvents([]); return }
const entries = await window.ipc.invoke('workspace:readdir', {
path: 'calendar_sync',
opts: { recursive: false, includeHidden: false, includeStats: false },
})
const jsonEntries = entries.filter((e) => e.kind === 'file' && e.name.endsWith('.json'))
const settled = await Promise.allSettled(
jsonEntries.map(async (entry): Promise<CalEvent | null> => {
const result = await window.ipc.invoke('workspace:readFile', { path: entry.path, encoding: 'utf8' })
return normalizeCalEvent(JSON.parse(result.data) as RawCalEvent, entry.path)
}),
)
const out: CalEvent[] = []
for (const r of settled) if (r.status === 'fulfilled' && r.value) out.push(r.value)
out.sort((a, b) => a.start.getTime() - b.start.getTime())
setEvents(out)
} catch (err) {
console.error('Home: failed to load events', err)
}
}, [])
const loadEmails = useCallback(async () => {
try {
const result = await window.ipc.invoke('gmail:getImportant', { limit: 25 })
setEmails(
result.threads
.filter((t) => t.unread === true)
.slice(0, 3)
.map((t) => ({ threadId: t.threadId, subject: t.subject ?? '(No subject)', from: t.from ?? '' })),
)
} catch (err) {
console.error('Home: failed to load emails', err)
}
}, [])
useEffect(() => { void loadEvents(); void loadEmails() }, [loadEvents, loadEmails])
// Upcoming (not-yet-ended) events, soonest first.
const upcoming = useMemo(() => {
const now = Date.now()
return events.filter((e) => {
const end = e.end ?? (e.isAllDay ? new Date(e.start.getTime() + 864e5) : e.start)
return end.getTime() > now
})
}, [events])
const nextEvent = upcoming[0]
const todaysEvents = useMemo(() => {
const now = new Date()
return upcoming.filter((e) =>
e.start.getFullYear() === now.getFullYear() &&
e.start.getMonth() === now.getMonth() &&
e.start.getDate() === now.getDate(),
)
}, [upcoming])
const activeAgents = useMemo(() => bgTaskSummaries.filter((t) => t.active), [bgTaskSummaries])
const recentAgent = useMemo(() => {
const t = (s?: string) => (s ? new Date(s).getTime() || 0 : 0)
return [...bgTaskSummaries].sort((a, b) =>
Math.max(t(b.lastRunAt), t(b.lastAttemptAt)) - Math.max(t(a.lastRunAt), t(a.lastAttemptAt)),
)[0]
}, [bgTaskSummaries])
const recentNotes = useMemo<TreeNode[]>(() => {
const out: TreeNode[] = []
const walk = (nodes: TreeNode[]) => {
for (const n of nodes) {
if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue
if (n.kind === 'file') out.push(n)
else if (n.children?.length) walk(n.children)
}
}
walk(tree)
return out
.filter((n) => n.stat?.mtimeMs)
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
.slice(0, 2)
}, [tree])
const recentActivity = useMemo(() => {
const items: Array<{ key: string; icon: 'note' | 'chat'; label: string; kind: string; when: number; open: () => void }> = []
for (const n of recentNotes) {
items.push({ key: `n:${n.path}`, icon: 'note', label: noteLabel(n), kind: 'note', when: n.stat?.mtimeMs ?? 0, open: () => onOpenNote(n.path) })
}
for (const r of runs.slice(0, 4)) {
items.push({ key: `r:${r.id}`, icon: 'chat', label: r.title || '(Untitled chat)', kind: 'chat', when: new Date(r.createdAt).getTime() || 0, open: () => onOpenRun(r.id) })
}
return items.sort((a, b) => b.when - a.when).slice(0, 4)
}, [recentNotes, runs, onOpenNote, onOpenRun])
return (
<div className="flex h-full flex-col overflow-hidden bg-muted/30">
<div className="flex-1 overflow-y-auto px-9 py-7">
<div className="mx-auto flex max-w-[760px] flex-col gap-[18px]">
{/* Greeting */}
<div className="flex items-baseline gap-3">
<h1 className="text-[28px] font-semibold tracking-tight">{greeting()}</h1>
<span className="text-sm text-muted-foreground">{todayLabel()}</span>
</div>
{/* Up-next hero */}
{nextEvent && (
<div className="flex items-center gap-[18px] rounded-xl bg-foreground p-[18px] text-background">
<div className="flex size-[52px] shrink-0 items-center justify-center rounded-xl bg-background/10">
<Mic className="size-[22px]" />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 text-[11px] uppercase tracking-wide text-background/55">
Up next · {nextEvent.isAllDay ? 'today' : relativeFromNow(nextEvent.start)}
</div>
<div className="mb-0.5 truncate text-[17px] font-medium">{nextEvent.summary}</div>
<div className="truncate text-[13px] text-background/70">
{nextEvent.isAllDay ? 'All day' : `${timeOfDay(nextEvent.start)}${nextEvent.end ? ` ${timeOfDay(nextEvent.end)}` : ''}`}
{nextEvent.location ? ` · ${nextEvent.location}` : ''}
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={onTakeMeetingNotes}
className="rounded-md bg-background px-3.5 py-2 text-[13px] font-medium text-foreground"
>
Take notes
</button>
{nextEvent.conferenceLink && (
<button
type="button"
onClick={() => window.open(nextEvent.conferenceLink!, '_blank')}
className="rounded-md border border-background/20 px-3 py-2 text-background"
aria-label="Join meeting"
>
<Video className="size-[13px]" />
</button>
)}
</div>
</div>
)}
{/* Inbox + Background agents */}
<div className="grid grid-cols-2 gap-[18px]">
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<Mail className="size-[15px]" />
<span className="text-sm font-medium">Inbox</span>
{emails.length > 0 && (
<span className="rounded-lg bg-destructive px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-wide text-white">
{emails.length} new
</span>
)}
<span className="flex-1" />
<button type="button" onClick={onOpenEmail} className="text-xs text-primary hover:underline">Open </button>
</div>
{emails.length === 0 ? (
<div className="py-1 text-[12.5px] text-muted-foreground">No unread important email.</div>
) : emails.map((e, i) => (
<button
key={e.threadId}
type="button"
onClick={onOpenEmail}
className={`flex w-full gap-2.5 py-[7px] text-left text-[12.5px] ${i ? 'border-t border-border' : ''}`}
>
<span className="w-[92px] shrink-0 truncate text-muted-foreground">{formatFrom(e.from)}</span>
<span className="flex-1 truncate">{e.subject}</span>
</button>
))}
</div>
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<Bot className="size-[15px]" />
<span className="text-sm font-medium">Background agents</span>
<span className="flex-1" />
<span className="text-xs text-muted-foreground">{activeAgents.length} active</span>
<button type="button" onClick={onOpenAgents} className="text-xs text-primary hover:underline">Open </button>
</div>
{recentAgent ? (
<button
type="button"
onClick={() => onOpenAgent(recentAgent.slug)}
className="flex w-full items-center gap-2.5 py-[7px] text-left text-[13px]"
>
<span className={`size-2 shrink-0 rounded-full ${recentAgent.active ? 'bg-emerald-500' : 'bg-muted-foreground'}`} />
<span className="flex-1 truncate font-medium">{recentAgent.name}</span>
<span className="text-[11.5px] text-muted-foreground">{relativeAgo(recentAgent.lastRunAt) || '—'}</span>
</button>
) : (
<div className="py-1 text-[12.5px] text-muted-foreground">No agents yet.</div>
)}
<button
type="button"
onClick={onOpenAgents}
className="mt-3.5 flex items-center gap-2 border-t border-border pt-3 text-[12.5px] text-primary"
>
<Plus className="size-3" />
Create an agent
</button>
</div>
</div>
{/* Today's schedule */}
<div className={CARD}>
<div className="mb-3.5 flex items-center gap-2">
<Calendar className="size-[14px]" />
<span className="text-sm font-medium">Today's schedule</span>
<span className="flex-1" />
<button type="button" onClick={onOpenMeetings} className="text-xs text-primary hover:underline">All meetings </button>
</div>
{todaysEvents.length === 0 ? (
<div className="py-1 text-[13px] italic text-muted-foreground">No more events today.</div>
) : todaysEvents.map((e, i) => (
<div key={e.id} className={`group flex items-center gap-3.5 py-2 text-[13px] ${i ? 'border-t border-border' : ''}`}>
<span className="w-[90px] shrink-0 font-mono text-[11.5px] text-muted-foreground">
{e.isAllDay ? 'All day' : `${timeOfDay(e.start)}${e.end ? ` ${timeOfDay(e.end)}` : ''}`}
</span>
<span className={`size-2 shrink-0 rounded-full ${i === 0 ? 'bg-emerald-500' : 'bg-border'}`} />
<span className="min-w-0 flex-1 truncate font-medium">{e.summary}</span>
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={() => triggerMeetingCapture(e, false)}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-foreground transition-colors hover:bg-accent"
>
<Mic className="size-3" />
Take notes
</button>
{e.conferenceLink && (
<button
type="button"
onClick={() => triggerMeetingCapture(e, true)}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-foreground transition-colors hover:bg-accent"
>
<Video className="size-3" />
Join &amp; take notes
</button>
)}
</div>
</div>
))}
</div>
{/* Recent activity */}
{recentActivity.length > 0 && (
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<Clock className="size-[14px]" />
<span className="text-sm font-medium">Recent activity</span>
</div>
{recentActivity.map((a, i) => (
<button
key={a.key}
type="button"
onClick={a.open}
className={`flex w-full items-center gap-3 py-2 text-left text-[13px] ${i ? 'border-t border-border' : ''}`}
>
{a.icon === 'note' ? <FileText className="size-[13px] shrink-0 text-muted-foreground" /> : <MessageSquare className="size-[13px] shrink-0 text-muted-foreground" />}
<span className="flex-1 truncate">{a.label}</span>
<span className="w-[60px] text-right text-[11px] text-muted-foreground">{a.kind}</span>
</button>
))}
</div>
)}
{/* Open chat CTA */}
{onOpenChat && (
<button
type="button"
onClick={onOpenChat}
className="flex items-center gap-3.5 rounded-xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground">
<MessageSquare className="size-[15px]" />
</div>
<div className="min-w-0 flex-1 text-[13.5px] leading-snug">
<span className="font-medium">Ask anything</span>
<span className="text-muted-foreground"> create presentations, do research, collaborate on docs.</span>
</div>
<span className="flex shrink-0 items-center gap-1 text-[12.5px] font-medium text-primary">
New chat
<ArrowRight className="size-3.5" />
</span>
</button>
)}
</div>
</div>
</div>
)
}
function formatFrom(from: string): string {
const m = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from)
return (m ? m[1] : from).trim()
}

View file

@ -1,33 +1,11 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
const MAX_SIZE_BYTES = 5 * 1024 * 1024
const CACHE_MAX_ENTRIES = 20
type CacheEntry = { html: string; mtimeMs: number; size: number }
const htmlCache = new Map<string, CacheEntry>()
function getCached(path: string, mtimeMs: number, size: number): string | null {
const entry = htmlCache.get(path)
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) return null
// Refresh LRU position
htmlCache.delete(path)
htmlCache.set(path, entry)
return entry.html
}
function setCached(path: string, html: string, mtimeMs: number, size: number) {
htmlCache.set(path, { html, mtimeMs, size })
while (htmlCache.size > CACHE_MAX_ENTRIES) {
const oldest = htmlCache.keys().next().value
if (oldest === undefined) break
htmlCache.delete(oldest)
}
}
type ViewerState =
| { kind: 'loading' }
| { kind: 'loaded'; html: string }
| { kind: 'loaded' }
| { kind: 'empty' }
| { kind: 'tooLarge'; sizeMB: number }
| { kind: 'error'; message: string }
@ -36,9 +14,15 @@ interface HtmlFileViewerProps {
path: string
}
function toAppWorkspaceUrl(path: string): string {
const segments = path.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg))
return `app://workspace/${segments.join('/')}`
}
export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
const [iframeLoaded, setIframeLoaded] = useState(false)
const iframeSrc = useMemo(() => toAppWorkspaceUrl(path), [path])
useEffect(() => {
let cancelled = false
@ -57,19 +41,11 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
return
}
const cachedHtml = getCached(path, stat.mtimeMs, stat.size)
if (cachedHtml !== null) {
setState(cachedHtml.trim() === '' ? { kind: 'empty' } : { kind: 'loaded', html: cachedHtml })
return
}
const result = await window.ipc.invoke('workspace:readFile', { path })
if (cancelled) return
setCached(path, result.data, stat.mtimeMs, stat.size)
if (!result.data || result.data.trim() === '') {
if (stat.size === 0) {
setState({ kind: 'empty' })
return
}
setState({ kind: 'loaded', html: result.data })
setState({ kind: 'loaded' })
} catch (err) {
if (cancelled) return
const message = err instanceof Error ? err.message : String(err)
@ -124,20 +100,16 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
)
}
// We use `srcDoc` here (not `src=app://workspace/<path>`) so the iframe
// gets a null origin with no base URL. Trade-off: relative assets inside
// the file — `<link href="./style.css">`, `<img src="./pic.png">`,
// `<script src="./foo.js">` — will silently 404. Self-contained HTML
// works fine; HTML that ships next to sibling assets will look broken.
// TODO: switch to `src=app://workspace/<path>` if we want relative-asset
// support; that path also resolves through the existing path-traversal
// guard in resolveWorkspacePath.
// Serve via the `app://workspace/<rel-path>` protocol so the iframe has a
// proper base URL — relative `<link>`, `<img>`, `<script>` references next
// to the file resolve correctly (the path-traversal guard in
// resolveWorkspacePath already gates the protocol handler).
return (
<div className="relative h-full w-full">
{state.kind === 'loaded' && (
<iframe
key={path}
srcDoc={state.html}
src={iframeSrc}
sandbox="allow-scripts"
className="h-full w-full border-0 bg-white"
title="HTML preview"

View file

@ -0,0 +1,412 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronRight,
Copy,
ExternalLink,
File as FileIcon,
FilePlus,
Folder as FolderIcon,
FolderOpen,
FolderPlus,
Network,
Pencil,
SearchIcon,
Table2,
Trash2,
} from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Input } from '@/components/ui/input'
import { VoiceNoteButton } from '@/components/sidebar-content'
import { formatRelativeTime } from '@/lib/relative-time'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
stat?: { size: number; mtimeMs: number }
}
export type KnowledgeViewActions = {
createNote: (parentPath?: string) => void
createFolder: (parentPath?: string) => Promise<string>
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
remove: (path: string) => Promise<void>
copyPath: (path: string) => void
revealInFileManager: (path: string, isDir: boolean) => void
onOpenInNewTab?: (path: string) => void
}
type KnowledgeViewProps = {
tree: TreeNode[]
actions: KnowledgeViewActions
onOpenNote: (path: string) => void
onOpenGraph: () => void
onOpenSearch: () => void
onOpenBases: () => void
onVoiceNoteCreated?: (path: string) => void
}
type FlatRow = {
node: TreeNode
depth: number
}
function sortNodes(nodes: TreeNode[]): TreeNode[] {
return [...nodes].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
}
function flatten(
nodes: TreeNode[],
expanded: Set<string>,
depth: number,
out: FlatRow[],
): void {
for (const node of sortNodes(nodes)) {
out.push({ node, depth })
if (node.kind === 'dir' && expanded.has(node.path) && node.children?.length) {
flatten(node.children, expanded, depth + 1, out)
}
}
}
function formatModified(mtimeMs?: number): string {
if (!mtimeMs) return ''
return formatRelativeTime(new Date(mtimeMs).toISOString())
}
function getFileManagerName(): string {
if (typeof navigator === 'undefined') return 'File Manager'
const platform = navigator.platform.toLowerCase()
if (platform.includes('mac')) return 'Finder'
if (platform.includes('win')) return 'Explorer'
return 'File Manager'
}
function displayName(node: TreeNode): string {
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
return node.name.slice(0, -3)
}
return node.name
}
const INDENT_PX = 16
const ROW_PADDING_PX = 12
export function KnowledgeView({
tree,
actions,
onOpenNote,
onOpenGraph,
onOpenSearch,
onOpenBases,
onVoiceNoteCreated,
}: KnowledgeViewProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [renameTarget, setRenameTarget] = useState<string | null>(null)
const rows = useMemo<FlatRow[]>(() => {
const out: FlatRow[] = []
// Meetings and Workspace have dedicated destinations, so hide them here.
const visible = tree.filter((n) => n.path !== 'knowledge/Meetings' && n.path !== 'knowledge/Workspace')
flatten(visible, expanded, 0, out)
return out
}, [tree, expanded])
const handleRowClick = useCallback(
(node: TreeNode) => {
if (node.kind === 'dir') {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(node.path)) next.delete(node.path)
else next.add(node.path)
return next
})
} else {
onOpenNote(node.path)
}
},
[onOpenNote],
)
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => actions.createNote()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<FilePlus className="size-4" />
<span>New note</span>
</button>
<button
type="button"
onClick={async () => {
try {
const path = await actions.createFolder()
setRenameTarget(path)
} catch { /* ignore */ }
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<FolderPlus className="size-4" />
<span>New folder</span>
</button>
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
<button
type="button"
onClick={onOpenSearch}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<SearchIcon className="size-4" />
<span>Search</span>
</button>
<button
type="button"
onClick={onOpenBases}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<Table2 className="size-4" />
<span>Bases</span>
</button>
<button
type="button"
onClick={onOpenGraph}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<Network className="size-4" />
<span>Graph view</span>
</button>
<button
type="button"
onClick={() => actions.revealInFileManager('knowledge', true)}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<FolderOpen className="size-4" />
<span>Open in {getFileManagerName()}</span>
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Page name</div>
<div className="w-32 shrink-0">Modified</div>
</div>
{rows.length === 0 ? (
<div className="px-6 py-8 text-sm text-muted-foreground">No pages yet.</div>
) : (
rows.map(({ node, depth }) => (
<KnowledgeRow
key={node.path}
node={node}
depth={depth}
isExpanded={expanded.has(node.path)}
actions={actions}
renameActive={renameTarget === node.path}
onRequestRename={(p) => setRenameTarget(p)}
onClearRename={() => setRenameTarget(null)}
onClick={handleRowClick}
/>
))
)}
</div>
</div>
</div>
)
}
function KnowledgeRow({
node,
depth,
isExpanded,
actions,
renameActive,
onRequestRename,
onClearRename,
onClick,
}: {
node: TreeNode
depth: number
isExpanded: boolean
actions: KnowledgeViewActions
renameActive: boolean
onRequestRename: (path: string) => void
onClearRename: () => void
onClick: (node: TreeNode) => void
}) {
const isDir = node.kind === 'dir'
const Icon = isDir ? FolderIcon : FileIcon
const paddingLeft = ROW_PADDING_PX + depth * INDENT_PX
const baseName = displayName(node)
const [newName, setNewName] = useState(baseName)
const inputRef = useRef<HTMLInputElement | null>(null)
const isSubmittingRef = useRef(false)
useEffect(() => {
if (renameActive) {
setNewName(baseName)
isSubmittingRef.current = false
// focus on next tick after mount
requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}
}, [renameActive, baseName])
const handleRenameSubmit = useCallback(async () => {
if (isSubmittingRef.current) return
isSubmittingRef.current = true
const trimmed = newName.trim()
if (trimmed && trimmed !== baseName) {
try {
await actions.rename(node.path, trimmed, isDir)
toast('Renamed successfully', 'success')
} catch {
toast('Failed to rename', 'error')
}
}
onClearRename()
setTimeout(() => {
isSubmittingRef.current = false
}, 100)
}, [actions, baseName, isDir, newName, node.path, onClearRename])
const cancelRename = useCallback(() => {
isSubmittingRef.current = true
setNewName(baseName)
onClearRename()
setTimeout(() => {
isSubmittingRef.current = false
}, 100)
}, [baseName, onClearRename])
const handleDelete = useCallback(async () => {
try {
await actions.remove(node.path)
toast('Moved to trash', 'success')
} catch {
toast('Failed to delete', 'error')
}
}, [actions, node.path])
const handleCopyPath = useCallback(() => {
actions.copyPath(node.path)
toast('Path copied', 'success')
}, [actions, node.path])
const row = (
<button
type="button"
onClick={() => onClick(node)}
className="group flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent"
>
<div className="flex flex-1 items-center gap-1.5 min-w-0" style={{ paddingLeft }}>
<span className="inline-flex w-4 shrink-0 items-center justify-center text-muted-foreground">
{isDir ? (
<ChevronRight
className={cn(
'size-3.5 transition-transform',
isExpanded && 'rotate-90',
)}
/>
) : null}
</span>
<Icon className="size-4 shrink-0 text-muted-foreground" />
{renameActive ? (
<Input
ref={inputRef}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
void handleRenameSubmit()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
}}
onBlur={() => {
if (!isSubmittingRef.current) void handleRenameSubmit()
}}
className="h-6 text-sm flex-1"
/>
) : (
<span className="min-w-0 truncate">{baseName}</span>
)}
</div>
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
{formatModified(node.stat?.mtimeMs)}
</div>
</button>
)
return (
<ContextMenu>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
{isDir && (
<>
<ContextMenuItem onClick={() => actions.createNote(node.path)}>
<FilePlus className="mr-2 size-4" />
New Note
</ContextMenuItem>
<ContextMenuItem onClick={() => void actions.createFolder(node.path)}>
<FolderPlus className="mr-2 size-4" />
New Folder
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isDir && actions.onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(node.path)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onClick={handleCopyPath}>
<Copy className="mr-2 size-4" />
Copy Path
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.revealInFileManager(node.path, isDir)}>
<FolderOpen className="mr-2 size-4" />
Open in {getFileManagerName()}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onRequestRename(node.path)}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
<ContextMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { SettingsDialog } from '@/components/settings-dialog'
import { formatRelativeTime } from '@/lib/relative-time'
import { extractConferenceLink } from '@/lib/calendar-event'
import { cn } from '@/lib/utils'
@ -189,6 +190,27 @@ function UpcomingEvents() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
// Calendar sync uses the native Google OAuth connection.
const [calendarConnected, setCalendarConnected] = useState<boolean | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
useEffect(() => {
let cancelled = false
const check = async () => {
try {
const oauthState = await window.ipc.invoke('oauth:getState', null)
if (!cancelled) setCalendarConnected(oauthState.config?.google?.connected ?? false)
} catch {
if (!cancelled) setCalendarConnected(false)
}
}
void check()
const cleanupOAuthConnect = window.ipc.on('oauth:didConnect', () => { void check() })
return () => {
cancelled = true
cleanupOAuthConnect()
}
}, [])
const loadEvents = useCallback(async () => {
setLoading(true)
@ -313,7 +335,20 @@ function UpcomingEvents() {
)}
</div>
{loading && events.length === 0 ? (
{calendarConnected === false && events.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<Calendar className="size-7 text-muted-foreground opacity-50" />
<p className="text-sm text-muted-foreground">Connect your calendar to see upcoming meetings here.</p>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
<Calendar className="size-4" />
Connect your calendar
</button>
</div>
) : loading && events.length === 0 ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
@ -335,6 +370,7 @@ function UpcomingEvents() {
</div>
)}
</div>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
</section>
)
}
@ -348,7 +384,7 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool
<div
className="grid"
style={{
gridTemplateColumns: '96px 1fr',
gridTemplateColumns: '96px minmax(0, 1fr)',
borderBottom: isLast ? undefined : '1px dashed var(--gm-border-strong)',
}}
>
@ -376,7 +412,7 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool
<span style={{ fontSize: 12, color: 'var(--gm-text-faint)' }}>{weekday}</span>
</span>
</div>
<div className="flex flex-col py-3 pr-3">
<div className="flex min-w-0 flex-col py-3 pr-3">
{day.events.length === 0 ? (
<div
className="flex w-full items-center gap-3 px-3 py-2 text-sm"

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-react"
import {
Dialog,
@ -11,6 +11,7 @@ import {
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Select,
SelectContent,
@ -25,7 +26,7 @@ import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "help"
interface TabConfig {
id: ConfigTab
@ -43,10 +44,10 @@ const tabs: TabConfig[] = [
description: "Manage your Rowboat account",
},
{
id: "connected-accounts",
label: "Connected Accounts",
id: "connections",
label: "Connections",
icon: Plug,
description: "Manage connected services",
description: "Manage accounts and tools",
},
{
id: "models",
@ -75,12 +76,6 @@ const tabs: TabConfig[] = [
icon: Palette,
description: "Customize the look and feel",
},
{
id: "tools",
label: "Tools Library",
icon: Wrench,
description: "Browse and enable toolkits",
},
{
id: "note-tagging",
label: "Note Tagging",
@ -88,10 +83,93 @@ const tabs: TabConfig[] = [
path: "config/tags.json",
description: "Configure tags for notes and emails",
},
{
id: "help",
label: "Help",
icon: HelpCircle,
description: "Get help and support",
},
]
interface SettingsDialogProps {
children: React.ReactNode
/** Optional trigger element. Omit when controlling `open` externally. */
children?: React.ReactNode
/** Tab to open on when the dialog is shown. Defaults to "account". */
defaultTab?: ConfigTab
/** Controlled open state. When provided, the dialog is fully controlled. */
open?: boolean
onOpenChange?: (open: boolean) => void
}
// --- Help & Support tab ---
function HelpSettings() {
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">Help &amp; Support</h4>
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
</div>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
<Bug className="size-4 text-destructive" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Report a bug</span>
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
<MessageCircle className="size-4 text-white" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Join our Discord</span>
<span className="text-xs text-muted-foreground">Chat with the community</span>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("mailto:contact@rowboatlabs.com", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mail className="size-4" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Contact us</span>
<span className="text-xs text-muted-foreground">contact@rowboatlabs.com</span>
</div>
</Button>
<div className="flex gap-3 text-xs text-muted-foreground">
<a
href="https://www.rowboatlabs.com/terms-of-service"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Terms of Service
</a>
<span>·</span>
<a
href="https://www.rowboatlabs.com/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</a>
</div>
</div>
)
}
// --- Theme option for Appearance tab ---
@ -1572,9 +1650,14 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
// --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ConfigTab>("account")
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
const [internalOpen, setInternalOpen] = useState(false)
const open = controlledOpen ?? internalOpen
const setOpen = useCallback((next: boolean) => {
if (onOpenChange) onOpenChange(next)
else setInternalOpen(next)
}, [onOpenChange])
const [activeTab, setActiveTab] = useState<ConfigTab>(defaultTab)
const [content, setContent] = useState("")
const [originalContent, setOriginalContent] = useState("")
const [loading, setLoading] = useState(false)
@ -1582,6 +1665,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const [error, setError] = useState<string | null>(null)
const [rowboatConnected, setRowboatConnected] = useState(false)
// Reset to the requested default tab each time the dialog is opened
useEffect(() => {
if (open) setActiveTab(defaultTab)
}, [open, defaultTab])
// Check if user is signed in to Rowboat
useEffect(() => {
if (!open) return
@ -1607,7 +1695,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@ -1673,7 +1761,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
<DialogContent
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
>
@ -1715,11 +1803,21 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connected-accounts" ? (
<ConnectedAccountsSettings dialogOpen={open} />
) : activeTab === "connections" ? (
<div className="space-y-6">
<div className="space-y-2">
<h4 className="text-sm font-semibold">Primary accounts</h4>
<ConnectedAccountsSettings dialogOpen={open} />
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">Library</h4>
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
</div>
</div>
) : activeTab === "models" ? (
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />
@ -1728,8 +1826,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : activeTab === "tools" ? (
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
) : activeTab === "help" ? (
<HelpSettings />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...

View file

@ -17,11 +17,44 @@ import {
import { Separator } from "@/components/ui/separator"
import { useBilling } from "@/hooks/useBilling"
import { toast } from "sonner"
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
interface AccountSettingsProps {
dialogOpen: boolean
}
function formatPlanName(plan: string | null | undefined) {
if (!plan) return 'No Plan'
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
}
function CreditUsageBar({ label, bucket, helper }: {
label: string
bucket: BillingUsageBucket
helper?: string
}) {
const pct = bucket.sanctionedCredits > 0
? Math.min(100, Math.max(0, Math.round((bucket.usedCredits / bucket.sanctionedCredits) * 100)))
: 0
return (
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
{helper ? <p className="text-[11px] text-muted-foreground">{helper}</p> : null}
</div>
<p className="shrink-0 text-xs font-medium tabular-nums">
{pct}%
</p>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
)
}
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [connectionLoading, setConnectionLoading] = useState(true)
@ -164,7 +197,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium capitalize">
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
{formatPlanName(billing.subscriptionPlan)}
</p>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
@ -179,14 +212,19 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
{!billing.subscriptionPlan && (
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
)}
{billing.subscriptionPlan === 'free' && (
<p className="text-xs text-muted-foreground">Free usage resets daily at 00:00 UTC.</p>
)}
</div>
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
</Button>
</div>
<div className="space-y-3 border-t pt-3">
<CreditUsageBar label="Plan usage" bucket={billing.monthly} />
<CreditUsageBar
label="Daily use"
bucket={billing.daily}
helper="Daily usage resets at 00:00 UTC"
/>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">Unable to load plan details</p>

View file

@ -26,10 +26,10 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
return (
<div
key={provider}
className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
{icon}
</div>
<div className="flex flex-col min-w-0">
@ -119,15 +119,15 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
{/* Email & Calendar Section */}
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
<>
<div className="px-4 py-2">
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Email & Calendar
</span>
</div>
{c.useComposioForGoogle ? (
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mail className="size-4" />
</div>
<div className="flex flex-col min-w-0">
@ -174,9 +174,9 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
)}
{c.useComposioForGoogleCalendar && (
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Calendar className="size-4" />
</div>
<div className="flex flex-col min-w-0">
@ -220,14 +220,14 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
</div>
</div>
)}
<Separator className="my-3" />
<Separator className="my-2" />
</>
)}
{/* Meeting Notes Section */}
{c.providers.includes('fireflies-ai') && (
<>
<div className="px-4 py-2">
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>

File diff suppressed because it is too large Load diff

View file

@ -380,7 +380,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"flex min-h-0 flex-1 flex-col gap-1 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
{...props}
/>
)
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)

View file

@ -0,0 +1,531 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronRight,
Copy,
File as FileIcon,
FilePlus,
Folder as FolderIcon,
FolderOpen,
FolderPlus,
Home,
Pencil,
Plus,
Trash2,
UploadCloud,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
const WORKSPACE_ROOT = 'knowledge/Workspace'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
}
type WorkspaceActions = {
remove: (path: string) => Promise<void>
copyPath: (path: string) => void
revealInFileManager: (path: string, isDir: boolean) => void
}
type WorkspaceViewProps = {
tree: TreeNode[]
initialPath?: string | null
actions: WorkspaceActions
onOpenNote: (path: string) => void
onCreateWorkspace: (name: string) => Promise<void>
}
function getFileManagerName(): string {
if (typeof navigator === 'undefined') return 'File Manager'
const platform = navigator.platform.toLowerCase()
if (platform.includes('mac')) return 'Finder'
if (platform.includes('win')) return 'Explorer'
return 'File Manager'
}
function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null {
if (!nodes) return null
for (const node of nodes) {
if (node.path === path) return node
if (node.kind === 'dir' && path.startsWith(`${node.path}/`)) {
const found = findNode(node.children, path)
if (found) return found
}
}
return null
}
function countChildren(node: TreeNode | null): number {
if (!node || node.kind !== 'dir' || !node.children) return 0
return node.children.length
}
async function uniqueChildPath(parent: string, name: string): Promise<string> {
const dot = name.lastIndexOf('.')
const base = dot > 0 ? name.slice(0, dot) : name
const ext = dot > 0 ? name.slice(dot) : ''
let candidate = `${parent}/${name}`
let i = 1
while ((await window.ipc.invoke('workspace:exists', { path: candidate })).exists) {
candidate = `${parent}/${base} (${i})${ext}`
i += 1
}
return candidate
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
resolve(result.split(',')[1] ?? '')
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
const [currentPath, setCurrentPath] = useState<string>(initialPath || WORKSPACE_ROOT)
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [renameTarget, setRenameTarget] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [uploading, setUploading] = useState(false)
const dragDepthRef = useRef(0)
const filesInputRef = useRef<HTMLInputElement | null>(null)
const folderInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (initialPath) setCurrentPath(initialPath)
}, [initialPath])
const isRoot = currentPath === WORKSPACE_ROOT
const fileManagerName = getFileManagerName()
const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath])
const items = useMemo<TreeNode[]>(() => {
const children = currentNode?.children ?? []
const filtered = isRoot ? children.filter((c) => c.kind === 'dir') : children
return [...filtered].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
}, [currentNode, isRoot])
const breadcrumbs = useMemo(() => {
if (isRoot) return [] as { path: string; name: string }[]
const rel = currentPath.slice(WORKSPACE_ROOT.length + 1)
const parts = rel.split('/').filter(Boolean)
let acc = WORKSPACE_ROOT
return parts.map((seg) => {
acc = `${acc}/${seg}`
return { path: acc, name: seg }
})
}, [currentPath, isRoot])
const handleItemClick = useCallback(
(item: TreeNode) => {
if (renameTarget) return
if (item.kind === 'dir') {
setCurrentPath(item.path)
} else {
onOpenNote(item.path)
}
},
[onOpenNote, renameTarget],
)
const beginRename = useCallback((item: TreeNode) => {
setRenameTarget(item.path)
setRenameValue(item.name)
}, [])
const commitRename = useCallback(async () => {
if (!renameTarget) return
const node = items.find((i) => i.path === renameTarget)
const trimmed = renameValue.trim()
setRenameTarget(null)
if (!node || !trimmed || trimmed === node.name || trimmed.includes('/')) return
const parent = renameTarget.slice(0, renameTarget.lastIndexOf('/'))
try {
await window.ipc.invoke('workspace:rename', { from: renameTarget, to: `${parent}/${trimmed}` })
toast('Renamed', 'success')
} catch {
toast('Failed to rename', 'error')
}
}, [renameTarget, renameValue, items])
const handleDelete = useCallback(async (item: TreeNode) => {
try {
await actions.remove(item.path)
toast('Moved to trash', 'success')
} catch {
toast('Failed to delete', 'error')
}
}, [actions])
const uploadFiles = useCallback(async (files: FileList | File[], preserveStructure = false) => {
const list = Array.from(files)
if (list.length === 0) return
setUploading(true)
try {
for (const file of list) {
const data = await readFileAsBase64(file)
const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath
const target = preserveStructure && rel
? `${currentPath}/${rel}`
: await uniqueChildPath(currentPath, file.name)
await window.ipc.invoke('workspace:writeFile', {
path: target,
data,
opts: { encoding: 'base64', mkdirp: true },
})
}
toast(list.length === 1 ? 'Added' : `${list.length} items added`, 'success')
} catch (err) {
console.error('Failed to add files:', err)
toast('Failed to add', 'error')
} finally {
setUploading(false)
}
}, [currentPath])
// Drag-and-drop (only inside a workspace folder, not at the root grid).
// stopPropagation keeps the drop from also reaching the copilot's
// document-level drop listener when it lands on the workspace area.
const dropEnabled = !isRoot
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
if (!Array.from(e.dataTransfer.types).includes('Files')) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current += 1
setIsDraggingOver(true)
}, [dropEnabled])
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
if (!Array.from(e.dataTransfer.types).includes('Files')) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [dropEnabled])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current -= 1
if (dragDepthRef.current <= 0) {
dragDepthRef.current = 0
setIsDraggingOver(false)
}
}, [dropEnabled])
const handleDrop = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current = 0
setIsDraggingOver(false)
if (e.dataTransfer.files?.length) void uploadFiles(e.dataTransfer.files)
}, [dropEnabled, uploadFiles])
const resetAddDialog = useCallback(() => {
setNewName('')
setError(null)
setCreating(false)
}, [])
const handleCreate = useCallback(async () => {
const trimmed = newName.trim()
if (!trimmed) {
setError('Name is required')
return
}
if (trimmed.includes('/')) {
setError('Name cannot contain "/"')
return
}
setCreating(true)
setError(null)
try {
await onCreateWorkspace(trimmed)
setAddOpen(false)
resetAddDialog()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create workspace')
setCreating(false)
}
}, [newName, onCreateWorkspace, resetAddDialog])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-6 py-4">
<div className="flex min-w-0 items-center gap-1 text-sm">
<button
type="button"
onClick={() => setCurrentPath(WORKSPACE_ROOT)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors',
isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
)}
>
<Home className="size-4" />
<span className="font-medium">Workspace</span>
</button>
{breadcrumbs.map((crumb, idx) => {
const isLast = idx === breadcrumbs.length - 1
return (
<span key={crumb.path} className="flex items-center gap-1">
<ChevronRight className="size-4 text-muted-foreground/60" />
{isLast ? (
<span className="rounded-md px-2 py-1 font-medium text-foreground truncate">
{crumb.name}
</span>
) : (
<button
type="button"
onClick={() => setCurrentPath(crumb.path)}
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
>
{crumb.name}
</button>
)}
</span>
)
})}
</div>
{isRoot ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm">
<Plus className="size-4" />
Add
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
<FilePlus className="mr-2 size-4" />
Add files
</DropdownMenuItem>
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderPlus className="mr-2 size-4" />
Add folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<input
ref={filesInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) void uploadFiles(e.target.files, false)
e.target.value = ''
}}
/>
<input
ref={folderInputRef}
type="file"
// @ts-expect-error non-standard but supported in Chromium/Electron
webkitdirectory=""
directory=""
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) void uploadFiles(e.target.files, true)
e.target.value = ''
}}
/>
<div
className="relative flex-1 overflow-y-auto px-6 py-6"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{items.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
<FolderIcon className="size-10 opacity-50" />
<div className="text-sm">
{isRoot
? 'No workspaces yet. Create one to get started.'
: 'This folder is empty. Drag files in or use New note / New folder.'}
</div>
{isRoot && (
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
</Button>
)}
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
{items.map((item) => {
const childCount = item.kind === 'dir' ? countChildren(item) : 0
const Icon = item.kind === 'dir' ? FolderIcon : FileIcon
const isRenaming = renameTarget === item.path
const card = (
<button
type="button"
onClick={() => handleItemClick(item)}
className="group flex w-full flex-col items-start gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-foreground/20 hover:bg-accent"
>
<Icon className="size-6 text-muted-foreground group-hover:text-foreground" />
<div className="min-w-0 w-full">
{isRenaming ? (
<Input
autoFocus
value={renameValue}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') { e.preventDefault(); void commitRename() }
else if (e.key === 'Escape') { e.preventDefault(); setRenameTarget(null) }
}}
className="h-6 text-sm"
/>
) : (
<div className="truncate text-sm font-medium">{item.name}</div>
)}
{item.kind === 'dir' && !isRenaming && (
<div className="text-xs text-muted-foreground">
{childCount} {childCount === 1 ? 'item' : 'items'}
</div>
)}
</div>
</button>
)
return (
<ContextMenu key={item.path}>
<ContextMenuTrigger asChild>{card}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={() => beginRename(item)}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => { actions.copyPath(item.path); toast('Path copied', 'success') }}>
<Copy className="mr-2 size-4" />
Copy Path
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, item.kind === 'dir')}>
<FolderOpen className="mr-2 size-4" />
Show in {fileManagerName}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onClick={() => void handleDelete(item)}>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
)}
{dropEnabled && isDraggingOver && (
<div className="pointer-events-none absolute inset-3 z-10 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5 text-primary">
<UploadCloud className="size-8" />
<span className="text-sm font-medium">Drop files to add to this folder</span>
</div>
)}
{uploading && (
<div className="pointer-events-none absolute bottom-4 right-4 z-10 rounded-md bg-foreground/80 px-3 py-1.5 text-xs text-background">
Adding files
</div>
)}
</div>
<Dialog
open={addOpen}
onOpenChange={(open) => {
setAddOpen(open)
if (!open) resetAddDialog()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>New workspace</DialogTitle>
<DialogDescription>
Workspaces are top-level folders inside knowledge/Workspace.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<label htmlFor="workspace-name" className="text-sm font-medium">Name</label>
<Input
id="workspace-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Alpha"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !creating) {
e.preventDefault()
void handleCreate()
}
}}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setAddOpen(false)
resetAddDialog()
}}
disabled={creating}
>
Cancel
</Button>
<Button onClick={() => void handleCreate()} disabled={creating || !newName.trim()}>
{creating ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,26 @@
export const BILLING_ERROR_PATTERNS = [
{
pattern: /upgrade required/i,
title: 'A subscription is required',
subtitle: 'Get started with a plan to access AI features in Rowboat.',
cta: 'Subscribe',
},
{
pattern: /not enough credits/i,
title: "You've run out of credits",
subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.',
cta: 'Upgrade plan',
},
{
pattern: /subscription not active/i,
title: 'Your subscription is inactive',
subtitle: 'Reactivate your subscription to continue using AI features.',
cta: 'Reactivate',
},
] as const
export type BillingErrorMatch = (typeof BILLING_ERROR_PATTERNS)[number]
export function matchBillingError(message: string): BillingErrorMatch | null {
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
}

View file

@ -1,7 +1,23 @@
/**
* Matches a video-conference join URL for the providers we support (Zoom,
* Microsoft Teams, Google Meet). Captures the full URL up to the first
* whitespace, quote, or angle/round/square bracket.
*/
const MEETING_URL_RE =
/https?:\/\/(?:[a-z0-9-]+\.)*(?:zoom\.us|zoomgov\.com|teams\.microsoft\.com|teams\.live\.com|meet\.google\.com)\/[^\s"'<>)\]]+/i
function findMeetingUrl(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const match = MEETING_URL_RE.exec(value)
// Calendar descriptions are often HTML, so decode &amp; back to & in the URL.
return match ? match[0].replace(/&amp;/g, '&') : undefined
}
/**
* Extract a video conference link from raw Google Calendar event JSON.
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
* to a top-level conferenceLink if present.
* Checks conferenceData.entryPoints (video type), hangoutLink, a top-level
* conferenceLink, then falls back to scanning the location/description for a
* known meeting URL (Zoom, Microsoft Teams, Google Meet).
*/
export function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
@ -11,5 +27,5 @@ export function extractConferenceLink(raw: Record<string, unknown>): string | un
}
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
return undefined
return findMeetingUrl(raw.location) ?? findMeetingUrl(raw.description)
}

View file

@ -3,14 +3,21 @@ import fs from 'fs/promises';
import path from 'path';
import { ClientRegistrationResponse } from './types.js';
export const DEFAULT_CALLBACK_PORT = 8080;
export interface IClientRegistrationRepo {
getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null>;
saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise<void>;
/** Returns the port that was used when DCR-registering this provider, or DEFAULT_CALLBACK_PORT if not stored. */
getRegisteredPort(provider: string): Promise<number>;
saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise<void>;
clearClientRegistration(provider: string): Promise<void>;
}
// _registeredPort is our private field — stripped by Zod when we parse the RFC response fields
type StoredEntry = Record<string, unknown> & { _registeredPort?: number };
type ClientRegistrationStorage = {
[provider: string]: ClientRegistrationResponse;
[provider: string]: StoredEntry;
};
export class FSClientRegistrationRepo implements IClientRegistrationRepo {
@ -45,14 +52,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo {
async getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null> {
const config = await this.readConfig();
const registration = config[provider];
if (!registration) {
const entry = config[provider];
if (!entry) {
return null;
}
// Validate registration structure
// Validate registration structure (Zod strips unknown fields like _registeredPort)
try {
return ClientRegistrationResponse.parse(registration);
return ClientRegistrationResponse.parse(entry);
} catch {
// Invalid registration, remove it
await this.clearClientRegistration(provider);
@ -60,9 +67,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo {
}
}
async saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise<void> {
async getRegisteredPort(provider: string): Promise<number> {
const config = await this.readConfig();
config[provider] = registration;
return config[provider]?._registeredPort ?? DEFAULT_CALLBACK_PORT;
}
async saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise<void> {
const config = await this.readConfig();
config[provider] = { ...registration, _registeredPort: port };
await this.writeConfig(config);
}

View file

@ -20,8 +20,17 @@ export async function getBillingInfo(): Promise<BillingInfo> {
status: string | null;
trialExpiresAt: string | null;
usage: {
sanctionedCredits: number;
availableCredits: number;
monthly: {
sanctionedCredits: number;
usedCredits: number;
availableCredits: number;
};
daily: {
sanctionedCredits: number;
usedCredits: number;
availableCredits: number;
usageDay: string;
};
};
};
};
@ -31,7 +40,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
subscriptionPlan: body.billing.plan,
subscriptionStatus: body.billing.status,
trialExpiresAt: body.billing.trialExpiresAt ?? null,
sanctionedCredits: body.billing.usage.sanctionedCredits,
availableCredits: body.billing.usage.availableCredits,
monthly: body.billing.usage.monthly,
daily: body.billing.usage.daily,
};
}

View file

@ -28,6 +28,7 @@ const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
const MAX_THREADS_IN_DIGEST = 10;
const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000;
const nhm = new NodeHtmlMarkdown();
interface SnapshotCacheEntry {
@ -713,7 +714,9 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
} catch (error) {
console.error(`Error processing thread ${threadId}:`, error);
return null;
const status = getErrorStatus(error);
if (status === 404) return null;
throw error;
}
}
@ -757,20 +760,102 @@ async function pruneInboxCache(auth: OAuth2Client): Promise<void> {
}
}
function loadState(stateFile: string): { historyId?: string; last_sync?: string } {
function loadState(stateFile: string): { historyId?: string; last_sync?: string; last_recent_backfill?: string } {
if (fs.existsSync(stateFile)) {
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
}
return {};
}
function saveState(historyId: string, stateFile: string) {
function saveState(historyId: string, stateFile: string, extra: { last_recent_backfill?: string } = {}) {
const previous = loadState(stateFile);
fs.writeFileSync(stateFile, JSON.stringify({
historyId,
last_sync: new Date().toISOString()
last_sync: new Date().toISOString(),
last_recent_backfill: extra.last_recent_backfill ?? previous.last_recent_backfill,
...extra,
}, null, 2));
}
function getErrorStatus(error: unknown): number | undefined {
const status = (error as { response?: { status?: number } }).response?.status;
if (status) return status;
const code = Number((error as { code?: number | string }).code);
return Number.isFinite(code) ? code : undefined;
}
function recentDateQuery(lookbackDays: number): string {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - lookbackDays);
return pastDate.toISOString().split('T')[0].replace(/-/g, '/');
}
async function listRecentNonDeletedThreadIds(gmailClient: gmail.Gmail, lookbackDays: number): Promise<RecentThreadInfo[]> {
const dateQuery = recentDateQuery(lookbackDays);
const results: RecentThreadInfo[] = [];
const seen = new Set<string>();
let pageToken: string | undefined;
do {
const res = await gmailClient.users.threads.list({
userId: 'me',
q: `after:${dateQuery} -in:spam -in:trash`,
maxResults: 500,
pageToken,
});
for (const thread of res.data.threads || []) {
if (!thread.id || seen.has(thread.id)) continue;
seen.add(thread.id);
results.push({
threadId: thread.id,
historyId: thread.historyId || '',
snippet: thread.snippet || undefined,
});
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
return results;
}
function shouldRunRecentBackfill(stateFile: string): boolean {
const state = loadState(stateFile);
if (!state.last_recent_backfill) return true;
const lastRunMs = new Date(state.last_recent_backfill).getTime();
if (!Number.isFinite(lastRunMs)) return true;
return Date.now() - lastRunMs >= RECENT_BACKFILL_INTERVAL_MS;
}
async function backfillMissingRecentThreads(
auth: OAuth2Client,
syncDir: string,
attachmentsDir: string,
stateFile: string,
lookbackDays: number,
): Promise<SyncedThread[]> {
if (!shouldRunRecentBackfill(stateFile)) return [];
const gmailClient = google.gmail({ version: 'v1', auth });
const recentThreads = await listRecentNonDeletedThreadIds(gmailClient, lookbackDays);
const missingThreadIds = recentThreads
.map((thread) => thread.threadId)
.filter((threadId) => !fs.existsSync(path.join(syncDir, `${threadId}.md`)));
const synced: SyncedThread[] = [];
for (const threadId of missingThreadIds) {
const result = await processThread(auth, threadId, syncDir, attachmentsDir);
if (result) synced.push(result);
}
const profile = await gmailClient.users.getProfile({ userId: 'me' });
saveState(profile.data.historyId!, stateFile, { last_recent_backfill: new Date().toISOString() });
if (missingThreadIds.length > 0) {
console.log(`Recent Gmail backfill synced ${synced.length}/${missingThreadIds.length} missing thread(s).`);
}
return synced;
}
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
const gmail = google.gmail({ version: 'v1', auth });
@ -814,6 +899,7 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
const res = await gmail.users.threads.list({
userId: 'me',
q: `after:${dateQuery} -in:spam -in:trash`,
maxResults: 500,
pageToken
});
@ -907,15 +993,24 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
};
try {
const res = await gmail.users.history.list({
userId: 'me',
startHistoryId,
historyTypes: ['messageAdded']
});
const changes: gmail.Schema$History[] = [];
let pageToken: string | undefined;
do {
const res = await gmail.users.history.list({
userId: 'me',
startHistoryId,
historyTypes: ['messageAdded'],
maxResults: 500,
pageToken,
});
if (res.data.history) changes.push(...res.data.history);
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
const changes = res.data.history;
if (!changes || changes.length === 0) {
console.log("No new changes.");
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
await publishGmailSyncEvent(backfilled);
const profile = await gmail.users.getProfile({ userId: 'me' });
saveState(profile.data.historyId!, stateFile);
return;
@ -937,6 +1032,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
}
if (threadIds.size === 0) {
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
await publishGmailSyncEvent(backfilled);
const profile = await gmail.users.getProfile({ userId: 'me' });
saveState(profile.data.historyId!, stateFile);
return;
@ -961,6 +1058,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
const result = await processThread(auth, tid, syncDir, attachmentsDir);
if (result) synced.push(result);
}
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
synced.push(...backfilled);
await publishGmailSyncEvent(synced);

View file

@ -3,13 +3,22 @@ import { z } from 'zod';
export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']);
export type BillingPlan = z.infer<typeof BillingPlanSchema>;
export const BillingUsageBucketSchema = z.object({
sanctionedCredits: z.number(),
usedCredits: z.number(),
availableCredits: z.number(),
});
export type BillingUsageBucket = z.infer<typeof BillingUsageBucketSchema>;
export const BillingInfoSchema = z.object({
userEmail: z.string().nullable(),
userId: z.string().nullable(),
subscriptionPlan: BillingPlanSchema.nullable(),
subscriptionStatus: z.string().nullable(),
trialExpiresAt: z.string().nullable(),
sanctionedCredits: z.number(),
availableCredits: z.number(),
monthly: BillingUsageBucketSchema,
daily: BillingUsageBucketSchema.extend({
usageDay: z.string(),
}),
});
export type BillingInfo = z.infer<typeof BillingInfoSchema>;