diff --git a/apps/backend/implementation_plan/subtask.py b/apps/backend/implementation_plan/subtask.py index 71edf94821..5bf4c4399a 100644 --- a/apps/backend/implementation_plan/subtask.py +++ b/apps/backend/implementation_plan/subtask.py @@ -20,6 +20,7 @@ class Subtask: id: str description: str + title: str | None = None status: SubtaskStatus = SubtaskStatus.PENDING # Scoping @@ -53,6 +54,8 @@ def to_dict(self) -> dict: "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 @@ def from_dict(cls, data: dict) -> "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), diff --git a/apps/backend/prompts/planner.md b/apps/backend/prompts/planner.md index ce811676b7..4055716266 100644 --- a/apps/backend/prompts/planner.md +++ b/apps/backend/prompts/planner.md @@ -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"], diff --git a/apps/backend/spec/validate_pkg/schemas.py b/apps/backend/spec/validate_pkg/schemas.py index 6683c1017c..32752ce97f 100644 --- a/apps/backend/spec/validate_pkg/schemas.py +++ b/apps/backend/spec/validate_pkg/schemas.py @@ -53,6 +53,7 @@ "subtask_schema": { "required_fields": ["id", "description", "status"], "optional_fields": [ + "title", "service", "all_services", "files_to_modify", diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index cca93eeeb0..3ef548f2c4 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -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: [] diff --git a/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx b/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx index 7d4c8a1fca..c701ec5c25 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx @@ -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(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 ( + + + setEl(node)} className={className} tabIndex={isOverflowing ? 0 : undefined}> + {text} + + + {isOverflowing && ( + +

{text}

+
+ )} +
+ ); +} + 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} - - {subtask.title || t('tasks:subtasks.untitled')} - + - - -

- {subtask.description} -

-
- {subtask.description && subtask.description.length > 80 && ( - -

{subtask.description}

-
- )} -
+ {subtask.description && subtask.description !== subtask.title && ( + + )} {subtask.files && subtask.files.length > 0 && (
{subtask.files.map((file) => ( diff --git a/apps/frontend/src/renderer/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index aa89e4cfa0..c379124341 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -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((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 { diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index 495b707380..6103cfd7f3 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -304,6 +304,7 @@ export interface Phase { export interface PlanSubtask { id: string; + title?: string; description: string; status: SubtaskStatus; verification?: { diff --git a/apps/frontend/src/shared/utils/__tests__/subtask-title.test.ts b/apps/frontend/src/shared/utils/__tests__/subtask-title.test.ts new file mode 100644 index 0000000000..34a18d72b8 --- /dev/null +++ b/apps/frontend/src/shared/utils/__tests__/subtask-title.test.ts @@ -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); + }); + }); +}); diff --git a/apps/frontend/src/shared/utils/subtask-title.ts b/apps/frontend/src/shared/utils/subtask-title.ts new file mode 100644 index 0000000000..2ab69bcff9 --- /dev/null +++ b/apps/frontend/src/shared/utils/subtask-title.ts @@ -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`; +}