mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-18 21:21:11 +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';
|
import { URL } from 'url';
|
||||||
|
|
||||||
const OAUTH_CALLBACK_PATH = '/oauth/callback';
|
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 */
|
/** Escape HTML special characters to prevent XSS */
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|
@ -19,13 +20,8 @@ export interface AuthServerResult {
|
||||||
port: number;
|
port: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function tryBindPort(
|
||||||
* Create a local HTTP server to handle OAuth callback
|
port: number,
|
||||||
* Listens on http://localhost:8080/oauth/callback
|
|
||||||
* Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
|
|
||||||
*/
|
|
||||||
export function createAuthServer(
|
|
||||||
port: number = DEFAULT_PORT,
|
|
||||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||||
): Promise<AuthServerResult> {
|
): Promise<AuthServerResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -37,7 +33,7 @@ export function createAuthServer(
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url, `http://localhost:${port}`);
|
const url = new URL(req.url, `http://localhost:${port}`);
|
||||||
|
|
||||||
if (url.pathname === OAUTH_CALLBACK_PATH) {
|
if (url.pathname === OAUTH_CALLBACK_PATH) {
|
||||||
const error = url.searchParams.get('error');
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
|
|
@ -96,8 +92,10 @@ export function createAuthServer(
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === 'EADDRINUSE') {
|
server.close();
|
||||||
reject(new Error(`Port ${port} is already in use`));
|
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
||||||
|
// Signal caller to try next port
|
||||||
|
reject(Object.assign(new Error(err.code), { code: err.code }));
|
||||||
} else {
|
} else {
|
||||||
reject(err);
|
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 { shell } from 'electron';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
import { createAuthServer } from './auth-server.js';
|
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 * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||||
import type { Configuration } 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';
|
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 { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||||
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.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. */
|
/** Top-level openid-client messages that often wrap a more specific cause. */
|
||||||
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
|
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 config = await getProviderConfig(provider);
|
||||||
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
||||||
if (config.client.mode === 'static' && config.client.clientId) {
|
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`);
|
console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`);
|
||||||
const clientRepo = getClientRegistrationRepo();
|
const clientRepo = getClientRegistrationRepo();
|
||||||
const existingRegistration = await clientRepo.getClientRegistration(provider);
|
const existingRegistration = await clientRepo.getClientRegistration(provider);
|
||||||
|
|
||||||
if (existingRegistration) {
|
if (existingRegistration) {
|
||||||
console.log(`[OAuth] ${provider}: Using existing DCR registration`);
|
console.log(`[OAuth] ${provider}: Using existing DCR registration`);
|
||||||
return await oauthClient.discoverConfiguration(
|
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 scopes = config.scopes || [];
|
||||||
const { config: oauthConfig, registration } = await oauthClient.registerClient(
|
const { config: oauthConfig, registration } = await oauthClient.registerClient(
|
||||||
config.discovery.issuer,
|
config.discovery.issuer,
|
||||||
[REDIRECT_URI],
|
[redirectUri],
|
||||||
scopes
|
scopes
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save registration for future use
|
// Parse port from redirectUri (e.g. "http://localhost:8081/...") and save
|
||||||
await clientRepo.saveClientRegistration(provider, registration);
|
const boundPort = new URL(redirectUri).port
|
||||||
console.log(`[OAuth] ${provider}: DCR registration saved`);
|
? 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;
|
return oauthConfig;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -176,7 +188,7 @@ async function getProviderConfiguration(provider: string, credentialsOverride?:
|
||||||
if (config.client.mode !== 'static') {
|
if (config.client.mode !== 'static') {
|
||||||
throw new Error('DCR requires discovery mode "issuer", not "static"');
|
throw new Error('DCR requires discovery mode "issuer", not "static"');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
|
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
|
||||||
const { clientId, clientSecret } = await resolveClientCredentials();
|
const { clientId, clientSecret } = await resolveClientCredentials();
|
||||||
return oauthClient.createStaticConfiguration(
|
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
|
* Initiate OAuth flow for a provider
|
||||||
*/
|
*/
|
||||||
|
|
@ -225,154 +268,188 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create OAuth configuration
|
// For static-client providers (Google BYOK) the redirect URI is pre-registered
|
||||||
const config = await getProviderConfiguration(provider, credentials);
|
// 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
|
// --- Callback server ---
|
||||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
// Declare `state` before the closure so the callback can close over its binding.
|
||||||
const state = oauthClient.generateState();
|
// The variable is assigned below, before shell.openExternal, so it is always
|
||||||
|
// set by the time any browser request arrives.
|
||||||
// Get scopes from config
|
let state = '';
|
||||||
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
|
|
||||||
let callbackHandled = false;
|
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);
|
const { server, port: boundPort } = await createAuthServer(
|
||||||
if (!flow || flow.provider !== provider) {
|
startPort,
|
||||||
throw new Error('Invalid OAuth flow state');
|
async (callbackUrl) => {
|
||||||
}
|
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||||
|
if (callbackHandled) return;
|
||||||
try {
|
callbackHandled = true;
|
||||||
// Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
|
const receivedState = callbackUrl.searchParams.get('state');
|
||||||
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
|
if (receivedState == null || receivedState === '') {
|
||||||
const tokens = await oauthClient.exchangeCodeForTokens(
|
throw new Error(
|
||||||
flow.config,
|
'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.'
|
||||||
callbackUrl,
|
);
|
||||||
flow.codeVerifier,
|
}
|
||||||
state
|
if (receivedState !== state) {
|
||||||
);
|
throw new Error('Invalid state parameter - possible CSRF attack');
|
||||||
|
|
||||||
// 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
|
const flow = activeFlows.get(state);
|
||||||
// notifying the renderer. Without this, parallel API calls from
|
if (!flow || flow.provider !== provider) {
|
||||||
// multiple renderer hooks race to create the user, causing duplicates.
|
throw new Error('Invalid OAuth flow state');
|
||||||
let signedInUserId: string | undefined;
|
}
|
||||||
if (provider === 'rowboat') {
|
|
||||||
try {
|
try {
|
||||||
const billing = await getBillingInfo();
|
// Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
|
||||||
if (billing.userId) {
|
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
|
||||||
signedInUserId = billing.userId;
|
const tokens = await oauthClient.exchangeCodeForTokens(
|
||||||
analyticsIdentify(billing.userId, {
|
flow.config,
|
||||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
callbackUrl,
|
||||||
plan: billing.subscriptionPlan,
|
flow.codeVerifier,
|
||||||
status: billing.subscriptionStatus,
|
state
|
||||||
});
|
);
|
||||||
analyticsCapture('user_signed_in', {
|
|
||||||
plan: billing.subscriptionPlan,
|
// Save tokens and credentials. For Google, BYOK is the only path
|
||||||
status: billing.subscriptionStatus,
|
// 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
|
// Emit success event to renderer
|
||||||
emitOAuthEvent({
|
emitOAuthEvent({
|
||||||
provider,
|
provider,
|
||||||
success: true,
|
success: true,
|
||||||
...(signedInUserId ? { userId: signedInUserId } : {}),
|
...(signedInUserId ? { userId: signedInUserId } : {}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('OAuth token exchange failed:', error);
|
console.error('OAuth token exchange failed:', error);
|
||||||
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
|
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
|
||||||
let cause: unknown = error;
|
let cause: unknown = error;
|
||||||
while (cause != null && typeof cause === 'object' && 'cause' in cause) {
|
while (cause != null && typeof cause === 'object' && 'cause' in cause) {
|
||||||
cause = (cause as { cause?: unknown }).cause;
|
cause = (cause as { cause?: unknown }).cause;
|
||||||
if (cause != null) {
|
if (cause != null) {
|
||||||
console.error('[OAuth] Caused by:', cause);
|
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 });
|
// Static providers (Google BYOK) keep fixed-port behaviour to match the
|
||||||
throw error;
|
// pre-registered redirect URI at the provider's console. DCR providers
|
||||||
} finally {
|
// can fall back since we register the actual bound port below.
|
||||||
// Clean up
|
{ 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);
|
activeFlows.delete(state);
|
||||||
if (activeFlow && activeFlow.state === state) {
|
|
||||||
clearTimeout(activeFlow.cleanupTimeout);
|
|
||||||
activeFlow.server.close();
|
|
||||||
activeFlow = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
throw setupError;
|
||||||
|
}
|
||||||
// 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 };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('OAuth connection failed:', error);
|
console.error('OAuth connection failed:', error);
|
||||||
return {
|
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.
|
* "Edit with Copilot" button in the detail-view sidebar footer.
|
||||||
*/
|
*/
|
||||||
onEditWithCopilot?: (slug: string) => void
|
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 {
|
function formatLastRanLabel(iso: string | null | undefined): string {
|
||||||
|
|
@ -1425,9 +1437,12 @@ function formatLastRanLabel(iso: string | null | undefined): string {
|
||||||
return formatRelativeTime(iso) || 'Never'
|
return formatRelativeTime(iso) || 'Never'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot }: BgTasksViewProps = {}) {
|
export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlug, slugVersion }: BgTasksViewProps = {}) {
|
||||||
const [items, setItems] = useState<BackgroundTaskSummary[]>([])
|
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 [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showNewDialog, setShowNewDialog] = useState(false)
|
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 () => {
|
const handleSetWorkDir = useCallback(async () => {
|
||||||
try {
|
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', {
|
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
|
||||||
title: 'Choose work directory',
|
title: 'Choose work directory',
|
||||||
defaultPath: workDir ?? undefined,
|
defaultPath,
|
||||||
})
|
})
|
||||||
if (!chosen) return
|
if (!chosen) return
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { ChatHeader } from '@/components/chat-header'
|
||||||
|
import { ChatEmptyState } from '@/components/chat-empty-state'
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
ConversationEmptyState,
|
|
||||||
ConversationScrollButton,
|
ConversationScrollButton,
|
||||||
} from '@/components/ai-elements/conversation'
|
} from '@/components/ai-elements/conversation'
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,13 +23,12 @@ import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-c
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||||
import { TerminalOutput } from '@/components/terminal-output'
|
import { TerminalOutput } from '@/components/terminal-output'
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
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 { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||||
import { defaultRemarkPlugins } from 'streamdown'
|
import { defaultRemarkPlugins } from 'streamdown'
|
||||||
import remarkBreaks from 'remark-breaks'
|
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 { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { useSidebar } from '@/components/ui/sidebar'
|
import { useSidebar } from '@/components/ui/sidebar'
|
||||||
|
|
@ -52,6 +52,7 @@ import {
|
||||||
parseAttachedFiles,
|
parseAttachedFiles,
|
||||||
toToolState,
|
toToolState,
|
||||||
} from '@/lib/chat-conversation'
|
} from '@/lib/chat-conversation'
|
||||||
|
import { matchBillingError } from '@/lib/billing-error'
|
||||||
|
|
||||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
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 MIN_WIDTH = 360
|
||||||
const MAX_WIDTH = 1600
|
const MAX_WIDTH = 1600
|
||||||
const MIN_MAIN_PANE_WIDTH = 420
|
const MIN_MAIN_PANE_WIDTH = 420
|
||||||
|
|
@ -173,11 +120,12 @@ interface ChatSidebarProps {
|
||||||
chatTabs: ChatTab[]
|
chatTabs: ChatTab[]
|
||||||
activeChatTabId: string
|
activeChatTabId: string
|
||||||
getChatTabTitle: (tab: ChatTab) => string
|
getChatTabTitle: (tab: ChatTab) => string
|
||||||
isChatTabProcessing: (tab: ChatTab) => boolean
|
|
||||||
onSwitchChatTab: (tabId: string) => void
|
|
||||||
onCloseChatTab: (tabId: string) => void
|
|
||||||
onNewChatTab: () => void
|
onNewChatTab: () => void
|
||||||
|
recentRuns?: { id: string; title?: string; createdAt: string }[]
|
||||||
|
onSelectRun?: (runId: string) => void
|
||||||
|
onOpenChatHistory?: () => void
|
||||||
onOpenFullScreen?: () => void
|
onOpenFullScreen?: () => void
|
||||||
|
onCloseChat?: () => void
|
||||||
conversation: ConversationItem[]
|
conversation: ConversationItem[]
|
||||||
currentAssistantMessage: string
|
currentAssistantMessage: string
|
||||||
chatTabStates?: Record<string, ChatTabViewState>
|
chatTabStates?: Record<string, ChatTabViewState>
|
||||||
|
|
@ -228,11 +176,12 @@ export function ChatSidebar({
|
||||||
chatTabs,
|
chatTabs,
|
||||||
activeChatTabId,
|
activeChatTabId,
|
||||||
getChatTabTitle,
|
getChatTabTitle,
|
||||||
isChatTabProcessing,
|
|
||||||
onSwitchChatTab,
|
|
||||||
onCloseChatTab,
|
|
||||||
onNewChatTab,
|
onNewChatTab,
|
||||||
|
recentRuns = [],
|
||||||
|
onSelectRun,
|
||||||
|
onOpenChatHistory,
|
||||||
onOpenFullScreen,
|
onOpenFullScreen,
|
||||||
|
onCloseChat,
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
chatTabStates = {},
|
chatTabStates = {},
|
||||||
|
|
@ -380,7 +329,6 @@ export function ChatSidebar({
|
||||||
if (tabId === activeChatTabId) return activeTabState
|
if (tabId === activeChatTabId) return activeTabState
|
||||||
return chatTabStates[tabId] ?? emptyTabState
|
return chatTabStates[tabId] ?? emptyTabState
|
||||||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||||
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
|
|
||||||
|
|
||||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
|
|
@ -491,19 +439,8 @@ export function ChatSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isErrorMessage(item)) {
|
if (isErrorMessage(item)) {
|
||||||
const billingError = matchBillingError(item.message)
|
if (matchBillingError(item.message)) {
|
||||||
if (billingError) {
|
return null
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
<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,
|
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabBar
|
<ChatHeader
|
||||||
tabs={chatTabs}
|
activeTitle={(() => {
|
||||||
activeTabId={activeChatTabId}
|
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
||||||
getTabTitle={getChatTabTitle}
|
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||||
getTabId={(tab) => tab.id}
|
})()}
|
||||||
isProcessing={isChatTabProcessing}
|
onNewChatTab={onNewChatTab}
|
||||||
onSwitchTab={onSwitchChatTab}
|
recentRuns={recentRuns}
|
||||||
onCloseTab={onCloseChatTab}
|
activeRunId={runId}
|
||||||
|
onSelectRun={onSelectRun}
|
||||||
|
onOpenChatHistory={onOpenChatHistory}
|
||||||
/>
|
/>
|
||||||
<Tooltip>
|
{isMaximized ? (
|
||||||
<TooltipTrigger asChild>
|
onOpenFullScreen && (
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger asChild>
|
||||||
size="icon"
|
<Button
|
||||||
onClick={onNewChatTab}
|
variant="ghost"
|
||||||
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
size="icon"
|
||||||
>
|
onClick={onOpenFullScreen}
|
||||||
<SquarePen className="size-5" />
|
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
</Button>
|
aria-label="Dock chat to side pane"
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
<ArrowRight className="size-5" />
|
||||||
</Tooltip>
|
</Button>
|
||||||
{onOpenFullScreen && (
|
</TooltipTrigger>
|
||||||
<Tooltip>
|
<TooltipContent side="bottom">Dock to side pane</TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
</Tooltip>
|
||||||
<Button
|
)
|
||||||
variant="ghost"
|
) : (
|
||||||
size="icon"
|
onCloseChat && (
|
||||||
onClick={onOpenFullScreen}
|
<Tooltip>
|
||||||
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
<TooltipTrigger asChild>
|
||||||
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
{isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
|
size="icon"
|
||||||
</Button>
|
onClick={onCloseChat}
|
||||||
</TooltipTrigger>
|
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
<TooltipContent side="bottom">
|
aria-label="Close chat"
|
||||||
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
>
|
||||||
</TooltipContent>
|
<X className="size-5" />
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Close chat</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -629,11 +572,19 @@ export function ChatSidebar({
|
||||||
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
||||||
className="relative flex-1"
|
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 ? (
|
{!tabHasConversation ? (
|
||||||
<ConversationEmptyState className="h-auto">
|
<ChatEmptyState
|
||||||
<div className="text-sm text-muted-foreground">Ask anything...</div>
|
wide={isMaximized}
|
||||||
</ConversationEmptyState>
|
recentRuns={recentRuns}
|
||||||
|
onSelectRun={onSelectRun}
|
||||||
|
onOpenChatHistory={onOpenChatHistory}
|
||||||
|
onPickPrompt={setLocalPresetMessage}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{groupConversationItems(
|
{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="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="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">
|
<div className="mx-auto w-full max-w-4xl px-3">
|
||||||
{!hasConversation && (
|
|
||||||
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
|
|
||||||
)}
|
|
||||||
{chatTabs.map((tab) => {
|
{chatTabs.map((tab) => {
|
||||||
const isActive = tab.id === activeChatTabId
|
const isActive = tab.id === activeChatTabId
|
||||||
const tabState = getTabState(tab.id)
|
const tabState = getTabState(tab.id)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
|
|
@ -8,6 +8,7 @@ import type { blocks } from '@x/shared'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { useTheme } from '@/contexts/theme-context'
|
import { useTheme } from '@/contexts/theme-context'
|
||||||
|
import { SettingsDialog } from '@/components/settings-dialog'
|
||||||
|
|
||||||
type GmailThread = blocks.GmailThread
|
type GmailThread = blocks.GmailThread
|
||||||
type GmailThreadMessage = blocks.GmailThreadMessage
|
type GmailThreadMessage = blocks.GmailThreadMessage
|
||||||
|
|
@ -817,15 +818,52 @@ function clearLoadingFlag(state: SectionState | null): SectionState {
|
||||||
return { ...state, loadingPage: false }
|
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 [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant))
|
||||||
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
|
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
|
||||||
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
|
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
|
||||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
|
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(initialThreadId ?? null)
|
||||||
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>([])
|
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 [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [query, setQuery] = useState('')
|
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(() => { persistedImportant = important }, [important])
|
||||||
useEffect(() => { persistedOther = other }, [other])
|
useEffect(() => { persistedOther = other }, [other])
|
||||||
|
|
@ -1185,12 +1223,26 @@ export function EmailView() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="gmail-empty-state">
|
||||||
{initialLoading ? 'Loading Gmail threads…' : 'No Gmail threads in your inbox cache yet.'}
|
{initialLoading ? 'Loading Gmail threads…' : 'No Gmail threads in your inbox cache yet.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
|
||||||
</div>
|
</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'
|
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||||
|
|
||||||
const MAX_SIZE_BYTES = 5 * 1024 * 1024
|
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 =
|
type ViewerState =
|
||||||
| { kind: 'loading' }
|
| { kind: 'loading' }
|
||||||
| { kind: 'loaded'; html: string }
|
| { kind: 'loaded' }
|
||||||
| { kind: 'empty' }
|
| { kind: 'empty' }
|
||||||
| { kind: 'tooLarge'; sizeMB: number }
|
| { kind: 'tooLarge'; sizeMB: number }
|
||||||
| { kind: 'error'; message: string }
|
| { kind: 'error'; message: string }
|
||||||
|
|
@ -36,9 +14,15 @@ interface HtmlFileViewerProps {
|
||||||
path: string
|
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) {
|
export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
||||||
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
|
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
|
||||||
const [iframeLoaded, setIframeLoaded] = useState(false)
|
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||||
|
const iframeSrc = useMemo(() => toAppWorkspaceUrl(path), [path])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -57,19 +41,11 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
||||||
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
|
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cachedHtml = getCached(path, stat.mtimeMs, stat.size)
|
if (stat.size === 0) {
|
||||||
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() === '') {
|
|
||||||
setState({ kind: 'empty' })
|
setState({ kind: 'empty' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setState({ kind: 'loaded', html: result.data })
|
setState({ kind: 'loaded' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
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
|
// Serve via the `app://workspace/<rel-path>` protocol so the iframe has a
|
||||||
// gets a null origin with no base URL. Trade-off: relative assets inside
|
// proper base URL — relative `<link>`, `<img>`, `<script>` references next
|
||||||
// the file — `<link href="./style.css">`, `<img src="./pic.png">`,
|
// to the file resolve correctly (the path-traversal guard in
|
||||||
// `<script src="./foo.js">` — will silently 404. Self-contained HTML
|
// resolveWorkspacePath already gates the protocol handler).
|
||||||
// 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.
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{state.kind === 'loaded' && (
|
{state.kind === 'loaded' && (
|
||||||
<iframe
|
<iframe
|
||||||
key={path}
|
key={path}
|
||||||
srcDoc={state.html}
|
src={iframeSrc}
|
||||||
sandbox="allow-scripts"
|
sandbox="allow-scripts"
|
||||||
className="h-full w-full border-0 bg-white"
|
className="h-full w-full border-0 bg-white"
|
||||||
title="HTML preview"
|
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 { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { SettingsDialog } from '@/components/settings-dialog'
|
||||||
import { formatRelativeTime } from '@/lib/relative-time'
|
import { formatRelativeTime } from '@/lib/relative-time'
|
||||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
@ -189,6 +190,27 @@ function UpcomingEvents() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [refreshTick, setRefreshTick] = useState(0)
|
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 () => {
|
const loadEvents = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -313,7 +335,20 @@ function UpcomingEvents() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-center py-6">
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -335,6 +370,7 @@ function UpcomingEvents() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +384,7 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool
|
||||||
<div
|
<div
|
||||||
className="grid"
|
className="grid"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: '96px 1fr',
|
gridTemplateColumns: '96px minmax(0, 1fr)',
|
||||||
borderBottom: isLast ? undefined : '1px dashed var(--gm-border-strong)',
|
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 style={{ fontSize: 12, color: 'var(--gm-text-faint)' }}>{weekday}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 ? (
|
{day.events.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className="flex w-full items-center gap-3 px-3 py-2 text-sm"
|
className="flex w-full items-center gap-3 px-3 py-2 text-sm"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useState, useEffect, useCallback, useMemo } 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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -25,7 +26,7 @@ import { toast } from "sonner"
|
||||||
import { AccountSettings } from "@/components/settings/account-settings"
|
import { AccountSettings } from "@/components/settings/account-settings"
|
||||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-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 {
|
interface TabConfig {
|
||||||
id: ConfigTab
|
id: ConfigTab
|
||||||
|
|
@ -43,10 +44,10 @@ const tabs: TabConfig[] = [
|
||||||
description: "Manage your Rowboat account",
|
description: "Manage your Rowboat account",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "connected-accounts",
|
id: "connections",
|
||||||
label: "Connected Accounts",
|
label: "Connections",
|
||||||
icon: Plug,
|
icon: Plug,
|
||||||
description: "Manage connected services",
|
description: "Manage accounts and tools",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "models",
|
id: "models",
|
||||||
|
|
@ -75,12 +76,6 @@ const tabs: TabConfig[] = [
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
description: "Customize the look and feel",
|
description: "Customize the look and feel",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "tools",
|
|
||||||
label: "Tools Library",
|
|
||||||
icon: Wrench,
|
|
||||||
description: "Browse and enable toolkits",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "note-tagging",
|
id: "note-tagging",
|
||||||
label: "Note Tagging",
|
label: "Note Tagging",
|
||||||
|
|
@ -88,10 +83,93 @@ const tabs: TabConfig[] = [
|
||||||
path: "config/tags.json",
|
path: "config/tags.json",
|
||||||
description: "Configure tags for notes and emails",
|
description: "Configure tags for notes and emails",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "help",
|
||||||
|
label: "Help",
|
||||||
|
icon: HelpCircle,
|
||||||
|
description: "Get help and support",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
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 ---
|
// --- Theme option for Appearance tab ---
|
||||||
|
|
@ -1572,9 +1650,14 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
|
|
||||||
// --- Main Settings Dialog ---
|
// --- Main Settings Dialog ---
|
||||||
|
|
||||||
export function SettingsDialog({ children }: SettingsDialogProps) {
|
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<ConfigTab>("account")
|
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 [content, setContent] = useState("")
|
||||||
const [originalContent, setOriginalContent] = useState("")
|
const [originalContent, setOriginalContent] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -1582,6 +1665,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [rowboatConnected, setRowboatConnected] = useState(false)
|
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
|
// Check if user is signed in to Rowboat
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
@ -1607,7 +1695,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
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)!
|
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||||
if (!tabConfig.path) return
|
if (!tabConfig.path) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -1673,7 +1761,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
|
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -1715,11 +1803,21 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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" ? (
|
{activeTab === "account" ? (
|
||||||
<AccountSettings dialogOpen={open} />
|
<AccountSettings dialogOpen={open} />
|
||||||
) : activeTab === "connected-accounts" ? (
|
) : activeTab === "connections" ? (
|
||||||
<ConnectedAccountsSettings dialogOpen={open} />
|
<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" ? (
|
) : activeTab === "models" ? (
|
||||||
rowboatConnected
|
rowboatConnected
|
||||||
? <RowboatModelSettings dialogOpen={open} />
|
? <RowboatModelSettings dialogOpen={open} />
|
||||||
|
|
@ -1728,8 +1826,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
<NoteTaggingSettings dialogOpen={open} />
|
<NoteTaggingSettings dialogOpen={open} />
|
||||||
) : activeTab === "appearance" ? (
|
) : activeTab === "appearance" ? (
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
) : activeTab === "tools" ? (
|
) : activeTab === "help" ? (
|
||||||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
<HelpSettings />
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
Loading...
|
Loading...
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,44 @@ import {
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { useBilling } from "@/hooks/useBilling"
|
import { useBilling } from "@/hooks/useBilling"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
|
||||||
|
|
||||||
interface AccountSettingsProps {
|
interface AccountSettingsProps {
|
||||||
dialogOpen: boolean
|
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) {
|
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||||
const [connectionLoading, setConnectionLoading] = useState(true)
|
const [connectionLoading, setConnectionLoading] = useState(true)
|
||||||
|
|
@ -164,7 +197,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium capitalize">
|
<p className="text-sm font-medium capitalize">
|
||||||
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
|
{formatPlanName(billing.subscriptionPlan)}
|
||||||
</p>
|
</p>
|
||||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
|
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
|
||||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
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 && (
|
{!billing.subscriptionPlan && (
|
||||||
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
|
<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>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
|
||||||
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
|
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
|
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,10 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={provider}
|
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 items-center gap-2.5 min-w-0">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
|
|
@ -119,15 +119,15 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
{/* Email & Calendar Section */}
|
{/* Email & Calendar Section */}
|
||||||
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
|
{(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">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Email & Calendar
|
Email & Calendar
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{c.useComposioForGoogle ? (
|
{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 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 items-center gap-2.5 min-w-0">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||||
<Mail className="size-4" />
|
<Mail className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<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.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
||||||
)}
|
)}
|
||||||
{c.useComposioForGoogleCalendar && (
|
{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 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 items-center gap-2.5 min-w-0">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||||
<Calendar className="size-4" />
|
<Calendar className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
|
|
@ -220,14 +220,14 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Separator className="my-3" />
|
<Separator className="my-2" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Meeting Notes Section */}
|
{/* Meeting Notes Section */}
|
||||||
{c.providers.includes('fireflies-ai') && (
|
{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">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Meeting Notes
|
Meeting Notes
|
||||||
</span>
|
</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-slot="sidebar-content"
|
||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-group"
|
data-slot="sidebar-group"
|
||||||
data-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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
<ul
|
<ul
|
||||||
data-slot="sidebar-menu"
|
data-slot="sidebar-menu"
|
||||||
data-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}
|
{...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.
|
* Extract a video conference link from raw Google Calendar event JSON.
|
||||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
* Checks conferenceData.entryPoints (video type), hangoutLink, a top-level
|
||||||
* to a top-level conferenceLink if present.
|
* 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 {
|
export function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: 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.hangoutLink === 'string') return raw.hangoutLink
|
||||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
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 path from 'path';
|
||||||
import { ClientRegistrationResponse } from './types.js';
|
import { ClientRegistrationResponse } from './types.js';
|
||||||
|
|
||||||
|
export const DEFAULT_CALLBACK_PORT = 8080;
|
||||||
|
|
||||||
export interface IClientRegistrationRepo {
|
export interface IClientRegistrationRepo {
|
||||||
getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null>;
|
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>;
|
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 = {
|
type ClientRegistrationStorage = {
|
||||||
[provider: string]: ClientRegistrationResponse;
|
[provider: string]: StoredEntry;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
||||||
|
|
@ -45,14 +52,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
||||||
|
|
||||||
async getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null> {
|
async getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null> {
|
||||||
const config = await this.readConfig();
|
const config = await this.readConfig();
|
||||||
const registration = config[provider];
|
const entry = config[provider];
|
||||||
if (!registration) {
|
if (!entry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate registration structure
|
// Validate registration structure (Zod strips unknown fields like _registeredPort)
|
||||||
try {
|
try {
|
||||||
return ClientRegistrationResponse.parse(registration);
|
return ClientRegistrationResponse.parse(entry);
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid registration, remove it
|
// Invalid registration, remove it
|
||||||
await this.clearClientRegistration(provider);
|
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();
|
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);
|
await this.writeConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,17 @@ export async function getBillingInfo(): Promise<BillingInfo> {
|
||||||
status: string | null;
|
status: string | null;
|
||||||
trialExpiresAt: string | null;
|
trialExpiresAt: string | null;
|
||||||
usage: {
|
usage: {
|
||||||
sanctionedCredits: number;
|
monthly: {
|
||||||
availableCredits: number;
|
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,
|
subscriptionPlan: body.billing.plan,
|
||||||
subscriptionStatus: body.billing.status,
|
subscriptionStatus: body.billing.status,
|
||||||
trialExpiresAt: body.billing.trialExpiresAt ?? null,
|
trialExpiresAt: body.billing.trialExpiresAt ?? null,
|
||||||
sanctionedCredits: body.billing.usage.sanctionedCredits,
|
monthly: body.billing.usage.monthly,
|
||||||
availableCredits: body.billing.usage.availableCredits,
|
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 SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
|
||||||
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
|
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
|
||||||
const MAX_THREADS_IN_DIGEST = 10;
|
const MAX_THREADS_IN_DIGEST = 10;
|
||||||
|
const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
const nhm = new NodeHtmlMarkdown();
|
const nhm = new NodeHtmlMarkdown();
|
||||||
|
|
||||||
interface SnapshotCacheEntry {
|
interface SnapshotCacheEntry {
|
||||||
|
|
@ -713,7 +714,9 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing thread ${threadId}:`, 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)) {
|
if (fs.existsSync(stateFile)) {
|
||||||
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||||
}
|
}
|
||||||
return {};
|
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({
|
fs.writeFileSync(stateFile, JSON.stringify({
|
||||||
historyId,
|
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));
|
}, 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) {
|
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
|
||||||
const gmail = google.gmail({ version: 'v1', auth });
|
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({
|
const res = await gmail.users.threads.list({
|
||||||
userId: 'me',
|
userId: 'me',
|
||||||
q: `after:${dateQuery} -in:spam -in:trash`,
|
q: `after:${dateQuery} -in:spam -in:trash`,
|
||||||
|
maxResults: 500,
|
||||||
pageToken
|
pageToken
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -907,15 +993,24 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await gmail.users.history.list({
|
const changes: gmail.Schema$History[] = [];
|
||||||
userId: 'me',
|
let pageToken: string | undefined;
|
||||||
startHistoryId,
|
do {
|
||||||
historyTypes: ['messageAdded']
|
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) {
|
if (!changes || changes.length === 0) {
|
||||||
console.log("No new changes.");
|
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' });
|
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||||
saveState(profile.data.historyId!, stateFile);
|
saveState(profile.data.historyId!, stateFile);
|
||||||
return;
|
return;
|
||||||
|
|
@ -937,6 +1032,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
|
||||||
}
|
}
|
||||||
|
|
||||||
if (threadIds.size === 0) {
|
if (threadIds.size === 0) {
|
||||||
|
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
|
||||||
|
await publishGmailSyncEvent(backfilled);
|
||||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||||
saveState(profile.data.historyId!, stateFile);
|
saveState(profile.data.historyId!, stateFile);
|
||||||
return;
|
return;
|
||||||
|
|
@ -961,6 +1058,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
|
||||||
const result = await processThread(auth, tid, syncDir, attachmentsDir);
|
const result = await processThread(auth, tid, syncDir, attachmentsDir);
|
||||||
if (result) synced.push(result);
|
if (result) synced.push(result);
|
||||||
}
|
}
|
||||||
|
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
|
||||||
|
synced.push(...backfilled);
|
||||||
|
|
||||||
await publishGmailSyncEvent(synced);
|
await publishGmailSyncEvent(synced);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,22 @@ import { z } from 'zod';
|
||||||
export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']);
|
export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']);
|
||||||
export type BillingPlan = z.infer<typeof BillingPlanSchema>;
|
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({
|
export const BillingInfoSchema = z.object({
|
||||||
userEmail: z.string().nullable(),
|
userEmail: z.string().nullable(),
|
||||||
userId: z.string().nullable(),
|
userId: z.string().nullable(),
|
||||||
subscriptionPlan: BillingPlanSchema.nullable(),
|
subscriptionPlan: BillingPlanSchema.nullable(),
|
||||||
subscriptionStatus: z.string().nullable(),
|
subscriptionStatus: z.string().nullable(),
|
||||||
trialExpiresAt: z.string().nullable(),
|
trialExpiresAt: z.string().nullable(),
|
||||||
sanctionedCredits: z.number(),
|
monthly: BillingUsageBucketSchema,
|
||||||
availableCredits: z.number(),
|
daily: BillingUsageBucketSchema.extend({
|
||||||
|
usageDay: z.string(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
export type BillingInfo = z.infer<typeof BillingInfoSchema>;
|
export type BillingInfo = z.infer<typeof BillingInfoSchema>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue