@@ -4,19 +4,23 @@ import {
44 BookOpen ,
55 Bookmark ,
66 Brain ,
7+ CheckCircle2 ,
78 CheckSquare ,
89 ChevronDown ,
10+ Circle ,
911 ClipboardCheck ,
1012 ClipboardList ,
1113 Compass ,
1214 Cpu ,
1315 Crosshair ,
16+ FileCode2 ,
1417 FileText ,
1518 HelpCircle ,
1619 Lightbulb ,
1720 ListTree ,
1821 MonitorCheck ,
1922 MousePointerClick ,
23+ NotebookPen ,
2024 Route ,
2125 Scale ,
2226 SquareX ,
@@ -115,6 +119,17 @@ const SECTION_ICONS: Record<string, LucideIcon> = {
115119 "Key Decisions" : Scale ,
116120} ;
117121
122+ // Per-task field icons — matched to the Console SpecTaskCard SubBlock icons.
123+ // Keys are the canonical labels emitted by parsePlanContent's KNOWN_LABEL_FIELDS.
124+ const FIELD_ICONS : Record < string , LucideIcon > = {
125+ "Definition of Done" : CheckSquare ,
126+ DoD : CheckSquare ,
127+ Files : FileCode2 ,
128+ "Key Decisions" : NotebookPen ,
129+ "Key Decisions / Notes" : NotebookPen ,
130+ Notes : NotebookPen ,
131+ } ;
132+
118133// Plan-header metadata paragraph: `Created: …\nAuthor: …\nStatus: …\n…`.
119134// Hidden from the shared view — reviewers don't review progress.
120135const PLAN_METADATA_RE = / ^ ( C r e a t e d | A u t h o r | S t a t u s | A p p r o v e d | I t e r a t i o n s | W o r k t r e e | T y p e ) : / m;
@@ -133,6 +148,20 @@ function isTaskProgressChecklistItem(block: Block): boolean {
133148 ) ;
134149}
135150
151+ // Build a map of task-number → completion state by scanning the section's
152+ // `- [x] Task N: …` prelude checklist. Used to draw the checkbox icon on the
153+ // task card header — same signal the Console SpecTaskCard reads.
154+ function extractTaskCompletion ( sectionBlocks : Block [ ] ) : Map < number , boolean > {
155+ const completion = new Map < number , boolean > ( ) ;
156+ for ( const block of sectionBlocks ) {
157+ if ( ! isTaskProgressChecklistItem ( block ) ) continue ;
158+ const match = block . content . match ( / ^ T a s k \s + ( \d + ) : / ) ;
159+ if ( ! match ) continue ;
160+ completion . set ( parseInt ( match [ 1 ] , 10 ) , block . checked === true ) ;
161+ }
162+ return completion ;
163+ }
164+
136165function groupByH2 ( blocks : Block [ ] ) : H2Group [ ] {
137166 const groups : H2Group [ ] = [ ] ;
138167 let current : H2Group | null = null ;
@@ -258,6 +287,112 @@ function CollapsibleCard({
258287 ) ;
259288}
260289
290+ interface FieldRowProps {
291+ label : string ;
292+ icon : LucideIcon ;
293+ defaultOpen : boolean ;
294+ expanded ?: boolean ;
295+ children : React . ReactNode ;
296+ }
297+
298+ /**
299+ * Flat task-field row mirroring the Console SpecTaskCard SubBlock: top border,
300+ * icon + label + chevron, content drops in below when expanded. Used inside a
301+ * TaskCard so the fields read as a single attached list, not a stack of
302+ * nested rounded sub-cards.
303+ */
304+ function FieldRow ( { label, icon : FieldIcon , defaultOpen, expanded, children } : FieldRowProps ) {
305+ const [ open , setOpen ] = useState ( defaultOpen ) ;
306+ const isOpen = expanded ?? open ;
307+ return (
308+ < div className = "border-t border-border/50" >
309+ < button
310+ type = "button"
311+ onClick = { ( ) => setOpen ( ! isOpen ) }
312+ className = "w-full flex items-center gap-2 px-3 py-2 text-left cursor-pointer hover:bg-muted/50 transition-colors"
313+ >
314+ < FieldIcon size = { 13 } className = "text-primary/70 flex-shrink-0" />
315+ < span className = "text-xs font-medium flex-1 text-muted-foreground" > { label } </ span >
316+ < ChevronDown
317+ size = { 12 }
318+ className = { cn (
319+ "text-muted-foreground/40 transition-transform duration-200" ,
320+ isOpen ? "rotate-180" : "" ,
321+ ) }
322+ />
323+ </ button >
324+ { isOpen && < div className = "px-3 pb-3 pt-1" > { children } </ div > }
325+ </ div >
326+ ) ;
327+ }
328+
329+ interface TaskCardProps {
330+ number : number ;
331+ title : string ;
332+ completed : boolean | null ;
333+ objective : React . ReactNode | null ;
334+ expanded : boolean ;
335+ children : React . ReactNode ;
336+ }
337+
338+ /**
339+ * Per-task card matching the Console SpecTaskCard layout:
340+ * - Header (always visible): completion icon + "Task N" + title + objective.
341+ * - Body (expanded only): flat list of FieldRow entries.
342+ *
343+ * The header isn't a `<button>` because the objective renders interactive
344+ * BlockRenderer content (quick-annotate buttons), which would nest buttons.
345+ * Instead, the header is a clickable region with role="button" + keyboard
346+ * handler, and a dedicated chevron button as the accessible toggle target.
347+ */
348+ function TaskCard ( { number, title, completed, objective, expanded, children } : TaskCardProps ) {
349+ const [ open , setOpen ] = useState ( expanded ) ;
350+ const isOpen = expanded || open ;
351+ const toggle = ( ) => setOpen ( ! isOpen ) ;
352+ return (
353+ < div className = "rounded-xl border border-border bg-card overflow-hidden" >
354+ < div
355+ role = "button"
356+ tabIndex = { 0 }
357+ aria-expanded = { isOpen }
358+ onClick = { toggle }
359+ onKeyDown = { ( e ) => {
360+ if ( e . key === "Enter" || e . key === " " ) {
361+ e . preventDefault ( ) ;
362+ toggle ( ) ;
363+ }
364+ } }
365+ className = "w-full text-left cursor-pointer hover:bg-muted/40 transition-colors"
366+ >
367+ < div className = "flex items-start gap-2.5 p-3" >
368+ < div className = "flex-shrink-0 mt-0.5" >
369+ { completed ? (
370+ < CheckCircle2 size = { 16 } className = "text-green-600 dark:text-green-400" />
371+ ) : (
372+ < Circle size = { 16 } className = "text-muted-foreground/40" />
373+ ) }
374+ </ div >
375+ < div className = "flex-1 min-w-0" >
376+ < div className = "flex items-baseline gap-2" >
377+ < span className = "text-xs font-mono text-muted-foreground/70" > Task { number } </ span >
378+ </ div >
379+ < div className = "text-sm font-semibold mt-0.5 leading-snug" > { title } </ div >
380+ { objective && < div className = "mt-1.5 text-sm text-muted-foreground" > { objective } </ div > }
381+ </ div >
382+ < ChevronDown
383+ size = { 14 }
384+ className = { cn (
385+ "text-muted-foreground/40 mt-0.5 flex-shrink-0 transition-transform duration-200" ,
386+ isOpen ? "rotate-180" : "" ,
387+ ) }
388+ />
389+ </ div >
390+ </ div >
391+ { isOpen && < > { children } </ > }
392+ </ div >
393+ ) ;
394+ }
395+
261396export function SectionedBlockRenderer ( {
262397 blocks,
263398 annotations,
@@ -335,76 +470,65 @@ export function SectionedBlockRenderer({
335470 >
336471 { isTaskSection ? (
337472 < div className = "space-y-2" >
338- { groupByTaskH3 ( section . blocks ) . map ( ( task ) => {
339- const taskForceOpen = containsBlockOrHeading (
340- task . blocks ,
341- task . headingBlock ,
342- forceOpenBlockId ,
343- ) ;
344- // Prelude blocks (before the first `### Task N:`): drop
345- // the progress checklist (`- [x] Task N: …`) — the
346- // per-task cards below already show each task.
347- if ( task . headingBlock === null ) {
348- const preludeBlocks = task . blocks . filter (
349- ( b ) => ! isTaskProgressChecklistItem ( b ) ,
350- ) ;
351- if ( preludeBlocks . length === 0 ) return null ;
352- return (
353- < div key = { `prelude-${ task . blocks [ 0 ] ?. id ?? "empty" } ` } >
354- { renderLeaf ( preludeBlocks ) }
355- </ div >
473+ { ( ( ) => {
474+ const completion = extractTaskCompletion ( section . blocks ) ;
475+ return groupByTaskH3 ( section . blocks ) . map ( ( task ) => {
476+ const taskForceOpen = containsBlockOrHeading (
477+ task . blocks ,
478+ task . headingBlock ,
479+ forceOpenBlockId ,
356480 ) ;
357- }
358- const taskHeadingId = task . headingBlock . id ;
359- const { objective, rest } = extractObjectiveBlocks ( task . blocks ) ;
360- return (
361- < CollapsibleCard
362- key = { taskHeadingId }
363- title = {
364- < div className = "flex items-baseline gap-2" >
365- < span className = "text-xs font-mono text-muted-foreground/70" >
366- Task { task . number }
367- </ span >
368- < span > { task . title } </ span >
481+ // Prelude blocks (before the first `### Task N:`): drop
482+ // the progress checklist (`- [x] Task N: …`) — the
483+ // per-task cards below already show each task.
484+ if ( task . headingBlock === null ) {
485+ const preludeBlocks = task . blocks . filter (
486+ ( b ) => ! isTaskProgressChecklistItem ( b ) ,
487+ ) ;
488+ if ( preludeBlocks . length === 0 ) return null ;
489+ return (
490+ < div key = { `prelude-${ task . blocks [ 0 ] ?. id ?? "empty" } ` } >
491+ { renderLeaf ( preludeBlocks ) }
369492 </ div >
370- }
371- defaultOpen = { taskForceOpen }
372- expanded = { taskForceOpen || undefined }
373- >
374- { /* The per-task Objective renders inline as the
375- "what this task does" line — matching the Console
376- SpecTaskCard layout. No second click required. */ }
377- { objective && objective . length > 0 && (
378- < div className = "mb-3 text-sm text-muted-foreground" >
379- { renderLeaf ( objective ) }
380- </ div >
381- ) }
382- < div className = "space-y-2" >
493+ ) ;
494+ }
495+ const taskHeadingId = task . headingBlock . id ;
496+ const { objective, rest } = extractObjectiveBlocks ( task . blocks ) ;
497+ const completed = completion . get ( task . number ) ?? null ;
498+ return (
499+ < TaskCard
500+ key = { taskHeadingId }
501+ number = { task . number }
502+ title = { task . title }
503+ completed = { completed }
504+ objective = {
505+ objective && objective . length > 0 ? renderLeaf ( objective ) : null
506+ }
507+ expanded = { taskForceOpen }
508+ >
383509 { groupByLabel ( rest ) . map ( ( field ) => {
384510 const fieldForceOpen = containsBlockOrHeading (
385511 field . blocks ,
386512 null ,
387513 forceOpenBlockId ,
388514 ) ;
515+ const FieldIcon = FIELD_ICONS [ field . label ] ?? FileText ;
389516 return (
390- < CollapsibleCard
517+ < FieldRow
391518 key = { `${ taskHeadingId } -${ field . label } ` }
392- title = {
393- < span className = "text-xs font-medium text-muted-foreground" >
394- { field . label }
395- </ span >
396- }
519+ label = { field . label }
520+ icon = { FieldIcon }
397521 defaultOpen = { fieldForceOpen }
398522 expanded = { fieldForceOpen || undefined }
399523 >
400524 { renderLeaf ( field . blocks ) }
401- </ CollapsibleCard >
525+ </ FieldRow >
402526 ) ;
403527 } ) }
404- </ div >
405- </ CollapsibleCard >
406- ) ;
407- } ) }
528+ </ TaskCard >
529+ ) ;
530+ } ) ;
531+ } ) ( ) }
408532 </ div >
409533 ) : (
410534 renderLeaf ( section . blocks )
0 commit comments