diff --git a/ui/src/Onboarding.module.css b/ui/src/Onboarding.module.css index c6927a2..84765df 100644 --- a/ui/src/Onboarding.module.css +++ b/ui/src/Onboarding.module.css @@ -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); diff --git a/ui/src/Onboarding.test.tsx b/ui/src/Onboarding.test.tsx new file mode 100644 index 0000000..f93beff --- /dev/null +++ b/ui/src/Onboarding.test.tsx @@ -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 = { + 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) { + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/ui/src/Onboarding.tsx b/ui/src/Onboarding.tsx index de22b12..cebf282 100644 --- a/ui/src/Onboarding.tsx +++ b/ui/src/Onboarding.tsx @@ -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'; @@ -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 = { @@ -62,6 +58,10 @@ export function Onboarding({ onComplete }: OnboardingProps) { const [step, setStep] = useState('provider'); const [available, setAvailable] = useState | null>(null); const [provider, setProvider] = useState('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). @@ -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)}`); @@ -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]); @@ -207,7 +214,14 @@ export function Onboarding({ onComplete }: OnboardingProps) {

Pick your LLM CLI

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. +

+

+ ⚠️ These are command-line tools you run in your terminal — not desktop + apps. In particular, Claude Code is the terminal tool, not the + Claude desktop app. Click Download, follow the install guide, then + press Re-check.

{!available ? (

Probing installed CLIs…

@@ -228,40 +242,56 @@ export function Onboarding({ onComplete }: OnboardingProps) { - {PROVIDERS.map((p) => ( -
  • - -
  • - ))} + {PROVIDERS.map((p) => { + const installed = available[p]; + const meta = PROVIDER_META[p]; + return ( +
  • + +
  • + ); + })} )} {!anyAvailable && available && (

    - No supported CLI found on PATH. Install one before continuing — for example,{' '} - - Claude Code - - . + No supported CLI found on PATH. Download one above (Claude Code is the easiest start), + install it, then press Re-check.

    )}
    +