diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index 9686552..5dccfb5 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -341,9 +341,34 @@ export interface Position { y: number; } +/** The auto-title's character cap (owner, 2026-10-06): long enough to + * stay informative in the sidebar and the top bar, short enough that a + * first message never reads as a paragraph. Tunable — one number, one + * place. */ +export const TITLE_MAX_CHARS = 72; + /** Derive a short chat title from the learner's first message (pure). + * Smart truncation (owner, 2026-10-06): the cut snaps to a word boundary + * AT OR UNDER the cap — a title never ends mid-word. The one exception + * is a single word longer than the cap (rare, but real: identifiers, + * URLs): it cuts at the cap, splitting the word. Whitespace collapses to + * single spaces first, so boundaries are always single-space boundaries. * Used on chat creation; deterministic so tests can pin it. */ export function titleFromPrompt(prompt: string): string { const oneLine = prompt.replace(/\s+/g, " ").trim(); - return oneLine.length > 72 ? `${oneLine.slice(0, 72)}…` : oneLine; + if (oneLine.length <= TITLE_MAX_CHARS) return oneLine; + // Last space among the first TITLE_MAX_CHARS characters; the cut sits + // before it so no trailing space rides into the title. + let cut = -1; + for (let i = TITLE_MAX_CHARS; i > 0; i--) { + if (oneLine[i - 1] === " ") { + cut = i - 1; + break; + } + } + // One long word: no boundary exists under the cap — split it (owner's + // special case). This also lands correctly when the first word exactly + // fills the cap: cutting at the cap IS the boundary. + if (cut < 0) cut = TITLE_MAX_CHARS; + return `${oneLine.slice(0, cut)}…`; } diff --git a/tests/title-from-prompt.test.ts b/tests/title-from-prompt.test.ts new file mode 100644 index 0000000..9897455 --- /dev/null +++ b/tests/title-from-prompt.test.ts @@ -0,0 +1,56 @@ +/** + * The auto-title's smart truncation (owner, 2026-10-06): the cut snaps to + * a word boundary at or under TITLE_MAX_CHARS (72); a single word longer + * than the cap splits at the cap. Pure seam: titleFromPrompt in + * core/contracts/graph.ts, applied server-side at chat creation (the one + * seam — home and both drafts post raw text; the route derives). + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { TITLE_MAX_CHARS, titleFromPrompt } from "../src/core/contracts/graph.ts"; + +describe("titleFromPrompt (smart truncation)", () => { + it("short prompts pass through untouched — no ellipsis, whitespace collapsed", () => { + assert.equal(titleFromPrompt("Relational databases"), "Relational databases"); + assert.equal(titleFromPrompt("line one\nline two\ttabbed"), "line one line two tabbed"); + }); + + it("exactly at the cap: unchanged, no ellipsis", () => { + const exact = "a".repeat(TITLE_MAX_CHARS); + assert.equal(titleFromPrompt(exact), exact); + }); + + it("over the cap: the cut snaps to the last word boundary under it", () => { + // > 72 chars total so truncation actually fires. + const words = ["teach", "me", "the", "relational", "model", "and", "its", "information", "principle", "underlying", "everything", "please"]; + const joined = words.join(" "); // 10 words, > 72 chars + const out = titleFromPrompt(joined); + assert.ok(out.endsWith("…"), "marks the truncation"); + assert.ok(!out.slice(0, -1).endsWith(" "), "… attaches directly to the kept word"); + // The kept prefix is a prefix of the original at a word boundary: + const kept = out.slice(0, -1); + assert.ok(joined.startsWith(kept)); + assert.ok(kept.length <= TITLE_MAX_CHARS); + assert.equal(joined[kept.length], " ", "the cut dropped a whole word, never half of one"); + }); + + it("boundary landing exactly at the cap is a clean cut (no mid-word split)", () => { + const first = "b".repeat(TITLE_MAX_CHARS); // fills the cap exactly + const prompt = `${first} then more words after it`; + const out = titleFromPrompt(prompt); + assert.equal(out, `${first}…`); + }); + + it("a single word longer than the cap splits at the cap", () => { + const giant = "x".repeat(TITLE_MAX_CHARS + 30); + assert.equal(titleFromPrompt(giant), `${"x".repeat(TITLE_MAX_CHARS)}…`); + }); + + it("the kept title never carries a trailing space", () => { + const prompt = `${"c".repeat(60)} ${"d".repeat(60)}`; // collapses to one space + const out = titleFromPrompt(prompt); + assert.ok(!out.slice(0, -1).endsWith(" ")); + assert.ok(out.endsWith("…")); + }); +});