You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
apps/web is Next.js App Router + Tailwind + shadcn-style components (Base UI primitives under src/components/ui/) + Clerk. The job detail page apps/web/src/app/(app)/jobs/[jobId]/page.tsx is a server component that renders a Tabs block (Analysis / AI agents / Outreach) and server-fetches auxiliary data with .catch() fallbacks so a failure never breaks the page (see how fetchAgentOutputs(jobId).catch(() => []) is wired). Client interactivity lives in 'use client' panels like apps/web/src/components/job-agents-panel.tsx (loading state per action, sonnertoast for errors, lucide-react icons, Badge/Button/Card from @/components/ui). All API calls go through apps/web/src/lib/api.ts → requestJson(path, init) which handles auth (Clerk token server-side, /api/proxy/* in the browser) and throws ApiRequestError with the server's error message. Unit tests are Vitest + Testing Library, colocated as *.test.tsx.
#290/#292 shipped the API: GET /api/jobs/:id/contacts → { contacts: JobContactRecord[] }; POST /api/jobs/:id/scout → { contacts, dropped_unverified, queries_used, model_used } (503 when the agent service is off, 429 on budget); POST /api/contacts/:id/draft-outreach with body { message_type: 'referral_request' | 'linkedin_connection' | 'recruiter_email' } → { subject, draft_text, safety_notes, outreach_id, job_id, contact }; PATCH /api/contacts/:id with { status }. A contact record is { id, jobId, name, roleTitle?, evidence: [{ url, note? }], relevance (0-100), status ('found'|'outreach_drafted'|'contacted'), createdAt, updatedAt }.
This ticket adds the People tab (spec §4.4: "UI: a 'People' tab on job detail listing contacts with evidence links and one-click 'draft outreach'"). Drafted outreach shows up in the existing Outreach tab's "Saved draft" card and is approved/sent through the existing flow — this tab drafts, it never sends.
Scope
API client types + functions in apps/web/src/lib/api.ts.
New client component apps/web/src/components/job-people-panel.tsx.
Wire a People tab into apps/web/src/app/(app)/jobs/[jobId]/page.tsx with server-fetched initial contacts.
Vitest coverage for the panel.
Out of scope:
Any API/agent changes.
Editing contacts (name/role/evidence are agent-verified data; only status is user-mutable).
Playwright e2e (the existing e2e suite is owner-run; unit tests cover this panel).
Notifications/badges outside the job detail page.
Implementation guide
Add to apps/web/src/lib/api.ts (near the agent-output section, following its style):
exporttypeJobContactStatus='found'|'outreach_drafted'|'contacted';exportinterfaceContactEvidenceLink{url: string;note?: string;}exportinterfaceJobContact{id: string;jobId: string;name: string;roleTitle?: string;/** Always non-empty: the API drops contacts without public evidence (fail closed). */evidence: ContactEvidenceLink[];relevance: number;status: JobContactStatus;createdAt: string;updatedAt: string;}exporttypeContactMessageType='referral_request'|'linkedin_connection'|'recruiter_email';exportinterfaceScoutRunResponse{contacts: JobContact[];dropped_unverified: number;queries_used: string[];model_used: string;}exportinterfaceContactOutreachResponse{subject: string;draft_text: string;safety_notes: string;outreach_id: string;job_id: string;contact: JobContact;}/** The verified contacts for a job — `GET /api/jobs/:id/contacts`. */exportasyncfunctionfetchJobContacts(jobId: string): Promise<JobContact[]>{constresponse=awaitrequestJson<{contacts: JobContact[]}>(`/api/jobs/${jobId}/contacts`,{cache: 'no-store'},);returnresponse.contacts;}/** Run the connection scout for a job — `POST /api/jobs/:id/scout`. */exportasyncfunctionscoutConnections(jobId: string): Promise<ScoutRunResponse>{returnrequestJson<ScoutRunResponse>(`/api/jobs/${jobId}/scout`,{method: 'POST',body: '{}',});}/** Draft outreach to a scouted contact — `POST /api/contacts/:id/draft-outreach`. */exportasyncfunctiondraftContactOutreach(contactId: string,messageType: ContactMessageType,): Promise<ContactOutreachResponse>{returnrequestJson<ContactOutreachResponse>(`/api/contacts/${contactId}/draft-outreach`,{method: 'POST',body: JSON.stringify({message_type: messageType}),});}/** Update a contact's status — `PATCH /api/contacts/:id`. */exportasyncfunctionupdateContactStatus(contactId: string,status: JobContactStatus,): Promise<JobContact>{constresponse=awaitrequestJson<{contact: JobContact}>(`/api/contacts/${contactId}`,{method: 'PATCH',body: JSON.stringify({ status }),});returnresponse.contact;}
Create apps/web/src/components/job-people-panel.tsx. Structure (follow job-agents-panel.tsx conventions — 'use client', per-action useState loading flags, toast.error((error as ApiRequestError).message) on failure, Loader2 spinner class animate-spin):
'use client';import{ExternalLink,Loader2,UserSearch}from'lucide-react';import{useState}from'react';import{toast}from'sonner';import{Badge}from'@/components/ui/badge';import{Button}from'@/components/ui/button';import{ApiRequestError,draftContactOutreach,scoutConnections,updateContactStatus,typeContactMessageType,typeJobContact,typeJobContactStatus,}from'@/lib/api';constSTATUS_LABEL: Record<JobContactStatus,string>={found: 'Found',outreach_drafted: 'Outreach drafted',contacted: 'Contacted',};constMESSAGE_TYPES: Array<{type: ContactMessageType;label: string}>=[{type: 'referral_request',label: 'Referral request'},{type: 'linkedin_connection',label: 'LinkedIn note'},{type: 'recruiter_email',label: 'Recruiter email'},];functionhostOf(url: string): string{try{returnnewURL(url).hostname.replace(/^www\./,'');}catch{returnurl;}}exportfunctionJobPeoplePanel({
jobId,
initialContacts,}: {jobId: string;initialContacts: JobContact[];}){const[contacts,setContacts]=useState<JobContact[]>(initialContacts);const[scouting,setScouting]=useState(false);const[droppedNote,setDroppedNote]=useState<number|null>(null);const[busyContactId,setBusyContactId]=useState<string|null>(null);asyncfunctionhandleScout(){/* setScouting; await scoutConnections(jobId); setContacts(result.contacts); setDroppedNote(result.dropped_unverified); toast.success(`Found ${result.contacts.length} people`); catch -> toast.error(message); finally setScouting(false) */}asyncfunctionhandleDraft(contact: JobContact,messageType: ContactMessageType){/* setBusyContactId(contact.id); await draftContactOutreach(contact.id, messageType); replace the contact in state with response.contact; toast.success('Draft saved — review it in the Outreach tab'); catch -> toast.error; finally setBusyContactId(null) */}asyncfunctionhandleMarkContacted(contact: JobContact){/* updateContactStatus(contact.id, 'contacted'); replace in state; toast on error */}// Render:// - Header row: title 'People' explainer 'Recruiters, hiring managers and likely// teammates found on the public web. Every person carries a public source link.'// + Button onClick={handleScout} disabled={scouting}:// {scouting ? <Loader2 className="size-4 animate-spin" /> : <UserSearch className="size-4" />}// {contacts.length ? 'Re-scout' : 'Scout connections'}// - droppedNote !== null && droppedNote > 0: muted line// `${droppedNote} unverifiable candidate(s) were dropped — only people with public evidence are shown.`// - Empty state (no contacts, not scouting): muted paragraph 'No people found yet.// Run the scout to search the public web for this company's recruiters and team.'// - Contact list: for each contact a bordered row/card with// * name (font-medium) + roleTitle (text-muted-foreground text-sm)// * <Badge variant="secondary">{contact.relevance}% match</Badge>// * <Badge variant="outline">{STATUS_LABEL[contact.status]}</Badge>// * evidence links: contact.evidence.map(e =>// <a key={e.url} href={e.url} target="_blank" rel="noopener noreferrer"// className="text-primary inline-flex items-center gap-1 text-xs underline underline-offset-2">// {hostOf(e.url)} <ExternalLink className="size-3" aria-hidden />// </a>)// * actions: three small outline Buttons from MESSAGE_TYPES calling// handleDraft(contact, type), disabled while busyContactId === contact.id,// plus a ghost 'Mark contacted' Button when status !== 'contacted'.}
Implement the commented pseudocode as real code — the handlers are fully specified above; there is no additional behavior to invent. Every evidence anchor must have target="_blank" rel="noopener noreferrer".
Wire the tab into apps/web/src/app/(app)/jobs/[jobId]/page.tsx:
Import: import { JobPeoplePanel } from '@/components/job-people-panel'; and add fetchJobContacts to the existing @/lib/api import.
Extend the parallel fetch (contacts are not load-bearing, same as agent outputs):
apps/web/src/components/job-people-panel.test.tsx (Vitest + Testing Library; mock the API module the way job-agents-panel.test.tsx and assistant-panel.test.tsx mock @/lib/api — copy their vi.mock setup):
import{render,screen,waitFor}from'@testing-library/react';importuserEventfrom'@testing-library/user-event';import{beforeEach,describe,expect,it,vi}from'vitest';import{JobPeoplePanel}from'./job-people-panel';import*asapifrom'@/lib/api';vi.mock('@/lib/api',async(importOriginal)=>({
...(awaitimportOriginal<typeofimport('@/lib/api')>()),scoutConnections: vi.fn(),draftContactOutreach: vi.fn(),updateContactStatus: vi.fn(),}));constcontact: api.JobContact={id: 'c1',jobId: 'j1',name: 'Jane Doe',roleTitle: 'Technical Recruiter',evidence: [{url: 'https://acme.com/team',note: 'returned by web search'}],relevance: 85,status: 'found',createdAt: '2026-08-12T00:00:00Z',updatedAt: '2026-08-12T00:00:00Z',};describe('JobPeoplePanel',()=>{beforeEach(()=>vi.clearAllMocks());it('renders the empty state with a scout button',()=>{render(<JobPeoplePaneljobId="j1"initialContacts={[]}/>);expect(screen.getByRole('button',{name: /scoutconnections/i})).toBeInTheDocument();expect(screen.getByText(/nopeoplefoundyet/i)).toBeInTheDocument();});it('renders a contact with evidence link, relevance and status chip',()=>{render(<JobPeoplePaneljobId="j1"initialContacts={[contact]}/>);expect(screen.getByText('Jane Doe')).toBeInTheDocument();expect(screen.getByText('85% match')).toBeInTheDocument();expect(screen.getByText('Found')).toBeInTheDocument();constlink=screen.getByRole('link',{name: /acme\.com/i});expect(link).toHaveAttribute('href','https://acme.com/team');expect(link).toHaveAttribute('rel','noopener noreferrer');expect(link).toHaveAttribute('target','_blank');});it('scouts and shows returned contacts plus the dropped note',async()=>{vi.mocked(api.scoutConnections).mockResolvedValue({contacts: [contact],dropped_unverified: 2,queries_used: ['q'],model_used: 'm',});render(<JobPeoplePaneljobId="j1"initialContacts={[]}/>);awaituserEvent.click(screen.getByRole('button',{name: /scoutconnections/i}));awaitwaitFor(()=>expect(screen.getByText('Jane Doe')).toBeInTheDocument());expect(screen.getByText(/2unverifiablecandidate/i)).toBeInTheDocument();});it('drafts outreach and updates the status chip',async()=>{vi.mocked(api.draftContactOutreach).mockResolvedValue({subject: 'S',draft_text: 'D',safety_notes: '',outreach_id: 'o1',job_id: 'j1',contact: { ...contact,status: 'outreach_drafted'},});render(<JobPeoplePaneljobId="j1"initialContacts={[contact]}/>);awaituserEvent.click(screen.getByRole('button',{name: /referralrequest/i}));awaitwaitFor(()=>expect(api.draftContactOutreach).toHaveBeenCalledWith('c1','referral_request'));expect(awaitscreen.findByText('Outreach drafted')).toBeInTheDocument();});it('surfaces API errors as toasts without crashing',async()=>{vi.mocked(api.scoutConnections).mockRejectedValue(newapi.ApiRequestError('Daily AI budget reached',429));render(<JobPeoplePaneljobId="j1"initialContacts={[]}/>);awaituserEvent.click(screen.getByRole('button',{name: /scoutconnections/i}));awaitwaitFor(()=>expect(screen.getByRole('button',{name: /scoutconnections/i})).toBeEnabled());});});
Run (from apps/web/): npm test (Vitest) → new suite passes; npm run typecheck; npm run lint; npm run build compiles. Manual: npm run dev, open a job, confirm the People tab renders, empty state → scout (with the API running) → contacts with working evidence links → draft → the draft appears under the Outreach tab's "Saved draft" card.
Acceptance criteria
Job detail shows a fourth tab People; a contacts-fetch failure does not break the page (.catch(() => [])).
Contacts render name, role, NN% match relevance badge, status chip (Found / Outreach drafted / Contacted), and every evidence URL as an external link with target="_blank" + rel="noopener noreferrer".
"Scout connections" runs the scout with a spinner, replaces the list with the response, and shows the dropped_unverified count when > 0 (the fail-closed gate is visible, not hidden).
One-click draft buttons (Referral request / LinkedIn note / Recruiter email) call POST /api/contacts/:id/draft-outreach, flip the chip to Outreach drafted, and direct the user to the Outreach tab for review — the panel contains no send action of any kind.
"Mark contacted" calls PATCH /api/contacts/:id and updates the chip.
API errors (429 budget, 503 agent disabled) surface as toasts with the server message; buttons re-enable.
npm test, npm run typecheck, npm run lint, npm run build green in apps/web; CI green.
"1. Nothing auto-sends. Ever." — this tab drafts only; approval and sending stay in the existing Outreach flow.
"2. Fail closed" — the UI shows the dropped-unverified count instead of hiding it; only evidence-backed contacts are ever displayed.
From spec §4.4: "UI: a 'People' tab on job detail listing contacts with evidence links and one-click 'draft outreach'. No LinkedIn scraping or automation; nothing sends without approval (existing outreach invariant unchanged)."
Context
apps/webis Next.js App Router + Tailwind + shadcn-style components (Base UI primitives undersrc/components/ui/) + Clerk. The job detail pageapps/web/src/app/(app)/jobs/[jobId]/page.tsxis a server component that renders aTabsblock (Analysis/AI agents/Outreach) and server-fetches auxiliary data with.catch()fallbacks so a failure never breaks the page (see howfetchAgentOutputs(jobId).catch(() => [])is wired). Client interactivity lives in'use client'panels likeapps/web/src/components/job-agents-panel.tsx(loading state per action,sonnertoastfor errors,lucide-reacticons,Badge/Button/Cardfrom@/components/ui). All API calls go throughapps/web/src/lib/api.ts→requestJson(path, init)which handles auth (Clerk token server-side,/api/proxy/*in the browser) and throwsApiRequestErrorwith the server's error message. Unit tests are Vitest + Testing Library, colocated as*.test.tsx.#290/#292 shipped the API:
GET /api/jobs/:id/contacts→{ contacts: JobContactRecord[] };POST /api/jobs/:id/scout→{ contacts, dropped_unverified, queries_used, model_used }(503 when the agent service is off, 429 on budget);POST /api/contacts/:id/draft-outreachwith body{ message_type: 'referral_request' | 'linkedin_connection' | 'recruiter_email' }→{ subject, draft_text, safety_notes, outreach_id, job_id, contact };PATCH /api/contacts/:idwith{ status }. A contact record is{ id, jobId, name, roleTitle?, evidence: [{ url, note? }], relevance (0-100), status ('found'|'outreach_drafted'|'contacted'), createdAt, updatedAt }.This ticket adds the People tab (spec §4.4: "UI: a 'People' tab on job detail listing contacts with evidence links and one-click 'draft outreach'"). Drafted outreach shows up in the existing Outreach tab's "Saved draft" card and is approved/sent through the existing flow — this tab drafts, it never sends.
Scope
apps/web/src/lib/api.ts.apps/web/src/components/job-people-panel.tsx.Peopletab intoapps/web/src/app/(app)/jobs/[jobId]/page.tsxwith server-fetched initial contacts.Out of scope:
statusis user-mutable).Implementation guide
apps/web/src/lib/api.ts(near the agent-output section, following its style):apps/web/src/components/job-people-panel.tsx. Structure (followjob-agents-panel.tsxconventions —'use client', per-actionuseStateloading flags,toast.error((error as ApiRequestError).message)on failure,Loader2spinner classanimate-spin):Implement the commented pseudocode as real code — the handlers are fully specified above; there is no additional behavior to invent. Every evidence anchor must have
target="_blank" rel="noopener noreferrer".apps/web/src/app/(app)/jobs/[jobId]/page.tsx:import { JobPeoplePanel } from '@/components/job-people-panel';and addfetchJobContactsto the existing@/lib/apiimport.Outreach:<TabsTrigger value="people">People</TabsTrigger>and the content:Testing
apps/web/src/components/job-people-panel.test.tsx(Vitest + Testing Library; mock the API module the wayjob-agents-panel.test.tsxandassistant-panel.test.tsxmock@/lib/api— copy theirvi.mocksetup):Run (from
apps/web/):npm test(Vitest) → new suite passes;npm run typecheck;npm run lint;npm run buildcompiles. Manual:npm run dev, open a job, confirm the People tab renders, empty state → scout (with the API running) → contacts with working evidence links → draft → the draft appears under the Outreach tab's "Saved draft" card.Acceptance criteria
People; a contacts-fetch failure does not break the page (.catch(() => [])).NN% matchrelevance badge, status chip (Found/Outreach drafted/Contacted), and every evidence URL as an external link withtarget="_blank"+rel="noopener noreferrer".dropped_unverifiedcount when > 0 (the fail-closed gate is visible, not hidden).POST /api/contacts/:id/draft-outreach, flip the chip toOutreach drafted, and direct the user to the Outreach tab for review — the panel contains no send action of any kind.PATCH /api/contacts/:idand updates the chip.npm test,npm run typecheck,npm run lint,npm run buildgreen inapps/web; CI green.Dependencies
Blocked by: #292 (endpoints), and transitively #290. Unblocks: nothing (epic leaf). Completes Epic 6's user-facing surface.
Invariants
From spec §12 (non-negotiable):
From spec §4.4: "UI: a 'People' tab on job detail listing contacts with evidence links and one-click 'draft outreach'. No LinkedIn scraping or automation; nothing sends without approval (existing outreach invariant unchanged)."