titles: smart truncation — the cut snaps to word boundaries
Owner round (2026-10-06, release polish #3): first messages are always long, and the auto-title's hard 72-char cut landed mid-word. The cut now snaps to the last word boundary at or under TITLE_MAX_CHARS (72, exported and tunable); whitespace collapses first so boundaries are single-space. The one special case (owner-specified): a single word longer than the cap — rare but real: identifiers, URLs — splits at the cap. A boundary landing exactly at the cap is a clean cut. Seam unchanged: titleFromPrompt (core/contracts/graph.ts), applied once server-side at chat creation. Pinned in tests/title-from-prompt.test.ts (6).
This commit is contained in:
@@ -341,9 +341,34 @@ export interface Position {
|
|||||||
y: number;
|
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).
|
/** 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. */
|
* Used on chat creation; deterministic so tests can pin it. */
|
||||||
export function titleFromPrompt(prompt: string): string {
|
export function titleFromPrompt(prompt: string): string {
|
||||||
const oneLine = prompt.replace(/\s+/g, " ").trim();
|
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)}…`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("…"));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user