Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/desktop/src/renderer/components/Welcome.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
.card:nth-child(1) { animation-delay: 100ms; }
.card:nth-child(2) { animation-delay: 200ms; }
.card:nth-child(3) { animation-delay: 300ms; }
.card:nth-child(4) { animation-delay: 400ms; }

.cardTitle {
font-family: var(--font-sans);
Expand All @@ -96,7 +97,7 @@
align-items: center;
gap: var(--space-3);
animation: fade-in 400ms ease both;
animation-delay: 400ms;
animation-delay: 500ms;
}

.skip {
Expand All @@ -113,3 +114,23 @@
.skip:hover {
color: var(--text-secondary);
}

.hint {
font-family: var(--font-sans);
font-size: var(--text-sm);
color: var(--text-faint);
margin: 0;
animation: fade-in 400ms ease both;
animation-delay: 600ms;
}

.kbd {
display: inline-block;
padding: 1px 6px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
}
10 changes: 9 additions & 1 deletion apps/desktop/src/renderer/components/Welcome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const features = [
title: 'Extensible',
desc: 'Plugins, themes, and AI built in.',
},
{
title: 'Import Your Notes',
desc: 'Import from Obsidian, Markdown folders, or other apps.',
},
] as const;

export function Welcome({ onComplete }: WelcomeProps) {
Expand Down Expand Up @@ -78,9 +82,13 @@ export function Welcome({ onComplete }: WelcomeProps) {
Create Your First Note
</Button>
<Button variant="ghost" onClick={() => onComplete(false)}>
Skip
I'll explore on my own
</Button>
</div>

<p className={styles.hint}>
Pro tip: Press <kbd className={styles.kbd}>Cmd+K</kbd> to open the command palette
</p>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,14 @@
flex-direction: column;
gap: 0.75rem;
}

.resendContent {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}

.spinnerInline {
animation: spin 1s linear infinite;
}
69 changes: 67 additions & 2 deletions apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* Multi-step dialog for passwordless authentication via email magic link.
*/

import { useState, useCallback, useEffect, FormEvent } from 'react';
import { Mail, CheckCircle, AlertCircle, X } from 'lucide-react';
import { useState, useCallback, useEffect, useRef, FormEvent } from 'react';
import { Mail, CheckCircle, AlertCircle, X, RefreshCw } from 'lucide-react';
import { useAuthStore } from '../../stores/authStore';
import styles from './MagicLinkFlow.module.css';

Expand All @@ -22,6 +22,9 @@ export function MagicLinkFlow({ onSuccess, onCancel }: MagicLinkFlowProps) {
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const [isResending, setIsResending] = useState(false);
const cooldownRef = useRef<ReturnType<typeof setInterval> | null>(null);

// Watch for auth success (deep link verified in background)
useEffect(() => {
Expand Down Expand Up @@ -64,8 +67,55 @@ export function MagicLinkFlow({ onSuccess, onCancel }: MagicLinkFlowProps) {
const handleRetry = useCallback(() => {
setStep('email');
setError(null);
setResendCooldown(0);
if (cooldownRef.current) {
clearInterval(cooldownRef.current);
cooldownRef.current = null;
}
}, []);

const startCooldown = useCallback(() => {
setResendCooldown(60);
if (cooldownRef.current) clearInterval(cooldownRef.current);
cooldownRef.current = setInterval(() => {
setResendCooldown(prev => {
if (prev <= 1) {
if (cooldownRef.current) clearInterval(cooldownRef.current);
cooldownRef.current = null;
return 0;
}
return prev - 1;
});
}, 1000);
}, []);

// Start cooldown when entering "sent" step
useEffect(() => {
if (step === 'sent') {
startCooldown();
}
return () => {
if (cooldownRef.current) {
clearInterval(cooldownRef.current);
cooldownRef.current = null;
}
};
}, [step, startCooldown]);

const handleResend = useCallback(async () => {
if (resendCooldown > 0 || isResending) return;
setIsResending(true);
setError(null);
try {
await requestMagicLink(email);
startCooldown();
} catch {
setError('Failed to resend magic link. Please try again.');
Comment on lines +109 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore stale resend results after leaving sent step

The resend handler always applies post-request state updates (startCooldown() on success and setError(...) on failure) after await requestMagicLink(email), even if the user has already clicked “Use different email” and moved back to the email step while that request is in flight. In that case, stale results from the old email can leak into the new flow (e.g., showing a resend failure for the wrong attempt or reintroducing cooldown unexpectedly). Please gate these updates on the current step/request instance, or cancel/ignore the in-flight resend when retrying.

Useful? React with 👍 / 👎.

} finally {
setIsResending(false);
}
}, [email, requestMagicLink, resendCooldown, isResending, startCooldown]);

return (
<div className={styles.overlay} onClick={handleCancel}>
<div className={styles.dialog} onClick={e => e.stopPropagation()}>
Expand Down Expand Up @@ -122,6 +172,21 @@ export function MagicLinkFlow({ onSuccess, onCancel }: MagicLinkFlowProps) {
{error && <p className={styles.errorText}>{error}</p>}

<div className={styles.actions}>
<button
type="button"
className={styles.primaryButton}
onClick={handleResend}
disabled={resendCooldown > 0 || isResending}
>
<span className={styles.resendContent}>
<RefreshCw size={16} className={isResending ? styles.spinnerInline : ''} />
{isResending
? 'Resending...'
: resendCooldown > 0
? `Resend in ${resendCooldown}s`
: 'Resend magic link'}
</span>
</button>
<button type="button" className={styles.secondaryButton} onClick={handleRetry}>
Use different email
</button>
Expand Down
124 changes: 23 additions & 101 deletions apps/desktop/src/renderer/pages/settings/sections/AiSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,31 +334,13 @@ export function AiSection() {
<SettingGroup title="Connection">
{isConnected ? (
/* Connected state */
<div style={{ padding: '1rem 1.25rem' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '1rem 1.25rem',
background: 'rgba(16, 185, 129, 0.08)',
border: '1px solid rgba(16, 185, 129, 0.2)',
borderRadius: '0.75rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<CheckCircle size={20} style={{ color: '#10b981' }} />
<div className={styles.aiConnectionWrapper}>
<div className={styles.aiConnectedBox}>
<div className={styles.aiConnectedInfo}>
<CheckCircle size={20} className={styles.aiConnectedIcon} />
<div>
<div
style={{
fontWeight: 600,
fontSize: '0.875rem',
color: 'var(--text-primary)',
}}
>
Connected to {providerInfo.name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 2 }}>
<div className={styles.aiConnectedTitle}>Connected to {providerInfo.name}</div>
<div className={styles.aiConnectedSubtitle}>
API key stored securely in your system keychain
</div>
</div>
Expand All @@ -375,32 +357,12 @@ export function AiSection() {
</div>
) : (
/* Not connected — show connect flow */
<div style={{ padding: '1rem 1.25rem' }}>
<div
style={{
padding: '1.25rem',
background: 'var(--bg-hover)',
border: '1px solid var(--border-subtle)',
borderRadius: '0.75rem',
}}
>
<div className={styles.aiConnectionWrapper}>
<div className={styles.aiConnectBox}>
{currentProvider !== 'ollama' && (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.75rem',
}}
>
<span
style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--text-primary)',
}}
>
<div className={styles.aiConnectHeader}>
<span className={styles.aiConnectLabel}>
Connect your {providerInfo.name} account
</span>
<Button
Expand All @@ -412,7 +374,7 @@ export function AiSection() {
Get API Key
</Button>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem' }}>
<div className={styles.aiKeyInputRow}>
<input
type="password"
value={apiKeyInput}
Expand All @@ -423,16 +385,7 @@ export function AiSection() {
placeholder={providerInfo.placeholder}
autoComplete="off"
spellCheck={false}
style={{
flex: 1,
padding: '0.625rem 0.875rem',
background: 'var(--bg-base)',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
color: 'var(--text-primary)',
fontSize: '0.875rem',
fontFamily: 'monospace',
}}
className={styles.aiKeyInput}
onKeyDown={e => {
if (e.key === 'Enter') void handleConnect();
}}
Expand All @@ -452,8 +405,8 @@ export function AiSection() {
)}

{currentProvider === 'ollama' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
<div className={styles.aiOllamaInfo}>
<div className={styles.aiOllamaDescription}>
Ollama runs locally — no API key needed. Make sure Ollama is running on your
machine.
</div>
Expand All @@ -470,19 +423,7 @@ export function AiSection() {
)}

{connectError && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginTop: '0.5rem',
padding: '0.5rem 0.75rem',
background: 'rgba(239, 68, 68, 0.08)',
borderRadius: '0.5rem',
fontSize: '0.8125rem',
color: '#ef4444',
}}
>
<div className={styles.aiErrorBox}>
<XCircle size={14} />
{connectError}
</div>
Expand Down Expand Up @@ -548,7 +489,7 @@ export function AiSection() {

{presetMessage?.type === 'success' && (
<div className={styles.successMessage}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span className={styles.aiMessageIcon}>
<CheckCircle size={14} />
{presetMessage.text}
</span>
Expand All @@ -557,42 +498,23 @@ export function AiSection() {

{presetMessage?.type === 'error' && (
<div className={styles.errorMessage}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span className={styles.aiMessageIcon}>
<XCircle size={14} />
{presetMessage.text}
</span>
</div>
)}

{registeredAiCommands.length > 0 && (
<div style={{ padding: '0.75rem 1rem' }}>
<div
style={{
fontSize: '0.8125rem',
color: 'var(--text-secondary)',
marginBottom: '0.5rem',
}}
>
<div className={styles.aiCommandListWrapper}>
<div className={styles.aiCommandListTitle}>
Registered AI Commands ({registeredAiCommands.length})
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
<div className={styles.aiCommandList}>
{registeredAiCommands.map(cmd => (
<div
key={cmd.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.5rem 0.75rem',
background: 'var(--bg-hover)',
borderRadius: '0.5rem',
fontSize: '0.8125rem',
}}
>
<span style={{ color: 'var(--text-primary)' }}>{cmd.name}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>
{cmd.pluginId}
</span>
<div key={cmd.id} className={styles.aiCommandItem}>
<span className={styles.aiCommandName}>{cmd.name}</span>
<span className={styles.aiCommandPlugin}>{cmd.pluginId}</span>
</div>
))}
</div>
Expand Down
Loading
Loading