Skip to content

feat(web): People tab on job detail — contacts, evidence links, status chips, one-click draft outreach #293

Description

@Taleef7

Key: #293 · Size: M · Epic: #249 · Program: #243 · Blocked by: #292

Context

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, sonner toast for errors, lucide-react icons, Badge/Button/Card from @/components/ui). All API calls go through apps/web/src/lib/api.tsrequestJson(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

  1. Add to apps/web/src/lib/api.ts (near the agent-output section, following its style):
export type JobContactStatus = 'found' | 'outreach_drafted' | 'contacted';

export interface ContactEvidenceLink {
  url: string;
  note?: string;
}

export interface JobContact {
  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;
}

export type ContactMessageType = 'referral_request' | 'linkedin_connection' | 'recruiter_email';

export interface ScoutRunResponse {
  contacts: JobContact[];
  dropped_unverified: number;
  queries_used: string[];
  model_used: string;
}

export interface ContactOutreachResponse {
  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`. */
export async function fetchJobContacts(jobId: string): Promise<JobContact[]> {
  const response = await requestJson<{ contacts: JobContact[] }>(
    `/api/jobs/${jobId}/contacts`,
    { cache: 'no-store' },
  );
  return response.contacts;
}

/** Run the connection scout for a job — `POST /api/jobs/:id/scout`. */
export async function scoutConnections(jobId: string): Promise<ScoutRunResponse> {
  return requestJson<ScoutRunResponse>(`/api/jobs/${jobId}/scout`, {
    method: 'POST',
    body: '{}',
  });
}

/** Draft outreach to a scouted contact — `POST /api/contacts/:id/draft-outreach`. */
export async function draftContactOutreach(
  contactId: string,
  messageType: ContactMessageType,
): Promise<ContactOutreachResponse> {
  return requestJson<ContactOutreachResponse>(`/api/contacts/${contactId}/draft-outreach`, {
    method: 'POST',
    body: JSON.stringify({ message_type: messageType }),
  });
}

/** Update a contact's status — `PATCH /api/contacts/:id`. */
export async function updateContactStatus(
  contactId: string,
  status: JobContactStatus,
): Promise<JobContact> {
  const response = await requestJson<{ contact: JobContact }>(`/api/contacts/${contactId}`, {
    method: 'PATCH',
    body: JSON.stringify({ status }),
  });
  return response.contact;
}
  1. 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,
  type ContactMessageType,
  type JobContact,
  type JobContactStatus,
} from '@/lib/api';

const STATUS_LABEL: Record<JobContactStatus, string> = {
  found: 'Found',
  outreach_drafted: 'Outreach drafted',
  contacted: 'Contacted',
};

const MESSAGE_TYPES: Array<{ type: ContactMessageType; label: string }> = [
  { type: 'referral_request', label: 'Referral request' },
  { type: 'linkedin_connection', label: 'LinkedIn note' },
  { type: 'recruiter_email', label: 'Recruiter email' },
];

function hostOf(url: string): string {
  try {
    return new URL(url).hostname.replace(/^www\./, '');
  } catch {
    return url;
  }
}

export function JobPeoplePanel({
  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);

  async function handleScout() { /* 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) */ }

  async function handleDraft(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) */
  }

  async function handleMarkContacted(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".

  1. 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):
const [{ job, source }, profile, agentOutputs, contacts] = await Promise.all([
  loadJob(jobId),
  fetchProfile().catch(() => null),
  fetchAgentOutputs(jobId).catch(() => []),
  fetchJobContacts(jobId).catch(() => []),
]);
  • Add the trigger after Outreach: <TabsTrigger value="people">People</TabsTrigger> and the content:
<TabsContent value="people">
  <Card className="p-5">
    <JobPeoplePanel jobId={job.id} initialContacts={contacts} />
  </Card>
</TabsContent>

Testing

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';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { JobPeoplePanel } from './job-people-panel';
import * as api from '@/lib/api';

vi.mock('@/lib/api', async (importOriginal) => ({
  ...(await importOriginal<typeof import('@/lib/api')>()),
  scoutConnections: vi.fn(),
  draftContactOutreach: vi.fn(),
  updateContactStatus: vi.fn(),
}));

const contact: 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(<JobPeoplePanel jobId="j1" initialContacts={[]} />);
    expect(screen.getByRole('button', { name: /scout connections/i })).toBeInTheDocument();
    expect(screen.getByText(/no people found yet/i)).toBeInTheDocument();
  });

  it('renders a contact with evidence link, relevance and status chip', () => {
    render(<JobPeoplePanel jobId="j1" initialContacts={[contact]} />);
    expect(screen.getByText('Jane Doe')).toBeInTheDocument();
    expect(screen.getByText('85% match')).toBeInTheDocument();
    expect(screen.getByText('Found')).toBeInTheDocument();
    const link = 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(<JobPeoplePanel jobId="j1" initialContacts={[]} />);
    await userEvent.click(screen.getByRole('button', { name: /scout connections/i }));
    await waitFor(() => expect(screen.getByText('Jane Doe')).toBeInTheDocument());
    expect(screen.getByText(/2 unverifiable candidate/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(<JobPeoplePanel jobId="j1" initialContacts={[contact]} />);
    await userEvent.click(screen.getByRole('button', { name: /referral request/i }));
    await waitFor(() =>
      expect(api.draftContactOutreach).toHaveBeenCalledWith('c1', 'referral_request'));
    expect(await screen.findByText('Outreach drafted')).toBeInTheDocument();
  });

  it('surfaces API errors as toasts without crashing', async () => {
    vi.mocked(api.scoutConnections).mockRejectedValue(
      new api.ApiRequestError('Daily AI budget reached', 429));
    render(<JobPeoplePanel jobId="j1" initialContacts={[]} />);
    await userEvent.click(screen.getByRole('button', { name: /scout connections/i }));
    await waitFor(() =>
      expect(screen.getByRole('button', { name: /scout connections/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.

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):

  • "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)."

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestjobright-parityJobright-parity program (2026-08 spec)parity-6-scoutParity epic 6: connection scout

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions