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
2 changes: 2 additions & 0 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Board sections: prospect (remote), local (local/hybrid), staffing (contract), ap
Use the available tools to read and mutate the board. After completing an action, confirm briefly what you did. Keep replies short.
When add_job returns a detailUrl for a pending or applied job, include a final markdown link exactly like: [View Job](detailUrl).

Whenever you list multiple jobs from list_jobs in a table, keep it to exactly two columns — the chat panel is narrow, and a long role name wraps badly once there's a third column competing for width. Column 1 is "Job": "Company — [Role](detailUrl)", with only the role text linked, never the company. Column 2 is one status-like field relevant to the section — "Status" for applied jobs, "Fit" for prospect/local/staffing jobs. Omit salary and date from the table by default; mention them in prose after the table, or add a column, only if the user specifically asked about comp or timing. Header row stays plain text ("| Job | Status |"); only body-row cells carry the link, e.g. "| Anthropic — [Head of Product Design](/job?section=prospect&company=Anthropic&role=Head+of+Product+Design) | Strong fit |". Never add a separate "Link" column or a trailing "[View](url)" — the role name is already the link. Only use the exact detailUrl string returned by list_jobs for that job — never invent, guess, or fall back to "/" or any other placeholder URL. If a job wasn't returned by list_jobs in this conversation, don't link it. For a single job, or a short list, plain prose or a bulleted list ("Company — [Role](detailUrl)") is fine — don't force a table.

When the user asks about fit, their background, how their experience compares to a role, or anything about the candidate's qualifications, call read_profile first — never ask the user to describe their own experience.

When the user asks to re-assess, re-score, or evaluate fit for the current job: call read_profile, then call fetch_job_description with the job URL if you don't already have the full JD, then call update_job with both fit (strong/good/caution/weak) and scoreRationale explaining the reasoning. Always write both fields together.
Expand Down
81 changes: 71 additions & 10 deletions components/MarkdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,31 @@ function InlineParsed({ text }: { text: string }) {
return <>{parseInline(text)}</>;
}

// Collects consecutive list-item lines matching itemPattern, tolerating a
// single blank line between items — LLMs commonly emit "loose" lists with
// blank-line spacing, and treating that as list-end would split one list
// into several single-item lists (each <ol> restarting its own count at 1).
function collectListItems(
lines: string[],
startIndex: number,
itemPattern: RegExp,
stripPattern: RegExp
): { items: string[]; nextIndex: number } {
const items: string[] = [];
let i = startIndex;
while (i < lines.length) {
if (itemPattern.test(lines[i])) {
items.push(lines[i].replace(stripPattern, ""));
i++;
} else if (lines[i].trim() === "" && i + 1 < lines.length && itemPattern.test(lines[i + 1])) {
i++; // blank line between items — keep the list going
} else {
break;
}
}
return { items, nextIndex: i };
}

interface Props {
text: string;
className?: string;
Expand Down Expand Up @@ -96,13 +121,52 @@ export function MarkdownContent({ text, className }: Props) {
continue;
}

// Unordered list
if (/^[-*]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^[-*]\s/.test(lines[i])) {
items.push(lines[i].replace(/^[-*]\s+/, ""));
// Pipe table — a header row, a separator row of only -, :, |, and
// whitespace, then one or more body rows, all starting with "|".
if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|?[\s:|-]+\|?$/.test(lines[i + 1].trim())) {
const splitRow = (row: string) =>
row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());

const headerCells = splitRow(line);
i += 2; // skip header + separator
const bodyRows: string[][] = [];
while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) {
bodyRows.push(splitRow(lines[i]));
i++;
}
nodes.push(
<div key={key++} className="my-2 overflow-x-auto">
<table className="text-sm border-collapse">
<thead>
<tr>
{headerCells.map((cell, idx) => (
<th key={idx} className="text-left font-semibold px-2 py-1 border-b border-stone-300 dark:border-white/20 whitespace-nowrap">
<InlineParsed text={cell} />
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, rIdx) => (
<tr key={rIdx}>
{row.map((cell, cIdx) => (
<td key={cIdx} className="px-2 py-1 border-b border-stone-100 dark:border-white/10 align-top">
<InlineParsed text={cell} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
continue;
}

// Unordered list
if (/^[-*]\s/.test(line)) {
const { items, nextIndex } = collectListItems(lines, i, /^[-*]\s/, /^[-*]\s+/);
i = nextIndex;
nodes.push(
<ul key={key++} className="my-1.5 ml-4 space-y-0.5 list-disc list-outside">
{items.map((it, idx) => (
Expand All @@ -117,11 +181,8 @@ export function MarkdownContent({ text, className }: Props) {

// Ordered list
if (/^\d+\.\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
items.push(lines[i].replace(/^\d+\.\s+/, ""));
i++;
}
const { items, nextIndex } = collectListItems(lines, i, /^\d+\.\s/, /^\d+\.\s+/);
i = nextIndex;
nodes.push(
<ol key={key++} className="my-1.5 ml-4 space-y-0.5 list-decimal list-outside">
{items.map((it, idx) => (
Expand Down
91 changes: 91 additions & 0 deletions components/__tests__/MarkdownContent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import { MarkdownContent } from "@/components/MarkdownContent";

describe("MarkdownContent", () => {
// Z — Zero: empty input renders nothing
it("renders no list items when given an empty string", () => {
const { container } = render(<MarkdownContent text="" />);
expect(container.querySelectorAll("li")).toHaveLength(0);
});

// O — One: a single tight ordered-list item renders inside one <ol>
it("renders a single ordered-list item inside one ol", () => {
render(<MarkdownContent text="1. Only item" />);
const ol = screen.getByRole("list");
expect(ol.tagName).toBe("OL");
expect(screen.getAllByRole("listitem")).toHaveLength(1);
});

describe("when an ordered list has blank lines between items", () => {
// M — Many: this is the reported bug — a "loose" list (blank line between
// each numbered item, which LLMs commonly emit) must still collapse into
// one <ol>, not one <ol> per item. A separate <ol> per item would make
// every item show "1." since each new <ol> restarts its own count.
it("collapses all items into a single ol regardless of the literal numbers used", () => {
const text = [
"1. Search RemoteOK",
"",
"1. Fetch a specific Greenhouse job",
"",
"1. Review your current board",
].join("\n");
render(<MarkdownContent text={text} />);
expect(screen.getAllByRole("list")).toHaveLength(1);
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});

// B — Boundary: the browser's native <ol> numbering (not our own counters)
// is what proves each item gets a distinct, incrementing number.
it("assigns each list item a distinct position so numbering increments natively", () => {
const text = ["1. First", "", "1. Second", "", "1. Third"].join("\n");
render(<MarkdownContent text={text} />);
const items = screen.getAllByRole("listitem");
expect(items.map((li) => li.textContent)).toEqual(["First", "Second", "Third"]);
});
});

describe("when an unordered list has blank lines between items", () => {
// Same defect applies to bullet lists — less visible (no number to repeat)
// but still wrong: it silently produces N one-item <ul>s instead of one.
it("collapses all items into a single ul", () => {
const text = ["- Search RemoteOK", "", "- Check the board"].join("\n");
render(<MarkdownContent text={text} />);
expect(screen.getAllByRole("list")).toHaveLength(1);
expect(screen.getAllByRole("listitem")).toHaveLength(2);
});
});

// I — Interface: two genuinely separate lists (more than one blank line, or
// intervening prose) must still stay separate, not merge into one.
it("keeps two ordered lists separate when prose falls between them", () => {
const text = ["1. First list item", "", "Some prose in between.", "", "1. Second list item"].join("\n");
render(<MarkdownContent text={text} />);
expect(screen.getAllByRole("list")).toHaveLength(2);
});

// E — Exception: a link with a real detailUrl still renders as a link inside a list item.
it("renders a markdown link inside a list item as a real anchor", () => {
render(<MarkdownContent text="1. [Anthropic](/job?section=prospect&company=Anthropic&role=Head)" />);
expect(screen.getByRole("link", { name: "Anthropic" })).toHaveAttribute(
"href",
"/job?section=prospect&company=Anthropic&role=Head"
);
});

// S — Simple: end-to-end happy path mirroring a real chat reply.
it("renders a full reply with bold text, a loose ordered list, and links", () => {
const text = [
"You applied to **7 jobs in June**:",
"",
"1. Figma — [Director of Product Design](/job?section=applied&company=Figma&role=Director)",
"",
"2. Stripe — [Creative Director](/job?section=applied&company=Stripe&role=Creative+Director)",
].join("\n");
render(<MarkdownContent text={text} />);
expect(screen.getByText("7 jobs in June")).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(2);
expect(screen.getByRole("link", { name: "Director of Product Design" })).toBeInTheDocument();
});
});
12 changes: 10 additions & 2 deletions lib/chat-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,16 @@ export async function executeTool(

case "list_jobs": {
const section = input.section as string | undefined;
if (!section || section === "all") return JSON.stringify(jobs, null, 2);
return JSON.stringify((jobs[section as JobSection] ?? []), null, 2);
const withDetailUrl = (sec: JobSection, list: AnyJob[]) =>
list.map((job) => ({ ...job, detailUrl: jobDetailHref(sec, job.company, job.role) }));

if (!section || section === "all") {
const withUrls = Object.fromEntries(
(Object.keys(jobs) as JobSection[]).map((sec) => [sec, withDetailUrl(sec, jobs[sec] as unknown as AnyJob[])])
);
return JSON.stringify(withUrls, null, 2);
}
return JSON.stringify(withDetailUrl(section as JobSection, (jobs[section as JobSection] ?? []) as unknown as AnyJob[]), null, 2);
}

case "add_job": {
Expand Down
59 changes: 54 additions & 5 deletions scripts/screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ function applyTheme(ctx, theme) {
}

async function newLoggedInPage(browser, theme = "light") {
const ctx = await browser.newContext();
const ctx = await browser.newContext({ deviceScaleFactor: 2 });
await applyTheme(ctx, theme);
await ctx.request.post(`${BASE}/api/auth/login`, { data: { password: PASSWORD } });
const page = await ctx.newPage();
Expand All @@ -222,7 +222,7 @@ async function newLoggedInPage(browser, theme = "light") {

async function captureLogin(browser, dir, theme = "light") {
console.log(" → login");
const ctx = await browser.newContext();
const ctx = await browser.newContext({ deviceScaleFactor: 2 });
await applyTheme(ctx, theme);
const page = await ctx.newPage();
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded", timeout: 90_000 });
Expand Down Expand Up @@ -260,10 +260,26 @@ async function captureChat(browser, dir, theme = "light") {
await page.getByRole("button", { name: "Send" }).click();
// Assistant replies render in a bubble with this corner-radius class —
// wait for one to appear rather than a fixed sleep, since AI latency varies.
await page.locator(".rounded-tl-sm").first().waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
// Generous timeout: this reply involves a list-jobs tool call before the model can answer.
await page.locator(".rounded-tl-sm").first().waitFor({ state: "visible", timeout: 45_000 }).catch(() => {});
await sleep(400);
await page.screenshot({ path: join(dir, "chat-with-reply--desktop.png") });
console.log(" chat-with-reply--desktop.png");

// Second example question — shows off the assistant's stats/reporting angle,
// distinct from the job-lookup question above.
console.log(" → chat (second question: applied-in-June stat)");
await input.fill("How many did I apply to in June?");
await sleep(200);
await page.screenshot({ path: join(dir, "chat-with-stats-input--desktop.png") });
console.log(" chat-with-stats-input--desktop.png");

await page.getByRole("button", { name: "Send" }).click();
// Second assistant bubble — wait for it specifically, not just "any" bubble.
await page.locator(".rounded-tl-sm").nth(1).waitFor({ state: "visible", timeout: 45_000 }).catch(() => {});
await sleep(400);
await page.screenshot({ path: join(dir, "chat-with-stats-reply--desktop.png") });
console.log(" chat-with-stats-reply--desktop.png");
}

await ctx.close();
Expand Down Expand Up @@ -354,6 +370,38 @@ async function captureJobDetail(browser, dir, { company, role, section, label },
await ctx.close();
}

// Uses one of the job page's real prompt-chip labels (see buildPrompts in
// app/job/page.tsx) rather than typed free text, so the screenshot matches
// an actual clickable affordance in the UI.
async function captureJobDetailChat(browser, dir, { company, role, section, label }, theme = "light") {
console.log(` → job detail chat: ${company} (${label})`);
const { page, ctx } = await newLoggedInPage(browser, theme);
const url = `${BASE}/job?company=${encodeURIComponent(company)}&role=${encodeURIComponent(role)}&section=${section}`;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 });
await sleep(500);
await page.setViewportSize({ width: 1440, height: 900 });
await page.addStyleTag({ content: HIDE_DEV_UI }).catch(() => {});

const prompt = page.getByRole("button", { name: "Should I apply to this role?" });
if (await prompt.isVisible().catch(() => false)) {
await prompt.click();
// Catch the pending state (bouncing "thinking" dots) before the reply lands.
await sleep(300);
mkdirSync(dir, { recursive: true });
await page.screenshot({ path: join(dir, `job-chat-${label}-waiting--desktop.png`) });
console.log(` job-chat-${label}-waiting--desktop.png`);

console.log(" → job chat (waiting for AI reply)");
// Generous timeout — this reply is scored against the full profile + job context.
await page.locator(".rounded-tl-sm").first().waitFor({ state: "visible", timeout: 45_000 }).catch(() => {});
await sleep(400);
await page.screenshot({ path: join(dir, `job-chat-${label}-reply--desktop.png`) });
console.log(` job-chat-${label}-reply--desktop.png`);
}

await ctx.close();
}

async function captureSettings(browser, dir, theme = "light") {
console.log(" → settings");
const { page, ctx } = await newLoggedInPage(browser, theme);
Expand Down Expand Up @@ -392,7 +440,7 @@ async function captureSettingsExport(browser, dir, theme = "light") {

async function captureLoginWithForgot(browser, dir, theme = "light") {
console.log(" → login (forgot password open)");
const ctx = await browser.newContext();
const ctx = await browser.newContext({ deviceScaleFactor: 2 });
await applyTheme(ctx, theme);
const page = await ctx.newPage();
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded", timeout: 90_000 });
Expand All @@ -404,7 +452,7 @@ async function captureLoginWithForgot(browser, dir, theme = "light") {
}

async function captureOnboardingSteps(browser, dir, theme = "light") {
const ctx = await browser.newContext();
const ctx = await browser.newContext({ deviceScaleFactor: 2 });
await applyTheme(ctx, theme);
await ctx.request.post(`${BASE}/api/auth/login`, { data: { password: PASSWORD } });
const page = await ctx.newPage();
Expand Down Expand Up @@ -481,6 +529,7 @@ async function runPersona(browser, persona) {
}
if (persona.prospectJob) {
await captureJobDetail(browser, dir, { ...persona.prospectJob, label: "prospect" }, theme);
await captureJobDetailChat(browser, dir, { ...persona.prospectJob, label: "prospect" }, theme);
}

// Settings pages — only needed once, use design persona
Expand Down
Loading