Skip to content

Commit 168df73

Browse files
authored
Add checklist views for proposed plans (#109)
- Extract checklist items from plan markdown - Render plan steps as a unified checklist in sidebar and card - Add tests for task lists and numbered steps
1 parent 03ac540 commit 168df73

5 files changed

Lines changed: 563 additions & 93 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { memo } from "react";
2+
import { CheckIcon, LoaderIcon } from "lucide-react";
3+
import { cn } from "~/lib/utils";
4+
5+
// ---------------------------------------------------------------------------
6+
// Types
7+
// ---------------------------------------------------------------------------
8+
9+
export interface PlanChecklistItemData {
10+
/** Display text of the step. */
11+
text: string;
12+
/** Execution status. */
13+
status: "pending" | "inProgress" | "completed";
14+
}
15+
16+
interface PlanChecklistProps {
17+
/** Checklist items to render. */
18+
items: PlanChecklistItemData[];
19+
/**
20+
* Completion mode label shown in the header.
21+
* @default "Completed In Order"
22+
*/
23+
completionMode?: string;
24+
/** Whether the checklist represents a live execution (enables animated indicators). */
25+
live?: boolean;
26+
/** Additional class names for the outer container. */
27+
className?: string;
28+
}
29+
30+
// ---------------------------------------------------------------------------
31+
// Component
32+
// ---------------------------------------------------------------------------
33+
34+
const PlanChecklist = memo(function PlanChecklist({
35+
items,
36+
completionMode = "Completed In Order",
37+
live = false,
38+
className,
39+
}: PlanChecklistProps) {
40+
if (items.length === 0) return null;
41+
42+
const completedCount = items.filter((item) => item.status === "completed").length;
43+
44+
return (
45+
<div data-slot="plan-checklist" className={cn("space-y-2", className)}>
46+
{/* Header */}
47+
<div className="flex items-center justify-between gap-2 px-1">
48+
<p className="text-[11px] text-muted-foreground/60">
49+
<span className="tabular-nums font-medium text-muted-foreground/80">{items.length}</span>{" "}
50+
{items.length === 1 ? "To-do" : "To-dos"}
51+
<span className="mx-1.5 text-muted-foreground/30">&middot;</span>
52+
<span>{completionMode}</span>
53+
</p>
54+
</div>
55+
56+
{/* Items */}
57+
<div className="rounded-xl border border-border/50 bg-background/40">
58+
{items.map((item, index) => (
59+
<PlanChecklistRow
60+
key={`checklist-item-${index}-${item.text.slice(0, 32)}`}
61+
item={item}
62+
index={index}
63+
isLast={index === items.length - 1}
64+
live={live}
65+
/>
66+
))}
67+
</div>
68+
69+
{/* Progress summary */}
70+
{completedCount > 0 && completedCount < items.length ? (
71+
<div className="flex items-center gap-2.5 px-1">
72+
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted/50">
73+
<div
74+
className="h-full rounded-full bg-emerald-500/70 transition-all duration-500 ease-out"
75+
style={{
76+
width: `${Math.round((completedCount / items.length) * 100)}%`,
77+
}}
78+
/>
79+
</div>
80+
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/50">
81+
{completedCount}/{items.length}
82+
</span>
83+
</div>
84+
) : null}
85+
</div>
86+
);
87+
});
88+
89+
// ---------------------------------------------------------------------------
90+
// Row
91+
// ---------------------------------------------------------------------------
92+
93+
const PlanChecklistRow = memo(function PlanChecklistRow({
94+
item,
95+
index,
96+
isLast,
97+
live,
98+
}: {
99+
item: PlanChecklistItemData;
100+
index: number;
101+
isLast: boolean;
102+
live: boolean;
103+
}) {
104+
return (
105+
<div
106+
data-slot="plan-checklist-item"
107+
data-status={item.status}
108+
className={cn(
109+
"flex items-start gap-3 px-3.5 py-2.5 transition-colors duration-150",
110+
!isLast && "border-b border-border/30",
111+
item.status === "inProgress" && "bg-blue-500/[0.03]",
112+
)}
113+
>
114+
{/* Status indicator */}
115+
<div className="mt-0.5 flex size-5 shrink-0 items-center justify-center">
116+
<ChecklistStatusIndicator status={item.status} live={live} />
117+
</div>
118+
119+
{/* Text */}
120+
<p
121+
className={cn(
122+
"min-w-0 flex-1 text-[13px] leading-snug",
123+
item.status === "completed"
124+
? "text-muted-foreground/45 line-through decoration-muted-foreground/20"
125+
: item.status === "inProgress"
126+
? "text-foreground/90 font-medium"
127+
: "text-foreground/70",
128+
)}
129+
>
130+
{item.text}
131+
</p>
132+
133+
{/* Item number */}
134+
<span className="mt-0.5 shrink-0 text-[10px] tabular-nums text-muted-foreground/25">
135+
{index + 1}
136+
</span>
137+
</div>
138+
);
139+
});
140+
141+
// ---------------------------------------------------------------------------
142+
// Status indicator
143+
// ---------------------------------------------------------------------------
144+
145+
function ChecklistStatusIndicator({
146+
status,
147+
live,
148+
}: {
149+
status: PlanChecklistItemData["status"];
150+
live: boolean;
151+
}) {
152+
if (status === "completed") {
153+
return (
154+
<span className="flex size-[18px] items-center justify-center rounded-full bg-emerald-500/15 text-emerald-500 ring-1 ring-emerald-500/25">
155+
<CheckIcon className="size-3" strokeWidth={2.5} />
156+
</span>
157+
);
158+
}
159+
160+
if (status === "inProgress") {
161+
return (
162+
<span className="flex size-[18px] items-center justify-center rounded-full bg-blue-500/10 text-blue-400 ring-1 ring-blue-400/30">
163+
{live ? (
164+
<LoaderIcon className="size-3 animate-spin" />
165+
) : (
166+
<span className="size-2 rounded-full bg-blue-400" />
167+
)}
168+
</span>
169+
);
170+
}
171+
172+
// pending
173+
return (
174+
<span className="flex size-[18px] items-center justify-center rounded-full ring-1 ring-border/60">
175+
<span className="size-1.5 rounded-full bg-muted-foreground/20" />
176+
</span>
177+
);
178+
}
179+
180+
export default PlanChecklist;
181+
export type { PlanChecklistProps };

apps/web/src/components/PlanSidebar.tsx

Lines changed: 34 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,7 @@ import { type TimestampFormat } from "../appSettings";
33
import { Button } from "./ui/button";
44
import { ScrollArea } from "./ui/scroll-area";
55
import ChatMarkdown from "./ChatMarkdown";
6-
import {
7-
CheckIcon,
8-
ChevronDownIcon,
9-
ChevronRightIcon,
10-
EllipsisIcon,
11-
LoaderIcon,
12-
PanelRightCloseIcon,
13-
} from "lucide-react";
14-
import { cn } from "~/lib/utils";
6+
import { ChevronDownIcon, ChevronRightIcon, EllipsisIcon, PanelRightCloseIcon } from "lucide-react";
157
import type { ActivePlanState } from "../session-logic";
168
import type { LatestProposedPlanState } from "../session-logic";
179
import {
@@ -21,6 +13,8 @@ import {
2113
downloadPlanAsTextFile,
2214
stripDisplayedPlanMarkdown,
2315
} from "../proposedPlan";
16+
import { extractPlanChecklistItems } from "../planChecklist";
17+
import PlanChecklist from "./PlanChecklist";
2418
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
2519
import { readNativeApi } from "~/nativeApi";
2620
import { toastManager } from "./ui/toast";
@@ -33,28 +27,6 @@ const PLAN_SIDEBAR_DEFAULT_WIDTH = 340;
3327
const PLAN_SIDEBAR_MIN_WIDTH = 260;
3428
const PLAN_SIDEBAR_MAX_WIDTH = 800;
3529

36-
function stepStatusIcon(status: string): React.ReactNode {
37-
if (status === "completed") {
38-
return (
39-
<span className="flex size-4 shrink-0 items-center justify-center text-emerald-500">
40-
<CheckIcon className="size-3" />
41-
</span>
42-
);
43-
}
44-
if (status === "inProgress") {
45-
return (
46-
<span className="flex size-4 shrink-0 items-center justify-center text-blue-400">
47-
<LoaderIcon className="size-3 animate-spin" />
48-
</span>
49-
);
50-
}
51-
return (
52-
<span className="flex size-4 shrink-0 items-center justify-center">
53-
<span className="size-1.5 rounded-full bg-muted-foreground/25" />
54-
</span>
55-
);
56-
}
57-
5830
interface PlanSidebarProps {
5931
activePlan: ActivePlanState | null;
6032
activeProposedPlan: LatestProposedPlanState | null;
@@ -175,7 +147,7 @@ const PlanSidebar = memo(function PlanSidebar({
175147
onClose,
176148
}: PlanSidebarProps) {
177149
const hasActiveSteps = (activePlan?.steps.length ?? 0) > 0;
178-
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(!hasActiveSteps);
150+
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
179151
const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
180152
const { copyToClipboard, isCopied } = useCopyToClipboard();
181153
const { width, railProps } = useResizablePlanSidebar();
@@ -185,12 +157,34 @@ const PlanSidebar = memo(function PlanSidebar({
185157
const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
186158
const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;
187159

188-
// Auto-expand the full plan when there are no active execution steps
160+
// Derive checklist items: prefer live execution steps; fall back to markdown extraction.
161+
// Always normalised to { text, status } for the PlanChecklist component.
162+
const checklistItems = useMemo<
163+
Array<{ text: string; status: "pending" | "inProgress" | "completed" }>
164+
>(() => {
165+
if (hasActiveSteps && activePlan) {
166+
return activePlan.steps.map((s) => ({ text: s.step, status: s.status }));
167+
}
168+
if (planMarkdown) {
169+
const extracted = extractPlanChecklistItems(planMarkdown);
170+
if (extracted.length > 0) {
171+
return extracted.map((item) => ({
172+
text: item.text,
173+
status: item.completed ? ("completed" as const) : ("pending" as const),
174+
}));
175+
}
176+
}
177+
return [];
178+
}, [hasActiveSteps, activePlan, planMarkdown]);
179+
180+
const hasChecklist = checklistItems.length > 0;
181+
182+
// Auto-expand markdown when there are no checklist items and no active steps.
189183
useEffect(() => {
190-
if (!hasActiveSteps && planMarkdown) {
184+
if (!hasChecklist && planMarkdown) {
191185
setProposedPlanExpanded(true);
192186
}
193-
}, [hasActiveSteps, planMarkdown]);
187+
}, [hasChecklist, planMarkdown]);
194188

195189
const handleCopyPlan = useCallback(() => {
196190
if (!planMarkdown) return;
@@ -320,33 +314,10 @@ const PlanSidebar = memo(function PlanSidebar({
320314
</p>
321315
) : null}
322316

323-
{/* Plan Steps */}
324-
{activePlan && activePlan.steps.length > 0 ? (
325-
<div className="space-y-0.5">
326-
{activePlan.steps.map((step) => (
327-
<div
328-
key={`${step.status}:${step.step}`}
329-
className="flex items-start gap-2 px-1 py-1.5"
330-
>
331-
<div className="mt-0.5">{stepStatusIcon(step.status)}</div>
332-
<p
333-
className={cn(
334-
"text-[13px] leading-snug",
335-
step.status === "completed"
336-
? "text-muted-foreground/40 line-through decoration-muted-foreground/20"
337-
: step.status === "inProgress"
338-
? "text-foreground/90"
339-
: "text-muted-foreground/60",
340-
)}
341-
>
342-
{step.step}
343-
</p>
344-
</div>
345-
))}
346-
</div>
347-
) : null}
317+
{/* Checklist (primary view) */}
318+
{hasChecklist ? <PlanChecklist items={checklistItems} live={hasActiveSteps} /> : null}
348319

349-
{/* Proposed Plan Markdown */}
320+
{/* Proposed Plan Markdown (collapsible detail) */}
350321
{planMarkdown ? (
351322
<div className="space-y-2">
352323
<button
@@ -360,7 +331,7 @@ const PlanSidebar = memo(function PlanSidebar({
360331
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" />
361332
)}
362333
<span className="text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase group-hover:text-muted-foreground/60">
363-
{planTitle ?? "Full Plan"}
334+
Full Plan
364335
</span>
365336
</button>
366337
{proposedPlanExpanded ? (
@@ -376,7 +347,7 @@ const PlanSidebar = memo(function PlanSidebar({
376347
) : null}
377348

378349
{/* Empty state */}
379-
{!activePlan && !planMarkdown ? (
350+
{!hasChecklist && !planMarkdown ? (
380351
<div className="flex flex-col items-center justify-center py-12 text-center">
381352
<p className="text-[13px] text-muted-foreground/40">No active plan yet.</p>
382353
<p className="mt-1 text-[11px] text-muted-foreground/30">

0 commit comments

Comments
 (0)