Skip to content

Commit 392b156

Browse files
committed
style: align pilot-shell.com task card with Console layout
1 parent 3c0d785 commit 392b156

1 file changed

Lines changed: 178 additions & 54 deletions

File tree

docs/site/src/components/feedback/SectionedBlockRenderer.tsx

Lines changed: 178 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
120135
const PLAN_METADATA_RE = /^(Created|Author|Status|Approved|Iterations|Worktree|Type):/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(/^Task\s+(\d+):/);
159+
if (!match) continue;
160+
completion.set(parseInt(match[1], 10), block.checked === true);
161+
}
162+
return completion;
163+
}
164+
136165
function 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+
261396
export 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

Comments
 (0)