From 0a3fc3736f947cab298ff3ee544e368394748a1b Mon Sep 17 00:00:00 2001 From: gagan Date: Fri, 22 May 2026 00:10:41 +0530 Subject: [PATCH 01/39] fix: fall back to next port when OAuth callback server can't bind 8080 (#560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fall back to next port when OAuth callback server can't bind 8080 On Windows with Hyper-V/WSL2/Docker, port 8080 is often reserved by the OS (EACCES) or already in use (EADDRINUSE), making sign-in completely impossible. The app now scans 8080–8089 and binds the first available port. For DCR providers, a stale registration locked to a blocked port is detected and cleared so the client re-registers on the new port. Static-client providers (Google BYOK) keep fixed-port behaviour with a clear error message instead of a raw Node.js exception. * fix: keep createAuthServer fixed-port by default, opt-in fallback Address review feedback: - Flip createAuthServer default to fixed-port; fallback is now opt-in via { fallback: true }. Composio (composio-handler.ts) keeps exact-port semantics with no code change — only the Rowboat sign-in call site, which builds its redirect URI from the actual bound port, opts in. - Wrap post-bind setup (DCR, PKCE, auth URL) in try/catch and close the server on any failure so the port is released for retries. * fix: clear stale DCR registration when bound port differs from start port --- apps/x/apps/main/src/auth-server.ts | 68 +++- apps/x/apps/main/src/oauth-handler.ts | 373 +++++++++++-------- apps/x/packages/core/src/auth/client-repo.ts | 28 +- 3 files changed, 302 insertions(+), 167 deletions(-) diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index ad184451..5c46ca3f 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -2,7 +2,8 @@ import { createServer, Server } from 'http'; import { URL } from 'url'; const OAUTH_CALLBACK_PATH = '/oauth/callback'; -const DEFAULT_PORT = 8080; +export const DEFAULT_PORT = 8080; +export const PORT_RANGE_SIZE = 10; /** Escape HTML special characters to prevent XSS */ function escapeHtml(str: string): string { @@ -19,13 +20,8 @@ export interface AuthServerResult { port: number; } -/** - * Create a local HTTP server to handle OAuth callback - * Listens on http://localhost:8080/oauth/callback - * Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds. - */ -export function createAuthServer( - port: number = DEFAULT_PORT, +function tryBindPort( + port: number, onCallback: (callbackUrl: URL) => void | Promise ): Promise { return new Promise((resolve, reject) => { @@ -37,7 +33,7 @@ export function createAuthServer( } const url = new URL(req.url, `http://localhost:${port}`); - + if (url.pathname === OAUTH_CALLBACK_PATH) { const error = url.searchParams.get('error'); @@ -96,8 +92,10 @@ export function createAuthServer( }); server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - reject(new Error(`Port ${port} is already in use`)); + server.close(); + if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { + // Signal caller to try next port + reject(Object.assign(new Error(err.code), { code: err.code })); } else { reject(err); } @@ -105,3 +103,51 @@ export function createAuthServer( }); } +/** + * Create a local HTTP server to handle OAuth callback. + * + * Defaults to fixed-port behaviour: only `port` is tried, and a clear error is + * thrown if it cannot be bound. This is the right behaviour for any provider + * whose redirect URI is pre-registered (Google BYOK, Composio, etc.) — those + * callers must keep using the exact port they've handed to the provider. + * + * Opt into `{ fallback: true }` only when the caller is prepared to use the + * port returned in `AuthServerResult` (i.e. the redirect URI is built from the + * actual bound port, not hard-coded). With fallback enabled, scans `port` + * through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling + * both EADDRINUSE and EACCES (the latter is common on Windows when + * Hyper-V/WSL2 reserve the port). + */ +export async function createAuthServer( + port: number = DEFAULT_PORT, + onCallback: (callbackUrl: URL) => void | Promise, + opts: { fallback?: boolean } = {}, +): Promise { + const fallback = opts.fallback === true; + const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port; + + for (let p = port; p <= limit; p++) { + try { + return await tryBindPort(p, onCallback); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) { + console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}…`); + continue; + } + if (!fallback) { + const reason = code === 'EACCES' || code === 'EADDRINUSE' + ? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.` + : (err instanceof Error ? err.message : String(err)); + throw new Error(reason); + } + throw new Error( + `No available port found in range ${port}–${limit}. Free a port in that range and try again.` + ); + } + } + + // Unreachable — loop always returns or throws — but satisfies TypeScript + throw new Error(`No available port found in range ${port}–${limit}.`); +} + diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index f61b59cc..ab00ab8c 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,6 +1,7 @@ import { shell } from 'electron'; import type { Server } from 'http'; import { createAuthServer } from './auth-server.js'; +import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js'; @@ -17,7 +18,9 @@ import { isSignedIn } from '@x/core/dist/account/account.js'; import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; -const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; +function buildRedirectUri(port: number): string { + return `http://localhost:${port}/oauth/callback`; +} /** Top-level openid-client messages that often wrap a more specific cause. */ const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']); @@ -114,9 +117,15 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { } /** - * Get or create OAuth configuration for a provider + * Get or create OAuth configuration for a provider. + * `redirectUri` is required for DCR providers — it is the actual callback URI + * (including port) that was just bound, so the registration and auth URL stay in sync. */ -async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise { +async function getProviderConfiguration( + provider: string, + redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT), + credentialsOverride?: { clientId: string; clientSecret: string }, +): Promise { const config = await getProviderConfig(provider); const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => { if (config.client.mode === 'static' && config.client.clientId) { @@ -148,7 +157,7 @@ async function getProviderConfiguration(provider: string, credentialsOverride?: console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`); const clientRepo = getClientRegistrationRepo(); const existingRegistration = await clientRepo.getClientRegistration(provider); - + if (existingRegistration) { console.log(`[OAuth] ${provider}: Using existing DCR registration`); return await oauthClient.discoverConfiguration( @@ -157,18 +166,21 @@ async function getProviderConfiguration(provider: string, credentialsOverride?: ); } - // Register new client + // Register new client with the actual redirect URI (port already bound) const scopes = config.scopes || []; const { config: oauthConfig, registration } = await oauthClient.registerClient( config.discovery.issuer, - [REDIRECT_URI], + [redirectUri], scopes ); - - // Save registration for future use - await clientRepo.saveClientRegistration(provider, registration); - console.log(`[OAuth] ${provider}: DCR registration saved`); - + + // Parse port from redirectUri (e.g. "http://localhost:8081/...") and save + const boundPort = new URL(redirectUri).port + ? parseInt(new URL(redirectUri).port, 10) + : DEFAULT_CALLBACK_PORT; + await clientRepo.saveClientRegistration(provider, registration, boundPort); + console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`); + return oauthConfig; } } else { @@ -176,7 +188,7 @@ async function getProviderConfiguration(provider: string, credentialsOverride?: if (config.client.mode !== 'static') { throw new Error('DCR requires discovery mode "issuer", not "static"'); } - + console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`); const { clientId, clientSecret } = await resolveClientCredentials(); return oauthClient.createStaticConfiguration( @@ -189,6 +201,37 @@ async function getProviderConfiguration(provider: string, credentialsOverride?: } } +/** + * Determine which port to start the OAuth callback server on for a DCR provider. + * + * If the provider has an existing registration, probes the port it was registered + * on. If that port is still available, returns it so the existing client_id keeps + * working. If it is blocked, clears the stale registration (forcing re-registration + * on the next available port) and returns DEFAULT_CALLBACK_PORT as the scan base. + * + * Exported for unit testing. + */ +export async function resolveStartPort( + provider: string, + clientRepo: IClientRegistrationRepo, +): Promise { + const existingReg = await clientRepo.getClientRegistration(provider); + if (!existingReg) return DEFAULT_CALLBACK_PORT; + + const registeredPort = await clientRepo.getRegisteredPort(provider); + try { + // Probe — fixed-port (no fallback) so we know whether the exact registered port is free + const probe = await createAuthServer(registeredPort, () => { /* probe */ }); + probe.server.close(); + console.log(`[OAuth] ${provider}: registered port ${registeredPort} still available`); + return registeredPort; + } catch { + console.log(`[OAuth] ${provider}: registered port ${registeredPort} blocked, clearing DCR registration`); + await clientRepo.clearClientRegistration(provider); + return DEFAULT_CALLBACK_PORT; + } +} + /** * Initiate OAuth flow for a provider */ @@ -225,154 +268,188 @@ export async function connectProvider(provider: string, credentials?: { clientId } } - // Get or create OAuth configuration - const config = await getProviderConfiguration(provider, credentials); + // For static-client providers (Google BYOK) the redirect URI is pre-registered + // at the OAuth provider console on a fixed port — we must not scan. + // For DCR providers, resolveStartPort handles the re-registration trap. + const isStaticClient = providerConfig.client.mode === 'static'; + const startPort = isStaticClient + ? DEFAULT_CALLBACK_PORT + : await resolveStartPort(provider, getClientRegistrationRepo()); - // Generate PKCE codes - const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); - const state = oauthClient.generateState(); - - // Get scopes from config - const scopes = providerConfig.scopes || []; - - // Store flow state - activeFlows.set(state, { codeVerifier, provider, config }); - - // Build authorization URL - const authUrl = oauthClient.buildAuthorizationUrl(config, { - redirect_uri: REDIRECT_URI, - scope: scopes.join(' '), - code_challenge: codeChallenge, - state, - }); - - // Create callback server + // --- Callback server --- + // Declare `state` before the closure so the callback can close over its binding. + // The variable is assigned below, before shell.openExternal, so it is always + // set by the time any browser request arrives. + let state = ''; let callbackHandled = false; - const { server } = await createAuthServer(8080, async (callbackUrl) => { - // Guard against duplicate callbacks (browser may send multiple requests) - if (callbackHandled) return; - callbackHandled = true; - const receivedState = callbackUrl.searchParams.get('state'); - if (receivedState == null || receivedState === '') { - throw new Error( - 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.' - ); - } - if (receivedState !== state) { - throw new Error('Invalid state parameter - possible CSRF attack'); - } - const flow = activeFlows.get(state); - if (!flow || flow.provider !== provider) { - throw new Error('Invalid OAuth flow state'); - } - - try { - // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds - console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); - const tokens = await oauthClient.exchangeCodeForTokens( - flow.config, - callbackUrl, - flow.codeVerifier, - state - ); - - // Save tokens and credentials. For Google, BYOK is the only path - // that reaches this token exchange (rowboat path returns above - // before any local server runs); stamp mode: 'byok' so a future - // refresh / reconnect can't get confused with a rowboat entry. - console.log(`[OAuth] Token exchange successful for ${provider}`); - await oauthRepo.upsert(provider, { - tokens, - ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), - ...(provider === 'google' ? { mode: 'byok' as const } : {}), - error: null, - }); - - // Trigger immediate sync for relevant providers - if (provider === 'google') { - triggerGmailSync(); - triggerCalendarSync(); - } else if (provider === 'fireflies-ai') { - triggerFirefliesSync(); + const { server, port: boundPort } = await createAuthServer( + startPort, + async (callbackUrl) => { + // Guard against duplicate callbacks (browser may send multiple requests) + if (callbackHandled) return; + callbackHandled = true; + const receivedState = callbackUrl.searchParams.get('state'); + if (receivedState == null || receivedState === '') { + throw new Error( + 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.' + ); + } + if (receivedState !== state) { + throw new Error('Invalid state parameter - possible CSRF attack'); } - // For Rowboat sign-in, ensure user + Stripe customer exist before - // notifying the renderer. Without this, parallel API calls from - // multiple renderer hooks race to create the user, causing duplicates. - let signedInUserId: string | undefined; - if (provider === 'rowboat') { - try { - const billing = await getBillingInfo(); - if (billing.userId) { - signedInUserId = billing.userId; - analyticsIdentify(billing.userId, { - ...(billing.userEmail ? { email: billing.userEmail } : {}), - plan: billing.subscriptionPlan, - status: billing.subscriptionStatus, - }); - analyticsCapture('user_signed_in', { - plan: billing.subscriptionPlan, - status: billing.subscriptionStatus, - }); + const flow = activeFlows.get(state); + if (!flow || flow.provider !== provider) { + throw new Error('Invalid OAuth flow state'); + } + + try { + // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds + console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); + const tokens = await oauthClient.exchangeCodeForTokens( + flow.config, + callbackUrl, + flow.codeVerifier, + state + ); + + // Save tokens and credentials. For Google, BYOK is the only path + // that reaches this token exchange (rowboat path returns above + // before any local server runs); stamp mode: 'byok' so a future + // refresh / reconnect can't get confused with a rowboat entry. + console.log(`[OAuth] Token exchange successful for ${provider}`); + await oauthRepo.upsert(provider, { + tokens, + ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), + ...(provider === 'google' ? { mode: 'byok' as const } : {}), + error: null, + }); + + // Trigger immediate sync for relevant providers + if (provider === 'google') { + triggerGmailSync(); + triggerCalendarSync(); + } else if (provider === 'fireflies-ai') { + triggerFirefliesSync(); + } + + // For Rowboat sign-in, ensure user + Stripe customer exist before + // notifying the renderer. Without this, parallel API calls from + // multiple renderer hooks race to create the user, causing duplicates. + let signedInUserId: string | undefined; + if (provider === 'rowboat') { + try { + const billing = await getBillingInfo(); + if (billing.userId) { + signedInUserId = billing.userId; + analyticsIdentify(billing.userId, { + ...(billing.userEmail ? { email: billing.userEmail } : {}), + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + analyticsCapture('user_signed_in', { + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + } + } catch (meError) { + console.error('[OAuth] Failed to initialize user via /v1/me:', meError); } - } catch (meError) { - console.error('[OAuth] Failed to initialize user via /v1/me:', meError); } - } - // Emit success event to renderer - emitOAuthEvent({ - provider, - success: true, - ...(signedInUserId ? { userId: signedInUserId } : {}), - }); - } catch (error) { - console.error('OAuth token exchange failed:', error); - // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError) - let cause: unknown = error; - while (cause != null && typeof cause === 'object' && 'cause' in cause) { - cause = (cause as { cause?: unknown }).cause; - if (cause != null) { - console.error('[OAuth] Caused by:', cause); + // Emit success event to renderer + emitOAuthEvent({ + provider, + success: true, + ...(signedInUserId ? { userId: signedInUserId } : {}), + }); + } catch (error) { + console.error('OAuth token exchange failed:', error); + // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError) + let cause: unknown = error; + while (cause != null && typeof cause === 'object' && 'cause' in cause) { + cause = (cause as { cause?: unknown }).cause; + if (cause != null) { + console.error('[OAuth] Caused by:', cause); + } + } + const errorMessage = getOAuthErrorMessage(error); + emitOAuthEvent({ provider, success: false, error: errorMessage }); + throw error; + } finally { + // Clean up + activeFlows.delete(state); + if (activeFlow && activeFlow.state === state) { + clearTimeout(activeFlow.cleanupTimeout); + activeFlow.server.close(); + activeFlow = null; } } - const errorMessage = getOAuthErrorMessage(error); - emitOAuthEvent({ provider, success: false, error: errorMessage }); - throw error; - } finally { - // Clean up + }, + // Static providers (Google BYOK) keep fixed-port behaviour to match the + // pre-registered redirect URI at the provider's console. DCR providers + // can fall back since we register the actual bound port below. + { fallback: !isStaticClient }, + ); + + // Server is bound. Any throw between here and `activeFlow = ...` would + // leak the port — `cancelActiveFlow` only closes it once activeFlow is set. + try { + // TOCTOU guard: resolveStartPort probed the registered port and found it + // free, but the port could have been grabbed between probe and real bind, + // causing fallback to a different port. The cached client_id is registered + // for the old port — clear it so getProviderConfiguration re-registers + // with the actual bound port. + if (!isStaticClient && boundPort !== startPort) { + console.log(`[OAuth] ${provider}: bound port ${boundPort} differs from start port ${startPort}, clearing stale DCR registration`); + await getClientRegistrationRepo().clearClientRegistration(provider); + } + + const redirectUri = buildRedirectUri(boundPort); + const config = await getProviderConfiguration(provider, redirectUri, credentials); + + const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); + state = oauthClient.generateState(); + + const scopes = providerConfig.scopes || []; + activeFlows.set(state, { codeVerifier, provider, config }); + + const authUrl = oauthClient.buildAuthorizationUrl(config, { + redirect_uri: redirectUri, + scope: scopes.join(' '), + code_challenge: codeChallenge, + state, + }); + + // Set timeout to clean up abandoned flows (2 minutes) + const cleanupTimeout = setTimeout(() => { + if (activeFlow?.state === state) { + console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); + cancelActiveFlow('timed_out'); + } + }, 2 * 60 * 1000); + + activeFlow = { + provider, + state, + server, + cleanupTimeout, + }; + + // Open in system browser (shares cookies/sessions with user's regular browser) + shell.openExternal(authUrl.toString()); + + return { success: true }; + } catch (setupError) { + // Post-bind setup failed — close the server so the port is released and + // a retry isn't blocked by our own zombie listener. + server.close(); + if (state) { activeFlows.delete(state); - if (activeFlow && activeFlow.state === state) { - clearTimeout(activeFlow.cleanupTimeout); - activeFlow.server.close(); - activeFlow = null; - } } - }); - - // Set timeout to clean up abandoned flows (2 minutes) - // This prevents memory leaks if user never completes the OAuth flow - const cleanupTimeout = setTimeout(() => { - if (activeFlow?.state === state) { - console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); - cancelActiveFlow('timed_out'); - } - }, 2 * 60 * 1000); // 2 minutes - - // Store complete flow state for cleanup - activeFlow = { - provider, - state, - server, - cleanupTimeout, - }; - - // Open in system browser (shares cookies/sessions with user's regular browser) - shell.openExternal(authUrl.toString()); - - // Wait for callback (server will handle it) - return { success: true }; + throw setupError; + } } catch (error) { console.error('OAuth connection failed:', error); return { diff --git a/apps/x/packages/core/src/auth/client-repo.ts b/apps/x/packages/core/src/auth/client-repo.ts index 62593469..e444e3c2 100644 --- a/apps/x/packages/core/src/auth/client-repo.ts +++ b/apps/x/packages/core/src/auth/client-repo.ts @@ -3,14 +3,21 @@ import fs from 'fs/promises'; import path from 'path'; import { ClientRegistrationResponse } from './types.js'; +export const DEFAULT_CALLBACK_PORT = 8080; + export interface IClientRegistrationRepo { getClientRegistration(provider: string): Promise; - saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise; + /** Returns the port that was used when DCR-registering this provider, or DEFAULT_CALLBACK_PORT if not stored. */ + getRegisteredPort(provider: string): Promise; + saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise; clearClientRegistration(provider: string): Promise; } +// _registeredPort is our private field — stripped by Zod when we parse the RFC response fields +type StoredEntry = Record & { _registeredPort?: number }; + type ClientRegistrationStorage = { - [provider: string]: ClientRegistrationResponse; + [provider: string]: StoredEntry; }; export class FSClientRegistrationRepo implements IClientRegistrationRepo { @@ -45,14 +52,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo { async getClientRegistration(provider: string): Promise { const config = await this.readConfig(); - const registration = config[provider]; - if (!registration) { + const entry = config[provider]; + if (!entry) { return null; } - // Validate registration structure + // Validate registration structure (Zod strips unknown fields like _registeredPort) try { - return ClientRegistrationResponse.parse(registration); + return ClientRegistrationResponse.parse(entry); } catch { // Invalid registration, remove it await this.clearClientRegistration(provider); @@ -60,9 +67,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo { } } - async saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise { + async getRegisteredPort(provider: string): Promise { const config = await this.readConfig(); - config[provider] = registration; + return config[provider]?._registeredPort ?? DEFAULT_CALLBACK_PORT; + } + + async saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise { + const config = await this.readConfig(); + config[provider] = { ...registration, _registeredPort: port }; await this.writeConfig(config); } From 5be0a11f9830d10c607a5ecefad12f44efe48a87 Mon Sep 17 00:00:00 2001 From: gagan Date: Fri, 22 May 2026 00:20:35 +0530 Subject: [PATCH 02/39] feat: show out-of-credits dialog instead of raw API error (#568) * feat: show billing error dialog on out-of-credits errors * fix: suppress chat dump and toast when billing dialog shows --- apps/x/apps/renderer/src/App.tsx | 32 ++++++++- .../src/components/billing-error-dialog.tsx | 61 ++++++++++++++++ .../renderer/src/components/chat-sidebar.tsx | 70 +------------------ apps/x/apps/renderer/src/lib/billing-error.ts | 26 +++++++ 4 files changed, 121 insertions(+), 68 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/billing-error-dialog.tsx create mode 100644 apps/x/apps/renderer/src/lib/billing-error.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e6c050b3..79a2b266 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -63,6 +63,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" +import { BillingErrorDialog } from "@/components/billing-error-dialog" +import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' @@ -793,7 +795,25 @@ function App() { // Chat state const [, setMessage] = useState('') const [conversation, setConversation] = useState([]) + const [billingErrorMatch, setBillingErrorMatch] = useState(null) + const [billingErrorOpen, setBillingErrorOpen] = useState(false) + const lastHandledBillingErrorIdRef = useRef(null) const [currentAssistantMessage, setCurrentAssistantMessage] = useState('') + + useEffect(() => { + for (let i = conversation.length - 1; i >= 0; i--) { + const item = conversation[i] + if (!isErrorMessage(item)) continue + if (item.id === lastHandledBillingErrorIdRef.current) return + const match = matchBillingError(item.message) + if (match) { + lastHandledBillingErrorIdRef.current = item.id + setBillingErrorMatch(match) + setBillingErrorOpen(true) + } + return + } + }, [conversation]) const [, setModelUsage] = useState(null) const [runId, setRunId] = useState(null) const runIdRef = useRef(null) @@ -2271,7 +2291,9 @@ function App() { message: event.error, timestamp: Date.now(), }]) - toast.error(event.error.split('\n')[0] || 'Model error') + if (!matchBillingError(event.error)) { + toast.error(event.error.split('\n')[0] || 'Model error') + } console.error('Run error:', event.error) break } @@ -4629,6 +4651,9 @@ function App() { } if (isErrorMessage(item)) { + if (matchBillingError(item.message)) { + return null + } return ( @@ -5399,6 +5424,11 @@ function App() { /> + void +} + +export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) { + const [appUrl, setAppUrl] = useState(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 ( + + + + {match.title} + {match.subtitle} + + + + + + + + ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 06680652..3b4021c8 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -52,6 +52,7 @@ import { parseAttachedFiles, toToolState, } from '@/lib/chat-conversation' +import { matchBillingError } from '@/lib/billing-error' const streamdownComponents = { pre: MarkdownPreOverride } @@ -85,60 +86,6 @@ function AutoScrollPre({ className, children }: { className?: string; children: ) } -/* ─── Billing error helpers ─── */ - -const BILLING_ERROR_PATTERNS = [ - { - pattern: /upgrade required/i, - title: 'A subscription is required', - subtitle: 'Get started with a plan to access AI features in Rowboat.', - cta: 'Subscribe', - }, - { - pattern: /not enough credits/i, - title: 'You\'ve run out of credits', - subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.', - cta: 'Upgrade plan', - }, - { - pattern: /subscription not active/i, - title: 'Your subscription is inactive', - subtitle: 'Reactivate your subscription to continue using AI features.', - cta: 'Reactivate', - }, -] as const - -function matchBillingError(message: string) { - return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null -} - -interface BillingRowboatAccount { - config?: { - appUrl?: string | null - } | null -} - -function BillingErrorCTA({ label }: { label: string }) { - const [appUrl, setAppUrl] = useState(null) - - useEffect(() => { - window.ipc.invoke('account:getRowboat', null) - .then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null)) - .catch(() => {}) - }, []) - - if (!appUrl) return null - - return ( - - ) -} - const MIN_WIDTH = 360 const MAX_WIDTH = 1600 const MIN_MAIN_PANE_WIDTH = 420 @@ -491,19 +438,8 @@ export function ChatSidebar({ } if (isErrorMessage(item)) { - const billingError = matchBillingError(item.message) - if (billingError) { - return ( - - -
-

{billingError.title}

-

{billingError.subtitle}

- -
-
-
- ) + if (matchBillingError(item.message)) { + return null } return ( diff --git a/apps/x/apps/renderer/src/lib/billing-error.ts b/apps/x/apps/renderer/src/lib/billing-error.ts new file mode 100644 index 00000000..ffb77470 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/billing-error.ts @@ -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 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 + +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 +} From 346c685ac9b06a3b6135c513aa4e7f8f29ba5e74 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 22 May 2026 15:46:33 +0530 Subject: [PATCH 03/39] new chat icon/shortcut, workspaces, and knowledge browsing Move new chat to a top-bar icon with a Cmd+N shortcut, introduce the Workspaces concept (workspace sidebar + default the working-directory picker to it), and expand the knowledge browser (view more). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/x/apps/renderer/src/App.tsx | 362 +++++++-- .../components/chat-input-with-mentions.tsx | 14 +- .../src/components/html-file-viewer.tsx | 58 +- .../src/components/knowledge-view.tsx | 405 ++++++++++ .../src/components/sidebar-content.tsx | 742 ++++-------------- .../src/components/workspace-view.tsx | 262 +++++++ 6 files changed, 1139 insertions(+), 704 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/knowledge-view.tsx create mode 100644 apps/x/apps/renderer/src/components/workspace-view.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 79a2b266..b641575c 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -25,6 +25,8 @@ import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; +import { WorkspaceView } from '@/components/workspace-view'; +import { KnowledgeView } from '@/components/knowledge-view'; import { MeetingsView } from '@/components/meetings-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { @@ -184,6 +186,9 @@ const MEETINGS_TAB_PATH = '__rowboat_meetings__' const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__' const EMAIL_TAB_PATH = '__rowboat_email__' +const WORKSPACE_TAB_PATH = '__rowboat_workspace__' +const WORKSPACE_ROOT = 'knowledge/Workspace' +const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -317,6 +322,8 @@ const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH +const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH +const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -567,12 +574,15 @@ type ViewState = | { type: 'meetings' } | { type: 'live-notes' } | { type: 'email' } + | { type: 'workspace'; path?: string } + | { type: 'knowledge-view' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false if (a.type === 'chat' && b.type === 'chat') return a.runId === b.runId if (a.type === 'file' && b.type === 'file') return a.path === b.path if (a.type === 'task' && b.type === 'task') return a.name === b.name + if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '') return true // both graph } @@ -616,6 +626,12 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'meetings' } case 'live-notes': return { type: 'live-notes' } + case 'workspace': { + const path = params.get('path') + return { type: 'workspace', path: path ?? undefined } + } + case 'knowledge-view': + return { type: 'knowledge-view' } default: return null } @@ -624,12 +640,16 @@ function parseDeepLink(input: string): ViewState | null { /** Sidebar toggle (fixed position, top-left) */ function FixedSidebarToggle({ leftInsetPx, + onNewChat, + onOpenSearch, }: { leftInsetPx: number + onNewChat?: () => void + onOpenSearch?: () => void }) { const { toggleSidebar } = useSidebar() return ( -
+
) } @@ -725,6 +767,9 @@ function App() { const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false) const [isBgTasksOpen, setIsBgTasksOpen] = useState(false) const [isEmailOpen, setIsEmailOpen] = useState(false) + const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false) + const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) + const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null graph: boolean @@ -1079,6 +1124,8 @@ function App() { if (isLiveNotesTabPath(tab.path)) return 'Live notes' if (isBgTasksTabPath(tab.path)) return 'Background tasks' if (isEmailTabPath(tab.path)) return 'Email' + if (isWorkspaceTabPath(tab.path)) return 'Workspace' + if (isKnowledgeViewTabPath(tab.path)) return 'Knowledge' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -2793,7 +2840,7 @@ function App() { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setSelectedPath(path) return } @@ -2802,7 +2849,7 @@ function App() { setActiveFileTabId(id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setSelectedPath(path) }, [fileTabs, dismissBrowserOverlay]) @@ -2821,14 +2868,14 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) return } if (isSuggestedTopicsTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) return } if (isLiveNotesTabPath(tab.path)) { @@ -2838,6 +2885,8 @@ function App() { setIsMeetingsOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setIsLiveNotesOpen(true) return } @@ -2847,6 +2896,9 @@ function App() { setIsSuggestedTopicsOpen(false) setIsMeetingsOpen(false) setIsLiveNotesOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setIsBgTasksOpen(true) return } @@ -2857,26 +2909,56 @@ function App() { setIsMeetingsOpen(true) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) return } if (isEmailTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setIsEmailOpen(true) return } + if (isWorkspaceTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsKnowledgeViewOpen(false) + setIsWorkspaceOpen(true) + return + } + if (isKnowledgeViewTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(true) + return + } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setSelectedPath(tab.path) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path) @@ -2899,7 +2981,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2913,12 +2995,12 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) } else if (isMeetingsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -2927,6 +3009,8 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) } else if (isLiveNotesTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -2934,6 +3018,8 @@ function App() { setIsMeetingsOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setIsLiveNotesOpen(true) } else if (isBgTasksTabPath(newActiveTab.path)) { setSelectedPath(null) @@ -2943,6 +3029,8 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(true) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) } else if (isEmailTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -2950,11 +3038,33 @@ function App() { setIsMeetingsOpen(false) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setIsEmailOpen(true) + } else if (isWorkspaceTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsKnowledgeViewOpen(false) + setIsWorkspaceOpen(true) + } else if (isKnowledgeViewTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(true) } else { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setSelectedPath(newActiveTab.path) } } @@ -2985,7 +3095,7 @@ function App() { dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -3002,8 +3112,8 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) - }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen]) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -3135,7 +3245,7 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -3151,19 +3261,19 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay]) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) } else if (expandedFrom.suggestedTopics) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) } else if (expandedFrom.meetings) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) @@ -3195,7 +3305,7 @@ function App() { } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -3209,10 +3319,12 @@ function App() { if (isMeetingsOpen) return { type: 'meetings' } if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } + if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } + if (isKnowledgeViewOpen) return { type: 'knowledge-view' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3313,6 +3425,28 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureWorkspaceFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isWorkspaceTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: WORKSPACE_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + + const ensureKnowledgeViewFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isKnowledgeViewTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: KNOWLEDGE_VIEW_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const openEmailView = useCallback(() => { setSelectedPath(null) setIsGraphOpen(false) @@ -3321,6 +3455,8 @@ function App() { setIsMeetingsOpen(false) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3333,7 +3469,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3350,6 +3486,8 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3365,7 +3503,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -3380,7 +3518,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -3393,7 +3531,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -3406,7 +3544,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) ensureSuggestedTopicsFileTab() return case 'meetings': @@ -3421,6 +3559,8 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) ensureMeetingsFileTab() return case 'live-notes': @@ -3434,6 +3574,8 @@ function App() { setIsMeetingsOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setIsLiveNotesOpen(true) ensureLiveNotesFileTab() return @@ -3449,8 +3591,43 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(true) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) ensureEmailFileTab() return + case 'workspace': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(true) + setIsKnowledgeViewOpen(false) + setWorkspaceInitialPath(view.path ?? null) + ensureWorkspaceFileTab() + return + case 'knowledge-view': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(true) + ensureKnowledgeViewFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -3459,7 +3636,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -3467,7 +3644,7 @@ function App() { } return } - }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -3789,7 +3966,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3817,6 +3994,18 @@ function App() { return () => document.removeEventListener('keydown', handleKeyDown) }, []) + // Keyboard shortcut: Cmd+N / Ctrl+N opens a new chat tab. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'n') { + e.preventDefault() + handleNewChatTab() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleNewChatTab]) + // Route undo/redo to the active markdown tab only (prevents cross-tab browser undo behavior). useEffect(() => { const handleHistoryKeyDown = (e: KeyboardEvent) => { @@ -3862,11 +4051,11 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen @@ -3879,6 +4068,10 @@ function App() { ? BG_TASKS_TAB_PATH : isEmailOpen ? EMAIL_TAB_PATH + : isWorkspaceOpen + ? WORKSPACE_TAB_PATH + : isKnowledgeViewOpen + ? KNOWLEDGE_VIEW_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -3933,7 +4126,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3958,7 +4151,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4084,19 +4277,49 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) }, + openWorkspaceAt: (path?: string) => { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { + setIsChatSidebarOpen(false) + setIsRightPaneMaximized(false) + } + void navigateToView({ type: 'workspace', path }) + }, + openKnowledgeView: () => { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { + setIsChatSidebarOpen(false) + setIsRightPaneMaximized(false) + } + void navigateToView({ type: 'knowledge-view' }) + }, + createWorkspace: async (name: string): Promise => { + const trimmed = name.trim() + if (!trimmed) throw new Error('Name is required') + if (trimmed.includes('/')) throw new Error('Name cannot contain "/"') + const rootExists = await window.ipc.invoke('workspace:exists', { path: WORKSPACE_ROOT }) + if (!rootExists.exists) { + await window.ipc.invoke('workspace:mkdir', { path: WORKSPACE_ROOT, recursive: true }) + } + const target = `${WORKSPACE_ROOT}/${trimmed}` + const exists = await window.ipc.invoke('workspace:exists', { path: target }) + if (exists.exists) { + throw new Error(`A workspace named "${trimmed}" already exists`) + } + await window.ipc.invoke('workspace:mkdir', { path: target, recursive: true }) + return target + }, expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), collapseAll: () => setExpandedPaths(new Set()), rename: async (oldPath: string, newName: string, isDir: boolean) => { @@ -4690,7 +4913,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4707,7 +4930,7 @@ function App() { return ( { - if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> @@ -4721,18 +4944,8 @@ function App() { { - setExpandedPaths((prev) => { - const next = new Set(prev) - if (next.has(path)) next.delete(path) - else next.add(path) - return next - }) - }} knowledgeActions={knowledgeActions} - onVoiceNoteCreated={handleVoiceNoteCreated} runs={runs} currentRunId={runId} processingRunIds={processingRunIds} @@ -4740,7 +4953,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4751,7 +4964,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4775,14 +4988,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4800,8 +5013,6 @@ function App() { }} backgroundTasks={backgroundTasks} selectedBackgroundTask={selectedBackgroundTask} - onNewChat={handleNewChatTab} - onOpenSearch={() => setIsSearchOpen(true)} isSearchOpen={isSearchOpen} isBrowserOpen={isBrowserOpen} onToggleBrowser={handleToggleBrowser} @@ -4809,8 +5020,6 @@ function App() { onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} isMeetingsOpen={isMeetingsOpen} onOpenMeetings={openMeetingsView} - isLiveNotesOpen={isLiveNotesOpen} - onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })} isBgTasksOpen={isBgTasksOpen} onOpenBgTasks={openBgTasksView} isEmailOpen={isEmailOpen} @@ -4834,7 +5043,7 @@ function App() { canNavigateForward={canNavigateForward} collapsedLeftPaddingPx={collapsedLeftPaddingPx} > - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedTask && !isBrowserOpen && (
+ ) : isWorkspaceOpen ? ( +
+ navigateToFile(path)} + onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }} + /> +
+ ) : isKnowledgeViewOpen ? ( +
+ navigateToFile(path)} + onOpenGraph={() => knowledgeActions.openGraph()} + onOpenSearch={() => setIsSearchOpen(true)} + onOpenBases={() => knowledgeActions.openBases()} + onVoiceNoteCreated={handleVoiceNoteCreated} + /> +
) : selectedPath && isBaseFilePath(selectedPath) ? (
setIsSearchOpen(true)} />
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index ccd96805..97386508 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -274,9 +274,21 @@ function ChatInputInner({ const handleSetWorkDir = useCallback(async () => { try { + let defaultPath: string | undefined = workDir ?? undefined + try { + const { root } = await window.ipc.invoke('workspace:getRoot', null) + const workspaceRel = 'knowledge/Workspace' + const exists = await window.ipc.invoke('workspace:exists', { path: workspaceRel }) + if (!exists.exists) { + await window.ipc.invoke('workspace:mkdir', { path: workspaceRel, recursive: true }) + } + defaultPath = `${root.replace(/\/$/, '')}/${workspaceRel}` + } catch (err) { + console.error('Failed to resolve Workspace path; falling back to current workDir', err) + } const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose work directory', - defaultPath: workDir ?? undefined, + defaultPath, }) if (!chosen) return await window.ipc.invoke('workspace:writeFile', { diff --git a/apps/x/apps/renderer/src/components/html-file-viewer.tsx b/apps/x/apps/renderer/src/components/html-file-viewer.tsx index 994ab73e..8343af28 100644 --- a/apps/x/apps/renderer/src/components/html-file-viewer.tsx +++ b/apps/x/apps/renderer/src/components/html-file-viewer.tsx @@ -1,33 +1,11 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react' const MAX_SIZE_BYTES = 5 * 1024 * 1024 -const CACHE_MAX_ENTRIES = 20 - -type CacheEntry = { html: string; mtimeMs: number; size: number } -const htmlCache = new Map() - -function getCached(path: string, mtimeMs: number, size: number): string | null { - const entry = htmlCache.get(path) - if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) return null - // Refresh LRU position - htmlCache.delete(path) - htmlCache.set(path, entry) - return entry.html -} - -function setCached(path: string, html: string, mtimeMs: number, size: number) { - htmlCache.set(path, { html, mtimeMs, size }) - while (htmlCache.size > CACHE_MAX_ENTRIES) { - const oldest = htmlCache.keys().next().value - if (oldest === undefined) break - htmlCache.delete(oldest) - } -} type ViewerState = | { kind: 'loading' } - | { kind: 'loaded'; html: string } + | { kind: 'loaded' } | { kind: 'empty' } | { kind: 'tooLarge'; sizeMB: number } | { kind: 'error'; message: string } @@ -36,9 +14,15 @@ interface HtmlFileViewerProps { path: string } +function toAppWorkspaceUrl(path: string): string { + const segments = path.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg)) + return `app://workspace/${segments.join('/')}` +} + export function HtmlFileViewer({ path }: HtmlFileViewerProps) { const [state, setState] = useState({ kind: 'loading' }) const [iframeLoaded, setIframeLoaded] = useState(false) + const iframeSrc = useMemo(() => toAppWorkspaceUrl(path), [path]) useEffect(() => { let cancelled = false @@ -57,19 +41,11 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) { setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) }) return } - const cachedHtml = getCached(path, stat.mtimeMs, stat.size) - if (cachedHtml !== null) { - setState(cachedHtml.trim() === '' ? { kind: 'empty' } : { kind: 'loaded', html: cachedHtml }) - return - } - const result = await window.ipc.invoke('workspace:readFile', { path }) - if (cancelled) return - setCached(path, result.data, stat.mtimeMs, stat.size) - if (!result.data || result.data.trim() === '') { + if (stat.size === 0) { setState({ kind: 'empty' }) return } - setState({ kind: 'loaded', html: result.data }) + setState({ kind: 'loaded' }) } catch (err) { if (cancelled) return const message = err instanceof Error ? err.message : String(err) @@ -124,20 +100,16 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) { ) } - // We use `srcDoc` here (not `src=app://workspace/`) so the iframe - // gets a null origin with no base URL. Trade-off: relative assets inside - // the file — ``, ``, - // `