mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
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:
commit
cbcdd10228
28 changed files with 4048 additions and 1705 deletions
|
|
@ -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}.`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
61
apps/x/apps/renderer/src/components/billing-error-dialog.tsx
Normal file
61
apps/x/apps/renderer/src/components/billing-error-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
apps/x/apps/renderer/src/components/chat-empty-state.tsx
Normal file
106
apps/x/apps/renderer/src/components/chat-empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
apps/x/apps/renderer/src/components/chat-header.tsx
Normal file
114
apps/x/apps/renderer/src/components/chat-header.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
177
apps/x/apps/renderer/src/components/chat-history-view.tsx
Normal file
177
apps/x/apps/renderer/src/components/chat-history-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
470
apps/x/apps/renderer/src/components/home-view.tsx
Normal file
470
apps/x/apps/renderer/src/components/home-view.tsx
Normal 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 & 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()
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
412
apps/x/apps/renderer/src/components/knowledge-view.tsx
Normal file
412
apps/x/apps/renderer/src/components/knowledge-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 & 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...
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
531
apps/x/apps/renderer/src/components/workspace-view.tsx
Normal file
531
apps/x/apps/renderer/src/components/workspace-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
apps/x/apps/renderer/src/lib/billing-error.ts
Normal file
26
apps/x/apps/renderer/src/lib/billing-error.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 & back to & in the URL.
|
||||
return match ? match[0].replace(/&/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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue