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
28 changes: 28 additions & 0 deletions ui/src/Onboarding.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,34 @@
margin-left: auto;
}

/* Per-provider Download link. Sits right after the right-aligned status
(which carries margin-left:auto), so both cluster at the row's right edge. */
.installLink {
margin-left: 0.6rem;
font-size: 0.8rem;
white-space: nowrap;
color: var(--primary);
text-decoration: none;
font-weight: 600;
}

.installLink:hover {
text-decoration: underline;
}

/* Disambiguation callout above the provider list — the crux of the fix:
non-technical users confuse the Claude desktop app with the Claude Code CLI. */
.installHelp {
margin: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
color: var(--text-muted);
font-size: 0.8125rem;
line-height: 1.45;
}

.warn {
background: var(--badge-interview-bg);
color: var(--badge-interview-fg);
Expand Down
89 changes: 89 additions & 0 deletions ui/src/Onboarding.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Onboarding } from './Onboarding.tsx';
import type { Provider } from './settings/types.ts';

const NONE_INSTALLED: Record<Provider, boolean> = {
claude: false,
codex: false,
gemini: false,
opencode: false,
};

// Route every /api/llm-detect probe through `getAvailable`, which is read
// fresh per call so a test can flip availability between probes (Re-check).
function mockDetect(getAvailable: () => Record<Provider, boolean>) {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/llm-detect')) {
return new Response(JSON.stringify({ available: getAvailable() }), { status: 200 });
}
return new Response('not mocked', { status: 500 });
}) as typeof fetch;
}

function downloadLinks(): HTMLAnchorElement[] {
return screen.queryAllByRole('link', { name: /download/i }) as HTMLAnchorElement[];
}

describe('Onboarding — provider step', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('disambiguates the Claude CLI from the desktop app', async () => {
mockDetect(() => NONE_INSTALLED);
render(<Onboarding onComplete={vi.fn()} />);
await waitFor(() => expect(screen.getByText('Auto-detect')).toBeInTheDocument());

expect(screen.getByText(/command-line tools/i)).toBeInTheDocument();
expect(screen.getByText(/Claude desktop app/i)).toBeInTheDocument();
});

it('offers a Download link aimed at the right CLI docs for each provider', async () => {
mockDetect(() => NONE_INSTALLED);
render(<Onboarding onComplete={vi.fn()} />);
await waitFor(() => expect(screen.getByText('Auto-detect')).toBeInTheDocument());

const hrefs = downloadLinks().map((a) => a.getAttribute('href'));
expect(hrefs).toEqual([
'https://code.claude.com/docs/en/quickstart',
'https://github.com/openai/codex',
'https://github.com/google-gemini/gemini-cli',
'https://opencode.ai/docs/',
]);
// Links open safely in a new tab.
for (const a of downloadLinks()) {
expect(a).toHaveAttribute('target', '_blank');
expect(a.getAttribute('rel')).toContain('noopener');
}
});

it('hides the Download link once a provider is installed', async () => {
mockDetect(() => ({ ...NONE_INSTALLED, claude: true }));
render(<Onboarding onComplete={vi.fn()} />);
await waitFor(() => expect(screen.getByText('Auto-detect')).toBeInTheDocument());

// claude installed → its radio is selectable and it carries no Download link.
expect(screen.getByRole('radio', { name: /claude code/i })).toBeEnabled();
expect(downloadLinks()).toHaveLength(3);
expect(downloadLinks().map((a) => a.getAttribute('href'))).not.toContain(
'https://code.claude.com/docs/en/quickstart',
);
});

it('re-probes detection when Re-check is clicked', async () => {
let claudeInstalled = false;
mockDetect(() => ({ ...NONE_INSTALLED, claude: claudeInstalled }));
render(<Onboarding onComplete={vi.fn()} />);
await waitFor(() => expect(screen.getByText('Auto-detect')).toBeInTheDocument());
expect(downloadLinks()).toHaveLength(4);

// User installs Claude Code in another terminal, then hits Re-check.
claudeInstalled = true;
fireEvent.click(screen.getByRole('button', { name: /re-check/i }));

await waitFor(() => expect(downloadLinks()).toHaveLength(3));
expect(screen.getByRole('radio', { name: /claude code/i })).toBeEnabled();
});
});
112 changes: 71 additions & 41 deletions ui/src/Onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { api, formatError } from './lib/api/index.ts';
import { useLlmStream } from './lib/use-llm-stream.ts';
import styles from './Onboarding.module.css';
import { StreamingPanel } from './StreamingPanel.tsx';
import { PROVIDER_META, PROVIDERS, type Provider, type ProviderChoice } from './settings/types.ts';
import bannerStyles from './styles/Banner.module.css';
import buttonStyles from './styles/Button.module.css';
import spinnerStyles from './styles/Spinner.module.css';
Expand All @@ -19,11 +20,6 @@ import spinnerStyles from './styles/Spinner.module.css';
// re-triggers after that, even if the brief gets removed (the regular
// Profile-tab empty state handles re-setup).

type Provider = 'claude' | 'codex' | 'gemini' | 'opencode';
type ProviderChoice = Provider | 'auto';

const PROVIDERS: readonly Provider[] = ['claude', 'codex', 'gemini', 'opencode'];

type CvFormat = 'pdf' | 'docx' | 'md' | 'txt';

const FORMAT_BY_EXT: Record<string, CvFormat> = {
Expand Down Expand Up @@ -62,6 +58,10 @@ export function Onboarding({ onComplete }: OnboardingProps) {
const [step, setStep] = useState<Step>('provider');
const [available, setAvailable] = useState<Record<Provider, boolean> | null>(null);
const [provider, setProvider] = useState<ProviderChoice>('auto');
// Set while /api/llm-detect is in flight. Drives the Re-check button label so
// a user who just installed a CLI in another terminal sees feedback without a
// page refresh.
const [probing, setProbing] = useState(false);
const [busy, setBusy] = useState(false);
// Separate `tuning` state so the button label can show what's actually
// happening when we block on /api/profile-generate (which can take 10–20s).
Expand All @@ -79,11 +79,12 @@ export function Onboarding({ onComplete }: OnboardingProps) {
keywordsChanged?: string[];
}>({ url: '/api/profile-generate' });

// Load installed-CLI status on mount.
useEffect(() => {
const ctrl = new AbortController();
const load = async () => {
const r = await api.llm.detect({ signal: ctrl.signal });
// Probe installed-CLI status. Runs on mount and again whenever the user
// clicks "Re-check" after downloading/installing a CLI.
const probe = useCallback(async (signal?: AbortSignal) => {
setProbing(true);
try {
const r = await api.llm.detect({ signal });
if (!r.ok) {
if (r.error.kind === 'abort') return;
setError(`Could not probe LLM CLIs: ${formatError(r.error)}`);
Expand All @@ -93,11 +94,17 @@ export function Onboarding({ onComplete }: OnboardingProps) {
// Pre-select the first installed CLI as a sensible default.
const firstInstalled = PROVIDERS.find((p) => r.value.available[p]);
if (firstInstalled) setProvider(firstInstalled);
};
void load();
return () => ctrl.abort();
} finally {
setProbing(false);
}
}, []);

useEffect(() => {
const ctrl = new AbortController();
void probe(ctrl.signal);
return () => ctrl.abort();
}, [probe]);

const anyAvailable = useMemo(() => {
if (!available) return false;
return PROVIDERS.some((p) => available[p]);
Expand Down Expand Up @@ -207,7 +214,14 @@ export function Onboarding({ onComplete }: OnboardingProps) {
<h2>Pick your LLM CLI</h2>
<p>
Pupila shells out to a local LLM CLI (no API keys, uses your existing subscription) for
the CV summary, per-job AI review, and AI Apply. Pick whichever you have installed.
the CV summary, per-job AI review, and AI Apply. Pick whichever you have installed — or
download one below.
</p>
<p className={styles.installHelp}>
⚠️ These are <strong>command-line tools</strong> you run in your terminal — not desktop
apps. In particular, <strong>Claude Code</strong> is the terminal tool, <em>not</em> the
Claude desktop app. Click <strong>Download</strong>, follow the install guide, then
press <strong>Re-check</strong>.
</p>
{!available ? (
<p className={styles.placeholder}>Probing installed CLIs…</p>
Expand All @@ -228,40 +242,56 @@ export function Onboarding({ onComplete }: OnboardingProps) {
</span>
</label>
</li>
{PROVIDERS.map((p) => (
<li key={p}>
<label>
<input
type="radio"
name="provider"
value={p}
checked={provider === p}
onChange={() => setProvider(p)}
disabled={!available[p]}
/>
<strong>{p}</strong>
<span className={available[p] ? styles.available : styles.unavailable}>
{available[p] ? '✓ installed' : '✗ not on PATH'}
</span>
</label>
</li>
))}
{PROVIDERS.map((p) => {
const installed = available[p];
const meta = PROVIDER_META[p];
return (
<li key={p}>
<label>
<input
type="radio"
name="provider"
value={p}
checked={provider === p}
onChange={() => setProvider(p)}
disabled={!installed}
/>
<strong>{meta.label}</strong>
<span className={installed ? styles.available : styles.unavailable}>
{installed ? '✓ installed' : '✗ not installed'}
</span>
{!installed && (
<a
className={styles.installLink}
href={meta.installUrl}
target="_blank"
rel="noopener noreferrer"
>
Download ↗
</a>
)}
</label>
</li>
);
})}
</ul>
)}
{!anyAvailable && available && (
<p className={styles.warn}>
No supported CLI found on PATH. Install one before continuing — for example,{' '}
<a
href="https://docs.claude.com/en/docs/claude-code/quickstart"
target="_blank"
rel="noopener noreferrer"
>
Claude Code
</a>
.
No supported CLI found on PATH. Download one above (Claude Code is the easiest start),
install it, then press Re-check.
</p>
)}
<div className={styles.actions}>
<button
type="button"
className={buttonStyles.primary}
disabled={probing || busy}
onClick={() => void probe()}
>
{probing && <span className={spinnerStyles.spinner} aria-hidden />}
{probing ? 'Re-checking…' : 'Re-check'}
</button>
<button
type="button"
className={buttonStyles.secondary}
Expand Down
35 changes: 35 additions & 0 deletions ui/src/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,41 @@ export type Provider = 'claude' | 'codex' | 'gemini' | 'opencode';
export type ProviderChoice = Provider | 'auto';
export const PROVIDERS: readonly Provider[] = ['claude', 'codex', 'gemini', 'opencode'];

/**
* Per-provider display metadata for the LLM-CLI picker.
*
* `label` is the human-facing name and `installUrl` points at the official
* install/quickstart page where the copy-paste install command lives. These
* exist so the onboarding picker can show a friendly name + a Download link:
* non-technical users (e.g. recruiters) routinely confuse "Claude Code" (the
* terminal CLI this app shells out to) with the Claude desktop app, and end up
* installing the wrong thing. Aiming the Download button at the CLI docs fixes
* that at the source.
*/
export interface ProviderMeta {
label: string;
installUrl: string;
}

export const PROVIDER_META: Record<Provider, ProviderMeta> = {
claude: {
label: 'Claude Code',
installUrl: 'https://code.claude.com/docs/en/quickstart',
},
codex: {
label: 'Codex CLI',
installUrl: 'https://github.com/openai/codex',
},
gemini: {
label: 'Gemini CLI',
installUrl: 'https://github.com/google-gemini/gemini-cli',
},
opencode: {
label: 'opencode',
installUrl: 'https://opencode.ai/docs/',
},
};

export interface PreferencesResponse {
provider: ProviderChoice | null;
onboardedAt: string | null;
Expand Down