diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index 4e950cc..a4560a2 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -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.
diff --git a/components/MarkdownContent.tsx b/components/MarkdownContent.tsx
index ec735a2..967f942 100644
--- a/components/MarkdownContent.tsx
+++ b/components/MarkdownContent.tsx
@@ -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
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;
@@ -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(
+
+
+
+
+ {headerCells.map((cell, idx) => (
+ |
+
+ |
+ ))}
+
+
+
+ {bodyRows.map((row, rIdx) => (
+
+ {row.map((cell, cIdx) => (
+ |
+
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+ continue;
+ }
+
+ // Unordered list
+ if (/^[-*]\s/.test(line)) {
+ const { items, nextIndex } = collectListItems(lines, i, /^[-*]\s/, /^[-*]\s+/);
+ i = nextIndex;
nodes.push(
{items.map((it, idx) => (
@@ -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(
{items.map((it, idx) => (
diff --git a/components/__tests__/MarkdownContent.test.tsx b/components/__tests__/MarkdownContent.test.tsx
new file mode 100644
index 0000000..0228005
--- /dev/null
+++ b/components/__tests__/MarkdownContent.test.tsx
@@ -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();
+ expect(container.querySelectorAll("li")).toHaveLength(0);
+ });
+
+ // O — One: a single tight ordered-list item renders inside one
+ it("renders a single ordered-list item inside one ol", () => {
+ render();
+ 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 , not one per item. A separate per item would make
+ // every item show "1." since each new 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();
+ expect(screen.getAllByRole("list")).toHaveLength(1);
+ expect(screen.getAllByRole("listitem")).toHaveLength(3);
+ });
+
+ // B — Boundary: the browser's native 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();
+ 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 s instead of one.
+ it("collapses all items into a single ul", () => {
+ const text = ["- Search RemoteOK", "", "- Check the board"].join("\n");
+ render();
+ 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();
+ 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();
+ 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();
+ expect(screen.getByText("7 jobs in June")).toBeInTheDocument();
+ expect(screen.getAllByRole("listitem")).toHaveLength(2);
+ expect(screen.getByRole("link", { name: "Director of Product Design" })).toBeInTheDocument();
+ });
+});
diff --git a/lib/chat-tools.ts b/lib/chat-tools.ts
index dc1e90a..4fae16c 100644
--- a/lib/chat-tools.ts
+++ b/lib/chat-tools.ts
@@ -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": {
diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs
index 3618b30..589be43 100644
--- a/scripts/screenshots.mjs
+++ b/scripts/screenshots.mjs
@@ -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();
@@ -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 });
@@ -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();
@@ -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)}§ion=${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);
@@ -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 });
@@ -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();
@@ -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