Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 149a6751f4 | |||
| 969917b651 | |||
| 37cb3db800 | |||
| 1227f7a015 | |||
| e0f589eb1f | |||
| dd72e956b5 | |||
| 738aa29515 | |||
| 87509b19b5 | |||
| 37530a4da1 | |||
| 53736585dc | |||
| 20b5176ffc | |||
| 0eaef26ec3 | |||
| a72de1c34a | |||
| a5faf0e967 | |||
| dce8437cdd | |||
| 94fd2b6cb4 | |||
| c504c27d46 | |||
| 0d3b2e75d6 | |||
| 41760dbbe4 | |||
| 4b21e63d34 | |||
| 0edba9aa9b | |||
| 69e32e839b | |||
| e361795001 | |||
| e96c15e1bb |
@@ -20,6 +20,7 @@ class Subtask:
|
||||
|
||||
id: str
|
||||
description: str
|
||||
title: str | None = None
|
||||
status: SubtaskStatus = SubtaskStatus.PENDING
|
||||
|
||||
# Scoping
|
||||
@@ -53,6 +54,8 @@ class Subtask:
|
||||
"description": self.description,
|
||||
"status": self.status.value,
|
||||
}
|
||||
if self.title:
|
||||
result["title"] = self.title
|
||||
if self.service:
|
||||
result["service"] = self.service
|
||||
if self.all_services:
|
||||
@@ -89,6 +92,7 @@ class Subtask:
|
||||
return cls(
|
||||
id=data["id"],
|
||||
description=data["description"],
|
||||
title=data.get("title"),
|
||||
status=SubtaskStatus(data.get("status", "pending")),
|
||||
service=data.get("service"),
|
||||
all_services=data.get("all_services", False),
|
||||
|
||||
@@ -232,6 +232,7 @@ Based on the workflow type and services involved, create the implementation plan
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "subtask-1-1",
|
||||
"title": "Create data models for analytics",
|
||||
"description": "Create data models for [feature]",
|
||||
"service": "backend",
|
||||
"files_to_modify": ["src/models/user.py"],
|
||||
@@ -246,6 +247,7 @@ Based on the workflow type and services involved, create the implementation plan
|
||||
},
|
||||
{
|
||||
"id": "subtask-1-2",
|
||||
"title": "Create API endpoints for analytics events",
|
||||
"description": "Create API endpoints for [feature]",
|
||||
"service": "backend",
|
||||
"files_to_modify": ["src/routes/api.py"],
|
||||
@@ -272,6 +274,7 @@ Based on the workflow type and services involved, create the implementation plan
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "subtask-2-1",
|
||||
"title": "Create aggregation Celery task",
|
||||
"description": "Create aggregation Celery task",
|
||||
"service": "worker",
|
||||
"files_to_modify": ["worker/tasks.py"],
|
||||
@@ -296,6 +299,7 @@ Based on the workflow type and services involved, create the implementation plan
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "subtask-3-1",
|
||||
"title": "Create real-time dashboard component",
|
||||
"description": "Create dashboard component",
|
||||
"service": "frontend",
|
||||
"files_to_modify": [],
|
||||
@@ -320,6 +324,7 @@ Based on the workflow type and services involved, create the implementation plan
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "subtask-4-1",
|
||||
"title": "Verify end-to-end analytics flow",
|
||||
"description": "End-to-end verification of analytics flow",
|
||||
"all_services": true,
|
||||
"files_to_modify": [],
|
||||
@@ -362,6 +367,7 @@ Use ONLY these values for the `type` field in phases:
|
||||
2. **Small scope** - Each subtask should take 1-3 files max
|
||||
3. **Clear verification** - Every subtask must have a way to verify it works
|
||||
4. **Explicit dependencies** - Phases block until dependencies complete
|
||||
5. **Title must be a short imperative label** (max 60 chars, e.g. "Create data models for analytics"). Description contains full implementation details.
|
||||
|
||||
### Verification Types
|
||||
|
||||
@@ -385,6 +391,7 @@ Use ONLY these values for the `type` field in phases:
|
||||
```json
|
||||
{
|
||||
"id": "subtask-investigate-1",
|
||||
"title": "Identify root cause of memory leak",
|
||||
"description": "Identify root cause of memory leak",
|
||||
"expected_output": "Document with: (1) Root cause, (2) Evidence, (3) Proposed fix",
|
||||
"files_to_modify": [],
|
||||
@@ -400,6 +407,7 @@ Use ONLY these values for the `type` field in phases:
|
||||
```json
|
||||
{
|
||||
"id": "subtask-refactor-1",
|
||||
"title": "Add new auth system alongside old",
|
||||
"description": "Add new auth system alongside old",
|
||||
"files_to_modify": ["src/auth/index.ts"],
|
||||
"files_to_create": ["src/auth/new_auth.ts"],
|
||||
|
||||
@@ -53,6 +53,7 @@ IMPLEMENTATION_PLAN_SCHEMA = {
|
||||
"subtask_schema": {
|
||||
"required_fields": ["id", "description", "status"],
|
||||
"optional_fields": [
|
||||
"title",
|
||||
"service",
|
||||
"all_services",
|
||||
"files_to_modify",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { findAllSpecPaths } from './utils/spec-path-helpers';
|
||||
import { ensureAbsolutePath } from './utils/path-helpers';
|
||||
import { writeFileAtomicSync } from './utils/atomic-file';
|
||||
import { updateRoadmapFeatureOutcome, revertRoadmapFeatureOutcome } from './utils/roadmap-utils';
|
||||
import { extractSubtaskTitle } from '../shared/utils/subtask-title';
|
||||
|
||||
interface TabState {
|
||||
openProjectIds: string[];
|
||||
@@ -502,7 +503,7 @@ export class ProjectStore {
|
||||
const items = phase.subtasks || (phase as { chunks?: PlanSubtask[] }).chunks || [];
|
||||
return items.map((subtask) => ({
|
||||
id: subtask.id,
|
||||
title: subtask.description,
|
||||
title: subtask.title || extractSubtaskTitle(subtask.description),
|
||||
description: subtask.description,
|
||||
status: subtask.status,
|
||||
files: []
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CheckCircle2, Clock, XCircle, AlertCircle, ListChecks, FileCode } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
@@ -22,6 +23,48 @@ function getSubtaskStatusIcon(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic overflow-aware text component.
|
||||
* Renders children in a line-clamped container and shows a tooltip with the
|
||||
* full text when the content is visually truncated.
|
||||
*/
|
||||
function OverflowText({
|
||||
text,
|
||||
as: Tag = 'p',
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
as?: 'p' | 'span';
|
||||
className?: string;
|
||||
}) {
|
||||
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!el) return;
|
||||
const check = () => setIsOverflowing(el.scrollHeight > el.clientHeight);
|
||||
check();
|
||||
const observer = new ResizeObserver(check);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [el, text]);
|
||||
|
||||
return (
|
||||
<Tooltip open={isOverflowing ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Tag ref={(node: HTMLElement | null) => setEl(node)} className={className} tabIndex={isOverflowing ? 0 : undefined}>
|
||||
{text}
|
||||
</Tag>
|
||||
</TooltipTrigger>
|
||||
{isOverflowing && (
|
||||
<TooltipContent side="bottom" className="max-w-sm">
|
||||
<p className="text-xs">{text}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskSubtasks({ task }: TaskSubtasksProps) {
|
||||
const { t } = useTranslation(['tasks']);
|
||||
const progress = calculateProgress(task.subtasks);
|
||||
@@ -68,22 +111,18 @@ export function TaskSubtasks({ task }: TaskSubtasksProps) {
|
||||
)}>
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground break-words flex-1 min-w-0">
|
||||
{subtask.title || t('tasks:subtasks.untitled')}
|
||||
</span>
|
||||
<OverflowText
|
||||
text={subtask.title || t('tasks:subtasks.untitled')}
|
||||
as="span"
|
||||
className="text-sm font-medium text-foreground break-words flex-1 min-w-0 line-clamp-2"
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="mt-1 text-xs text-muted-foreground line-clamp-2 cursor-default break-words">
|
||||
{subtask.description}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
{subtask.description && subtask.description.length > 80 && (
|
||||
<TooltipContent side="bottom" className="max-w-sm">
|
||||
<p className="text-xs">{subtask.description}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
{subtask.description && subtask.description !== subtask.title && (
|
||||
<OverflowText
|
||||
text={subtask.description}
|
||||
className="mt-1 text-xs text-muted-foreground line-clamp-2 break-words"
|
||||
/>
|
||||
)}
|
||||
{subtask.files && subtask.files.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{subtask.files.map((file) => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft, ImageAttachment, TaskOrderState } from '../../shared/types';
|
||||
import { debugLog, debugWarn } from '../../shared/utils/debug-logger';
|
||||
import { extractSubtaskTitle } from '../../shared/utils/subtask-title';
|
||||
import { useProjectStore } from './project-store';
|
||||
|
||||
/** Default max parallel tasks when no project setting is configured */
|
||||
@@ -373,7 +374,7 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
: `subtask-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
||||
// Defensive fallback: validatePlanData() ensures description exists, but kept for safety
|
||||
const description = subtask.description || 'No description available';
|
||||
const title = description; // Title and description are the same for subtasks
|
||||
const title = subtask.title || extractSubtaskTitle(description);
|
||||
const status = (subtask.status as SubtaskStatus) || 'pending';
|
||||
|
||||
return {
|
||||
|
||||
@@ -304,6 +304,7 @@ export interface Phase {
|
||||
|
||||
export interface PlanSubtask {
|
||||
id: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
status: SubtaskStatus;
|
||||
verification?: {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Unit tests for subtask title extraction utility
|
||||
* Tests extractSubtaskTitle() which derives concise titles from subtask descriptions
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractSubtaskTitle } from '../subtask-title';
|
||||
|
||||
describe('extractSubtaskTitle', () => {
|
||||
describe('short descriptions (<=80 chars)', () => {
|
||||
it('should return short description as-is', () => {
|
||||
const desc = 'Fix the login button styling';
|
||||
expect(extractSubtaskTitle(desc)).toBe(desc);
|
||||
});
|
||||
|
||||
it('should return description exactly at 80 chars as-is', () => {
|
||||
const desc = 'A'.repeat(80);
|
||||
expect(extractSubtaskTitle(desc)).toBe(desc);
|
||||
});
|
||||
});
|
||||
|
||||
describe('colon-space short string handling', () => {
|
||||
it('should split at period-space not colon-space for short descriptions', () => {
|
||||
const desc = 'Fix: align items. See related PR';
|
||||
expect(extractSubtaskTitle(desc)).toBe('Fix: align items');
|
||||
});
|
||||
});
|
||||
|
||||
describe('long descriptions with sentence boundary', () => {
|
||||
it('should truncate at first sentence ending with period-space', () => {
|
||||
const desc = 'Fix the login button styling. Then update the tests and make sure everything works correctly across all browsers.';
|
||||
expect(extractSubtaskTitle(desc)).toBe('Fix the login button styling');
|
||||
});
|
||||
|
||||
it('should truncate at first sentence ending with colon-space', () => {
|
||||
const desc = 'Fix the login button: Then update the tests and make sure everything works correctly across all browsers and devices.';
|
||||
expect(extractSubtaskTitle(desc)).toBe('Fix the login button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('long descriptions without sentence boundary', () => {
|
||||
it('should truncate at word boundary with ellipsis', () => {
|
||||
const desc = 'This is a very long description that does not have any sentence boundaries and keeps going on and on without stopping at all';
|
||||
const result = extractSubtaskTitle(desc);
|
||||
expect(result.endsWith('\u2026')).toBe(true);
|
||||
expect(result.length).toBeLessThanOrEqual(80); // content + ellipsis char within maxLength
|
||||
// Should end with a space before the truncation point (word boundary)
|
||||
const withoutEllipsis = result.slice(0, -1);
|
||||
expect(desc.charAt(withoutEllipsis.length)).toMatch(/\s/);
|
||||
});
|
||||
|
||||
it('should truncate at last space before maxLength', () => {
|
||||
const desc = 'word '.repeat(20); // 100 chars, spaces every 5 chars
|
||||
const result = extractSubtaskTitle(desc.trim());
|
||||
expect(result.endsWith('\u2026')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty and falsy inputs', () => {
|
||||
it('should return empty string for empty string', () => {
|
||||
expect(extractSubtaskTitle('')).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined', () => {
|
||||
expect(extractSubtaskTitle(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for null', () => {
|
||||
expect(extractSubtaskTitle(null)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for whitespace-only string', () => {
|
||||
expect(extractSubtaskTitle(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundary at maxLength', () => {
|
||||
it('should return as-is when exactly at default maxLength', () => {
|
||||
const desc = 'x'.repeat(80);
|
||||
expect(extractSubtaskTitle(desc)).toBe(desc);
|
||||
});
|
||||
|
||||
it('should truncate when one char over maxLength', () => {
|
||||
const desc = 'x'.repeat(81);
|
||||
const result = extractSubtaskTitle(desc);
|
||||
expect(result).toContain('\u2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom maxLength parameter', () => {
|
||||
it('should respect custom maxLength of 40', () => {
|
||||
const desc = 'This is a medium length description that exceeds forty characters';
|
||||
const result = extractSubtaskTitle(desc, 40);
|
||||
// Should truncate since > 40 chars
|
||||
expect(result.length).toBeLessThanOrEqual(40);
|
||||
});
|
||||
|
||||
it('should return short description as-is with large maxLength', () => {
|
||||
const desc = 'Short description';
|
||||
expect(extractSubtaskTitle(desc, 200)).toBe(desc);
|
||||
});
|
||||
|
||||
it('should truncate with custom maxLength at sentence boundary', () => {
|
||||
const desc = 'Fix bug. Then do more work that is unnecessary and verbose and goes on forever and ever.';
|
||||
expect(extractSubtaskTitle(desc, 40)).toBe('Fix bug');
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal period edge cases', () => {
|
||||
it('should handle single sentence ending with period and no trailing space', () => {
|
||||
const desc = 'Implement the complete authentication flow for the new user registration module.';
|
||||
expect(extractSubtaskTitle(desc)).toBe('Implement the complete authentication flow for the new user registration module');
|
||||
});
|
||||
|
||||
it('should handle long single sentence with terminal period', () => {
|
||||
const desc = 'This is a very long single sentence that exceeds the maximum length threshold and ends with a period.';
|
||||
const result = extractSubtaskTitle(desc);
|
||||
// Should truncate at word boundary since sentence is too long
|
||||
expect(result.endsWith('\u2026')).toBe(true);
|
||||
expect(result.length).toBeLessThanOrEqual(80);
|
||||
});
|
||||
|
||||
it('should handle period followed by newline', () => {
|
||||
const desc = 'Fix the login button.\nThen update the tests.';
|
||||
expect(extractSubtaskTitle(desc)).toBe('Fix the login button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('abbreviation handling', () => {
|
||||
it('should not split on "Dr. " abbreviation', () => {
|
||||
const desc = 'Dr. Smith should fix the login button styling issue';
|
||||
expect(extractSubtaskTitle(desc)).toBe(desc);
|
||||
});
|
||||
|
||||
it('should not split on "e.g. " abbreviation', () => {
|
||||
const desc = 'Use a framework e.g. React for building the component';
|
||||
expect(extractSubtaskTitle(desc)).toBe(desc);
|
||||
});
|
||||
|
||||
it('should not split on "i.e. " abbreviation', () => {
|
||||
const desc = 'Fix the main module i.e. the auth handler for the app';
|
||||
expect(extractSubtaskTitle(desc)).toBe(desc);
|
||||
});
|
||||
|
||||
it('should split on real sentence boundary after abbreviation', () => {
|
||||
const desc = 'Dr. Smith fixed the bug. Then we deployed the application to production servers and ran the full test suite.';
|
||||
expect(extractSubtaskTitle(desc)).toBe('Dr. Smith fixed the bug');
|
||||
});
|
||||
|
||||
it('should not split on "etc. " abbreviation', () => {
|
||||
const desc = 'Update icons, fonts, etc. to match the new design system specifications';
|
||||
expect(extractSubtaskTitle(desc)).toBe(desc);
|
||||
});
|
||||
|
||||
it('should strip trailing period while preserving abbreviation periods', () => {
|
||||
const desc = 'Talk to Dr. Jones.';
|
||||
expect(extractSubtaskTitle(desc)).toBe('Talk to Dr. Jones');
|
||||
});
|
||||
});
|
||||
|
||||
describe('degenerate truncation cases', () => {
|
||||
it('should not exceed maxLength even for single-word input', () => {
|
||||
const longWord = 'a'.repeat(100);
|
||||
const result = extractSubtaskTitle(longWord, 80);
|
||||
// Should be 79 chars + ellipsis = 80 total
|
||||
expect(result.length).toBeLessThanOrEqual(80);
|
||||
expect(result.endsWith('\u2026')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle very short maxLength gracefully', () => {
|
||||
const desc = 'This is a description';
|
||||
const result = extractSubtaskTitle(desc, 5);
|
||||
expect(result.length).toBeLessThanOrEqual(5);
|
||||
expect(result.endsWith('\u2026')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Default maximum length for subtask titles.
|
||||
* Used by extractSubtaskTitle and UI components for consistent truncation.
|
||||
*/
|
||||
export const SUBTASK_TITLE_MAX_LENGTH = 80;
|
||||
|
||||
// Common abbreviations that end with a period but don't end a sentence.
|
||||
// Uses \b word boundary before the $ anchor to prevent partial-word matches
|
||||
// (e.g. "items" must not match the "Ms" abbreviation).
|
||||
const ABBREVIATIONS = /\b(?:Dr|Mr|Ms|Mrs|Jr|Sr|Prof|St|vs|etc|e\.g|i\.e|a\.m|p\.m|no|vol|dept|est|approx|incl|govt|corp|assn|bros|co|ltd|inc)$/i;
|
||||
|
||||
/**
|
||||
* Extract a concise title from a subtask description.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Return '' for empty/undefined input (lets i18n fallback activate in UI)
|
||||
* 2. If description fits within maxLength, return as-is
|
||||
* 3. Try extracting the first sentence (split on '. ' or ': ' or terminal period),
|
||||
* skipping splits on common abbreviations (Dr., e.g., etc.)
|
||||
* 4. If first sentence fits, return it (strip trailing period)
|
||||
* 5. Otherwise truncate at last word boundary and append ellipsis
|
||||
*/
|
||||
export function extractSubtaskTitle(description: string | undefined | null, maxLength = SUBTASK_TITLE_MAX_LENGTH): string {
|
||||
if (!description || !description.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (maxLength < 1) return '';
|
||||
|
||||
const trimmed = description.trim();
|
||||
|
||||
// Short enough — return as-is unless the string contains a period-whitespace
|
||||
// sentence boundary (e.g. "Sentence one.\nSentence two."), in which case we
|
||||
// still extract the first sentence. Colon-space (": ") alone is NOT treated
|
||||
// as a sentence boundary for short strings to avoid splitting title-style
|
||||
// prefixes like "Fix: do the thing".
|
||||
if (trimmed.length <= maxLength && !/\.\s/.test(trimmed)) {
|
||||
if (/\.\s*$/.test(trimmed)) {
|
||||
const stripped = trimmed.replace(/\.\s*$/, '');
|
||||
if (!ABBREVIATIONS.test(stripped)) return stripped;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Try to extract first sentence via '. ', ': ', or period+newline,
|
||||
// skipping splits on common abbreviations
|
||||
const boundaryPattern = /(?:\.\s|:\s)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = boundaryPattern.exec(trimmed)) !== null) {
|
||||
const prefix = trimmed.substring(0, match.index);
|
||||
// Skip colon-space for short strings (title-style prefixes like "Fix: do the thing")
|
||||
// Also skip if the prefix before the colon is too short (< 15 chars), which would
|
||||
// produce a degenerate/meaningless title (e.g. "Fix" from "Fix: align the items...").
|
||||
if (match[0].startsWith(':') && (trimmed.length <= maxLength || prefix.trim().length < 15)) {
|
||||
continue;
|
||||
}
|
||||
// Skip if the period follows a common abbreviation
|
||||
if (match[0].startsWith('.') && ABBREVIATIONS.test(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const sentence = prefix.trim();
|
||||
if (sentence.length > 0 && sentence.length <= maxLength) {
|
||||
return sentence;
|
||||
}
|
||||
// First real sentence boundary found but too long - fall through to truncation
|
||||
break;
|
||||
}
|
||||
|
||||
// Strip trailing period if it ends the only sentence (not an abbreviation)
|
||||
if (/\.\s*$/.test(trimmed)) {
|
||||
const stripped = trimmed.replace(/\.\s*$/, '');
|
||||
if (!ABBREVIATIONS.test(stripped) && stripped.length <= maxLength) {
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
|
||||
// Short enough — return as-is (abbreviation periods kept the string out of
|
||||
// the early-return path above, but no real sentence boundary was found)
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Truncate at last word boundary within maxLength, ensuring result length doesn't exceed maxLength
|
||||
const truncated = trimmed.substring(0, maxLength);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
|
||||
// Truncate at last word boundary and append ellipsis
|
||||
if (lastSpace > 0) {
|
||||
return `${trimmed.substring(0, lastSpace)}\u2026`;
|
||||
}
|
||||
|
||||
// Fallback for single-word or no-space case: truncate to maxLength-1 + ellipsis
|
||||
const cutoff = Math.max(1, maxLength - 1);
|
||||
return `${trimmed.substring(0, cutoff)}\u2026`;
|
||||
}
|
||||
Reference in New Issue
Block a user