From bf91f991d26aee5a9448e5887f2bc4cb033400b1 Mon Sep 17 00:00:00 2001 From: Brian Schwartz Date: Tue, 7 Jul 2026 02:57:13 +0000 Subject: [PATCH 1/3] fix: render markdown tables in chat and link job roles correctly Chat replies were showing raw pipe-table syntax instead of tables, and list_jobs gave the model no URL to link jobs it names. Add table parsing to MarkdownContent, attach detailUrl to list_jobs results, and tighten the system prompt so the role name itself becomes the link (no separate link column, no invented/placeholder URLs). Also expand screenshots.mjs with a second board-chat example and a job-detail-chat capture, and capture at deviceScaleFactor 2 for sharper marketing screenshots. Co-Authored-By: Claude Sonnet 5 --- app/api/chat/route.ts | 2 ++ components/MarkdownContent.tsx | 42 ++++++++++++++++++++++++ lib/chat-tools.ts | 12 +++++-- scripts/screenshots.mjs | 59 +++++++++++++++++++++++++++++++--- 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 4e950cc..f8dd500 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 one or more specific jobs from list_jobs, make the role name itself the link to that job's detailUrl — never add a separate "Link" column or a trailing "[View](url)". In a table, the header row stays plain text ("| Company | Role | Salary |"); only body-row cells replace the role name with a markdown link, e.g. a body row reading "| Anthropic | [Head of Product Design](/job?section=prospect&company=Anthropic&role=Head+of+Product+Design) | $240K-$300K |". Do not add a link column — the table is already tight in the chat panel. In a bulleted or numbered list: "Company — [Role](detailUrl)". Never link the company name. 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. + 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..6868f09 100644 --- a/components/MarkdownContent.tsx +++ b/components/MarkdownContent.tsx @@ -96,6 +96,48 @@ export function MarkdownContent({ text, className }: Props) { continue; } + // 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: string[] = []; 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 From cbf3259a1d6a92f0bcdc18b82ba50b29377b1d0b Mon Sep 17 00:00:00 2001 From: Brian Schwartz Date: Tue, 7 Jul 2026 18:44:25 +0000 Subject: [PATCH 2/3] fix: ordered/unordered lists split on blank lines, resetting numbering to 1 MarkdownContent stopped collecting list items at the first blank line, so a "loose" list (blank line between items, which LLMs commonly emit) became several one-item lists instead of one. Every fresh
    restarts its own count, so every item rendered as "1." regardless of position. Fix: collectListItems now tolerates a single blank line between items, only ending the list when the next non-blank line isn't a list item. Co-Authored-By: Claude Sonnet 5 --- components/MarkdownContent.tsx | 39 ++++++-- components/__tests__/MarkdownContent.test.tsx | 91 +++++++++++++++++++ 2 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 components/__tests__/MarkdownContent.test.tsx diff --git a/components/MarkdownContent.tsx b/components/MarkdownContent.tsx index 6868f09..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; @@ -140,11 +165,8 @@ export function MarkdownContent({ text, className }: Props) { // Unordered list if (/^[-*]\s/.test(line)) { - const items: string[] = []; - while (i < lines.length && /^[-*]\s/.test(lines[i])) { - items.push(lines[i].replace(/^[-*]\s+/, "")); - i++; - } + const { items, nextIndex } = collectListItems(lines, i, /^[-*]\s/, /^[-*]\s+/); + i = nextIndex; nodes.push(
        {items.map((it, idx) => ( @@ -159,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(); + }); +}); From aaa240435258755bd9122e3f507e75b6db3bb617 Mon Sep 17 00:00:00 2001 From: Brian Schwartz Date: Tue, 7 Jul 2026 19:05:39 +0000 Subject: [PATCH 3/3] fix: compact job-list tables to two columns to stop role-name wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Company/Role/Salary/Link tables wrapped the role column badly in the narrow chat panel, especially with long titles. Collapse job tables to two columns by default — "Job" (company + linked role) and one status-like field (Status for applied, Fit for prospects) — and push salary/date into prose highlights instead, only surfacing them as columns when the user specifically asks about comp or timing. Co-Authored-By: Claude Sonnet 5 --- app/api/chat/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index f8dd500..a4560a2 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -21,7 +21,7 @@ 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 one or more specific jobs from list_jobs, make the role name itself the link to that job's detailUrl — never add a separate "Link" column or a trailing "[View](url)". In a table, the header row stays plain text ("| Company | Role | Salary |"); only body-row cells replace the role name with a markdown link, e.g. a body row reading "| Anthropic | [Head of Product Design](/job?section=prospect&company=Anthropic&role=Head+of+Product+Design) | $240K-$300K |". Do not add a link column — the table is already tight in the chat panel. In a bulleted or numbered list: "Company — [Role](detailUrl)". Never link the company name. 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. +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.