✂️ fix: Unicode-Safe Title Truncation and Shared View Layout Polish (#12003)

* fix: title sanitization with max length truncation and update ShareView for better text display

- Added functionality to `sanitizeTitle` to truncate titles exceeding 200 characters with an ellipsis, ensuring consistent title length.
- Updated `ShareView` component to apply a line clamp on the title, improving text display and preventing overflow in the UI.

* refactor: Update layout and styling in MessagesView and ShareView components

- Removed unnecessary padding in MessagesView to streamline the layout.
- Increased bottom padding in the message container for better spacing.
- Enhanced ShareView footer positioning and styling for improved visibility.
- Adjusted section and div classes in ShareView for better responsiveness and visual consistency.

* fix: Correct title fallback and enhance sanitization logic in sanitizeTitle

- Updated the fallback title in sanitizeTitle to use DEFAULT_TITLE_FALLBACK instead of a hardcoded string.
- Improved title truncation logic to ensure proper handling of maximum length and whitespace, including edge cases for emoji and whitespace-only titles.
- Added tests to validate the new sanitization behavior, ensuring consistent and expected results across various input scenarios.
This commit is contained in:
Danny Avila 2026-03-01 16:44:57 -05:00 committed by GitHub
parent ce1338285c
commit 5be90706b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 85 additions and 31 deletions

View file

@ -1,4 +1,4 @@
import { sanitizeTitle } from './sanitizeTitle';
import { sanitizeTitle, MAX_TITLE_LENGTH, DEFAULT_TITLE_FALLBACK } from './sanitizeTitle';
describe('sanitizeTitle', () => {
describe('Happy Path', () => {
@ -123,21 +123,21 @@ describe('sanitizeTitle', () => {
describe('Empty and Fallback Cases', () => {
it('should return fallback for empty string', () => {
expect(sanitizeTitle('')).toBe('Untitled Conversation');
expect(sanitizeTitle('')).toBe(DEFAULT_TITLE_FALLBACK);
});
it('should return fallback when only whitespace remains', () => {
const input = '<think>thinking</think> \n\t\r\n ';
expect(sanitizeTitle(input)).toBe('Untitled Conversation');
expect(sanitizeTitle(input)).toBe(DEFAULT_TITLE_FALLBACK);
});
it('should return fallback when only think blocks exist', () => {
const input = '<think>just thinking</think><think>more thinking</think>';
expect(sanitizeTitle(input)).toBe('Untitled Conversation');
expect(sanitizeTitle(input)).toBe(DEFAULT_TITLE_FALLBACK);
});
it('should return fallback for non-string whitespace', () => {
expect(sanitizeTitle(' ')).toBe('Untitled Conversation');
expect(sanitizeTitle(' ')).toBe(DEFAULT_TITLE_FALLBACK);
});
});
@ -174,6 +174,53 @@ describe('sanitizeTitle', () => {
});
});
describe('Max Length Truncation', () => {
const ellipsis = '...';
const maxContent = MAX_TITLE_LENGTH - ellipsis.length;
it('should pass through a title exactly at max length unchanged', () => {
const input = 'A'.repeat(MAX_TITLE_LENGTH);
expect(sanitizeTitle(input)).toBe(input);
});
it('should truncate a title over max length with ellipsis', () => {
const input = 'A'.repeat(MAX_TITLE_LENGTH + 50);
const result = sanitizeTitle(input);
expect(result).toBe('A'.repeat(maxContent) + ellipsis);
expect(result.length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
});
it('should truncate after think-block removal', () => {
const input = '<think>reasoning</think> ' + 'B'.repeat(MAX_TITLE_LENGTH + 50);
const result = sanitizeTitle(input);
expect(result).toBe('B'.repeat(maxContent) + ellipsis);
expect(result.length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
});
it('should trimEnd before appending ellipsis when slice ends with whitespace', () => {
const input = 'A'.repeat(maxContent - 1) + ' B' + 'C'.repeat(MAX_TITLE_LENGTH);
const result = sanitizeTitle(input);
expect(result).toBe('A'.repeat(maxContent - 1) + ellipsis);
expect(result).not.toMatch(/ \.\.\./);
});
it('should not produce lone surrogates when truncating emoji titles', () => {
const input = 'A'.repeat(MAX_TITLE_LENGTH - 2) + '\u{1F389}rest';
const result = sanitizeTitle(input);
expect(result.isWellFormed()).toBe(true);
expect([...result].length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
});
it('should handle a title composed entirely of emoji', () => {
const emoji = '\u{1F680}';
const input = emoji.repeat(MAX_TITLE_LENGTH + 10);
const result = sanitizeTitle(input);
expect(result.isWellFormed()).toBe(true);
expect(result.endsWith(ellipsis)).toBe(true);
expect([...result].length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
});
});
describe('Idempotency', () => {
it('should be idempotent', () => {
const input = '<think>reasoning</think> My Title';
@ -188,7 +235,7 @@ describe('sanitizeTitle', () => {
const once = sanitizeTitle(input);
const twice = sanitizeTitle(once);
expect(once).toBe(twice);
expect(once).toBe('Untitled Conversation');
expect(once).toBe(DEFAULT_TITLE_FALLBACK);
});
});

View file

@ -1,30 +1,35 @@
/** Max character length for sanitized titles (the output will never exceed this). */
export const MAX_TITLE_LENGTH = 200;
export const DEFAULT_TITLE_FALLBACK = 'Untitled Conversation';
/**
* Sanitizes LLM-generated chat titles by removing <think>...</think> reasoning blocks.
* Sanitizes LLM-generated chat titles by removing {@link https://en.wikipedia.org/wiki/Chain-of-thought_prompting <think>}
* reasoning blocks, normalizing whitespace, and truncating to {@link MAX_TITLE_LENGTH} characters.
*
* This function strips out all reasoning blocks (with optional attributes and newlines)
* and returns a clean title. If the result is empty, a fallback is returned.
* Titles exceeding the limit are truncated at a code-point-safe boundary and suffixed with `...`.
*
* @param rawTitle - The raw LLM-generated title string, potentially containing <think> blocks.
* @returns A sanitized title string, never empty (fallback used if needed).
* @returns A sanitized, potentially truncated title string, never empty (fallback used if needed).
*/
export function sanitizeTitle(rawTitle: string): string {
const DEFAULT_FALLBACK = 'Untitled Conversation';
// Step 1: Input Validation
if (!rawTitle || typeof rawTitle !== 'string') {
return DEFAULT_FALLBACK;
return DEFAULT_TITLE_FALLBACK;
}
// Step 2: Build and apply the regex to remove all <think>...</think> blocks
const thinkBlockRegex = /<think\b[^>]*>[\s\S]*?<\/think>/gi;
const cleaned = rawTitle.replace(thinkBlockRegex, '');
// Step 3: Normalize whitespace (collapse multiple spaces/newlines to single space)
const normalized = cleaned.replace(/\s+/g, ' ');
// Step 4: Trim leading and trailing whitespace
const trimmed = normalized.trim();
// Step 5: Return trimmed result or fallback if empty
return trimmed.length > 0 ? trimmed : DEFAULT_FALLBACK;
if (trimmed.length === 0) {
return DEFAULT_TITLE_FALLBACK;
}
const codePoints = [...trimmed];
if (codePoints.length > MAX_TITLE_LENGTH) {
const truncateAt = MAX_TITLE_LENGTH - 3;
return codePoints.slice(0, truncateAt).join('').trimEnd() + '...';
}
return trimmed;
}