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(