Compare commits

...

24 Commits

Author SHA1 Message Date
AndyMik90 149a6751f4 fix: add overflow tooltip to subtask titles and improve keyboard accessibility
Generalize OverflowDescription into a reusable OverflowText component that
supports both <p> and <span> elements. Apply it to the subtask title span so
that long titles truncated by line-clamp-2 show a tooltip with the full text.
The tooltip trigger is also keyboard-focusable (tabIndex=0) when overflow is
detected, addressing the accessibility finding from PR review NCR-NEW-003.

Also merges latest develop to bring the branch up to date.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:39:46 +01:00
AndyMik90 969917b651 Merge remote-tracking branch 'origin/develop' into auto-claude/231-investigate-and-fix-subtask-title-display 2026-02-20 12:37:22 +01:00
Andy 37cb3db800 Merge branch 'develop' into auto-claude/231-investigate-and-fix-subtask-title-display 2026-02-18 21:24:34 +01:00
Andy 1227f7a015 Merge branch 'develop' into auto-claude/231-investigate-and-fix-subtask-title-display 2026-02-18 21:22:40 +01:00
AndyMik90 e0f589eb1f fix: skip colon-space boundary when prefix is too short to be a title
For descriptions like "Fix: align the items in the header bar...",
splitting at ': ' would return just "Fix" as the title which is
meaningless. Add a minimum prefix length check (< 15 chars) so
short label prefixes are skipped and the function falls through to
word-boundary truncation instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:06:19 +01:00
AndyMik90 dd72e956b5 fix: address 3 PR review findings in subtask title display
- OverflowDescription: always render <Tooltip> wrapper and control
  visibility via open prop (open={isOverflowing ? undefined : false})
  to prevent React unmounting/remounting the <p> on isOverflowing toggle
- OverflowDescription: add `text` to useEffect dependency array so
  overflow is re-measured when text content changes
- extractSubtaskTitle: guard against degenerate maxLength < 1 by
  returning '' immediately after the empty-input early return

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 738aa29515 fix: address PR #1837 review findings in subtask title/overflow components
- TaskSubtasks: replace useRef with callback ref (useState<HTMLParagraphElement|null>)
  so ResizeObserver always attaches to the live DOM node when isOverflowing toggles
  and the <p> element is remounted inside the Tooltip tree
- subtask-title: skip colon-space boundaries for short strings (<=maxLength) in
  the boundary loop so "Fix: do the thing" style prefixes are not split at ':'
- subtask-title: add \b word boundary to ABBREVIATIONS regex to prevent partial-word
  false positives (e.g. "items" matching the Ms abbreviation)
- subtask-title tests: add colon-space short string test case per PR finding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 87509b19b5 fix: prevent early sentence split for short descriptions and eliminate OverflowDescription render oscillation
- Move length check before boundary loop in subtask-title.ts so short strings
  (e.g. "Fix: do the thing") are returned as-is instead of being truncated at
  the first ": " or ". " boundary; fall through to boundary loop only when the
  string contains a genuine period-whitespace sentence break
- Add a post-loop length guard to handle short strings whose abbreviation
  periods (Dr., e.g., etc.) caused them to bypass the early-return path
- Replace p/button element-type switch in OverflowDescription with a stable
  <p> ref; conditionally wrap in Tooltip when overflowing, eliminating the
  DOM node swap that risked render oscillation; remove isOverflowing from
  ResizeObserver effect deps and drop unused useCallback import

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 37530a4da1 fix: re-attach ResizeObserver when overflow element switches DOM nodes
Add isOverflowing to the useEffect dependency array so the
ResizeObserver is re-created when the component switches between
the <p> and <button> elements, preventing the observer from
staying attached to a detached DOM node.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 53736585dc fix: resolve OverflowDescription ref latch, period stripping with abbreviations
- Attach ref to both <button> and <p> in OverflowDescription so
  ResizeObserver can detect when overflow resolves (was a one-way latch)
- Fix terminal period stripping to check only the trailing period, not
  whether '. ' appears anywhere (failed for 'Talk to Dr. Jones.')
- Update misleading "Reserve space for ellipsis" comment
- Add test for abbreviation + terminal period combination

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 20b5176ffc fix: address PR #1837 review findings for subtask title display
- Fix regex abbreviation splitting (MEDIUM): Replace non-greedy regex with
  iterative boundary matching that skips common abbreviations (Dr., e.g., etc.)
- Fix redundant regex alternation (LOW): Remove unnecessary \n from regex
  since \s already includes newline characters
- Tighten test assertions (LOW): Change toBeLessThanOrEqual from 81/41 to
  80/40 to match actual implementation guarantees
- Fix tooltip overflow detection (LOW): Replace character-count-based tooltip
  trigger with ResizeObserver-based visual overflow detection so tooltips
  appear when text is actually clipped by line-clamp, not just when exceeding
  an arbitrary character threshold
- Add abbreviation handling tests for Dr., e.g., i.e., etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 0eaef26ec3 fix: address remaining review findings for subtask title extraction
Fixes three LOW severity findings from auto-claude PR review:
1. Decouple hardcoded 80-char threshold using SUBTASK_TITLE_MAX_LENGTH constant
2. Improve regex to handle terminal periods without trailing space/newline
3. Fix truncation to never exceed maxLength even for single-word inputs

Changes:
- Export SUBTASK_TITLE_MAX_LENGTH constant from subtask-title.ts
- Update TaskSubtasks.tsx to use the constant instead of magic number 80
- Enhanced sentence extraction regex to handle period+newline and terminal periods
- Improved truncation logic to ensure maxLength-1 + ellipsis for degenerate cases
- Added comprehensive tests for terminal period edge cases and degenerate truncation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 a72de1c34a fix: make subtask description tooltip keyboard-accessible
Replace paragraph element with button for tooltip trigger to enable keyboard navigation and focus visibility. Adds proper focus ring styling for accessibility compliance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 a5faf0e967 fix: add title to validation schema and fix Tooltip accessibility
Address review findings from sentry and CodeRabbit:
1. Add 'title' to subtask validation schema optional_fields so the AI
   planner can actually generate the field (HIGH severity finding)
2. Fix Tooltip rendering to only wrap content when description >80 chars,
   preventing Radix accessibility issues when TooltipContent is absent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 dce8437cdd fix: prevent duplicate display when subtask title equals description
Address review finding NEW-001 by conditionally rendering description
only when it differs from the title. This prevents duplicate text when
extractSubtaskTitle() returns the full description for short subtasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 94fd2b6cb4 Fix i18n bypass and relocate test file per PR review
Return empty string instead of hardcoded 'Untitled' from
extractSubtaskTitle() so the UI component's i18n fallback
(t('tasks:subtasks.untitled')) activates correctly for all locales.

Move subtask-title.test.ts to src/shared/utils/__tests__/ to match
the established co-located test pattern for shared utilities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 c504c27d46 auto-claude: subtask-4-1 - Add line-clamp-2 CSS class to subtask title span
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 0d3b2e75d6 auto-claude: subtask-3-2 - Use subtask.title with extractSubtaskTitle fallback in task-store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 41760dbbe4 auto-claude: subtask-3-1 - Use subtask.title with extractSubtaskTitle fallback in project-store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 4b21e63d34 auto-claude: subtask-2-3 - Create unit tests for extractSubtaskTitle()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 0edba9aa9b auto-claude: subtask-2-2 - Create extractSubtaskTitle utility function
Add shared utility for extracting concise titles from subtask descriptions
with sentence extraction and word-boundary truncation fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 69e32e839b auto-claude: subtask-2-1 - Add optional 'title' field to PlanSubtask interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 e361795001 auto-claude: subtask-1-2 - Add title field to planner prompt subtask examples
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
AndyMik90 e96c15e1bb auto-claude: subtask-1-1 - Add optional title field to Subtask dataclass
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:05:51 +01:00
9 changed files with 343 additions and 17 deletions
@@ -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),
+8
View File
@@ -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",
+2 -1
View File
@@ -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 {
+1
View File
@@ -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`;
}