Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions apps/web/src/components/PlanChecklist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { memo } from "react";
import { CheckIcon, LoaderIcon } from "lucide-react";
import { cn } from "~/lib/utils";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface PlanChecklistItemData {
/** Display text of the step. */
text: string;
/** Execution status. */
status: "pending" | "inProgress" | "completed";
}

interface PlanChecklistProps {
/** Checklist items to render. */
items: PlanChecklistItemData[];
/**
* Completion mode label shown in the header.
* @default "Completed In Order"
*/
completionMode?: string;
/** Whether the checklist represents a live execution (enables animated indicators). */
live?: boolean;
/** Additional class names for the outer container. */
className?: string;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

const PlanChecklist = memo(function PlanChecklist({
items,
completionMode = "Completed In Order",
live = false,
className,
}: PlanChecklistProps) {
if (items.length === 0) return null;

const completedCount = items.filter((item) => item.status === "completed").length;

return (
<div data-slot="plan-checklist" className={cn("space-y-2", className)}>
{/* Header */}
<div className="flex items-center justify-between gap-2 px-1">
<p className="text-[11px] text-muted-foreground/60">
<span className="tabular-nums font-medium text-muted-foreground/80">{items.length}</span>{" "}
{items.length === 1 ? "To-do" : "To-dos"}
<span className="mx-1.5 text-muted-foreground/30">&middot;</span>
<span>{completionMode}</span>
</p>
</div>

{/* Items */}
<div className="rounded-xl border border-border/50 bg-background/40">
{items.map((item, index) => (
<PlanChecklistRow
key={`checklist-item-${index}-${item.text.slice(0, 32)}`}
item={item}
index={index}
isLast={index === items.length - 1}
live={live}
/>
))}
</div>

{/* Progress summary */}
{completedCount > 0 && completedCount < items.length ? (
<div className="flex items-center gap-2.5 px-1">
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted/50">
<div
className="h-full rounded-full bg-emerald-500/70 transition-all duration-500 ease-out"
style={{
width: `${Math.round((completedCount / items.length) * 100)}%`,
}}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/50">
{completedCount}/{items.length}
</span>
</div>
) : null}
</div>
);
});

// ---------------------------------------------------------------------------
// Row
// ---------------------------------------------------------------------------

const PlanChecklistRow = memo(function PlanChecklistRow({
item,
index,
isLast,
live,
}: {
item: PlanChecklistItemData;
index: number;
isLast: boolean;
live: boolean;
}) {
return (
<div
data-slot="plan-checklist-item"
data-status={item.status}
className={cn(
"flex items-start gap-3 px-3.5 py-2.5 transition-colors duration-150",
!isLast && "border-b border-border/30",
item.status === "inProgress" && "bg-blue-500/[0.03]",
)}
>
{/* Status indicator */}
<div className="mt-0.5 flex size-5 shrink-0 items-center justify-center">
<ChecklistStatusIndicator status={item.status} live={live} />
</div>

{/* Text */}
<p
className={cn(
"min-w-0 flex-1 text-[13px] leading-snug",
item.status === "completed"
? "text-muted-foreground/45 line-through decoration-muted-foreground/20"
: item.status === "inProgress"
? "text-foreground/90 font-medium"
: "text-foreground/70",
)}
>
{item.text}
</p>

{/* Item number */}
<span className="mt-0.5 shrink-0 text-[10px] tabular-nums text-muted-foreground/25">
{index + 1}
</span>
</div>
);
});

// ---------------------------------------------------------------------------
// Status indicator
// ---------------------------------------------------------------------------

function ChecklistStatusIndicator({
status,
live,
}: {
status: PlanChecklistItemData["status"];
live: boolean;
}) {
if (status === "completed") {
return (
<span className="flex size-[18px] items-center justify-center rounded-full bg-emerald-500/15 text-emerald-500 ring-1 ring-emerald-500/25">
<CheckIcon className="size-3" strokeWidth={2.5} />
</span>
);
}

if (status === "inProgress") {
return (
<span className="flex size-[18px] items-center justify-center rounded-full bg-blue-500/10 text-blue-400 ring-1 ring-blue-400/30">
{live ? (
<LoaderIcon className="size-3 animate-spin" />
) : (
<span className="size-2 rounded-full bg-blue-400" />
)}
</span>
);
}

// pending
return (
<span className="flex size-[18px] items-center justify-center rounded-full ring-1 ring-border/60">
<span className="size-1.5 rounded-full bg-muted-foreground/20" />
</span>
);
}

export default PlanChecklist;
export type { PlanChecklistProps };
97 changes: 34 additions & 63 deletions apps/web/src/components/PlanSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,7 @@ import { type TimestampFormat } from "../appSettings";
import { Button } from "./ui/button";
import { ScrollArea } from "./ui/scroll-area";
import ChatMarkdown from "./ChatMarkdown";
import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
EllipsisIcon,
LoaderIcon,
PanelRightCloseIcon,
} from "lucide-react";
import { cn } from "~/lib/utils";
import { ChevronDownIcon, ChevronRightIcon, EllipsisIcon, PanelRightCloseIcon } from "lucide-react";
import type { ActivePlanState } from "../session-logic";
import type { LatestProposedPlanState } from "../session-logic";
import {
Expand All @@ -21,6 +13,8 @@ import {
downloadPlanAsTextFile,
stripDisplayedPlanMarkdown,
} from "../proposedPlan";
import { extractPlanChecklistItems } from "../planChecklist";
import PlanChecklist from "./PlanChecklist";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
import { readNativeApi } from "~/nativeApi";
import { toastManager } from "./ui/toast";
Expand All @@ -33,28 +27,6 @@ const PLAN_SIDEBAR_DEFAULT_WIDTH = 340;
const PLAN_SIDEBAR_MIN_WIDTH = 260;
const PLAN_SIDEBAR_MAX_WIDTH = 800;

function stepStatusIcon(status: string): React.ReactNode {
if (status === "completed") {
return (
<span className="flex size-4 shrink-0 items-center justify-center text-emerald-500">
<CheckIcon className="size-3" />
</span>
);
}
if (status === "inProgress") {
return (
<span className="flex size-4 shrink-0 items-center justify-center text-blue-400">
<LoaderIcon className="size-3 animate-spin" />
</span>
);
}
return (
<span className="flex size-4 shrink-0 items-center justify-center">
<span className="size-1.5 rounded-full bg-muted-foreground/25" />
</span>
);
}

interface PlanSidebarProps {
activePlan: ActivePlanState | null;
activeProposedPlan: LatestProposedPlanState | null;
Expand Down Expand Up @@ -175,7 +147,7 @@ const PlanSidebar = memo(function PlanSidebar({
onClose,
}: PlanSidebarProps) {
const hasActiveSteps = (activePlan?.steps.length ?? 0) > 0;
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(!hasActiveSteps);
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
const { copyToClipboard, isCopied } = useCopyToClipboard();
const { width, railProps } = useResizablePlanSidebar();
Expand All @@ -185,12 +157,34 @@ const PlanSidebar = memo(function PlanSidebar({
const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;

// Auto-expand the full plan when there are no active execution steps
// Derive checklist items: prefer live execution steps; fall back to markdown extraction.
// Always normalised to { text, status } for the PlanChecklist component.
const checklistItems = useMemo<
Array<{ text: string; status: "pending" | "inProgress" | "completed" }>
>(() => {
if (hasActiveSteps && activePlan) {
return activePlan.steps.map((s) => ({ text: s.step, status: s.status }));
}
if (planMarkdown) {
const extracted = extractPlanChecklistItems(planMarkdown);
if (extracted.length > 0) {
return extracted.map((item) => ({
text: item.text,
status: item.completed ? ("completed" as const) : ("pending" as const),
}));
}
}
return [];
}, [hasActiveSteps, activePlan, planMarkdown]);

const hasChecklist = checklistItems.length > 0;

// Auto-expand markdown when there are no checklist items and no active steps.
useEffect(() => {
if (!hasActiveSteps && planMarkdown) {
if (!hasChecklist && planMarkdown) {
setProposedPlanExpanded(true);
}
}, [hasActiveSteps, planMarkdown]);
}, [hasChecklist, planMarkdown]);

const handleCopyPlan = useCallback(() => {
if (!planMarkdown) return;
Expand Down Expand Up @@ -320,33 +314,10 @@ const PlanSidebar = memo(function PlanSidebar({
</p>
) : null}

{/* Plan Steps */}
{activePlan && activePlan.steps.length > 0 ? (
<div className="space-y-0.5">
{activePlan.steps.map((step) => (
<div
key={`${step.status}:${step.step}`}
className="flex items-start gap-2 px-1 py-1.5"
>
<div className="mt-0.5">{stepStatusIcon(step.status)}</div>
<p
className={cn(
"text-[13px] leading-snug",
step.status === "completed"
? "text-muted-foreground/40 line-through decoration-muted-foreground/20"
: step.status === "inProgress"
? "text-foreground/90"
: "text-muted-foreground/60",
)}
>
{step.step}
</p>
</div>
))}
</div>
) : null}
{/* Checklist (primary view) */}
{hasChecklist ? <PlanChecklist items={checklistItems} live={hasActiveSteps} /> : null}

{/* Proposed Plan Markdown */}
{/* Proposed Plan Markdown (collapsible detail) */}
{planMarkdown ? (
<div className="space-y-2">
<button
Expand All @@ -360,7 +331,7 @@ const PlanSidebar = memo(function PlanSidebar({
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" />
)}
<span className="text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase group-hover:text-muted-foreground/60">
{planTitle ?? "Full Plan"}
Full Plan
</span>
</button>
{proposedPlanExpanded ? (
Expand All @@ -376,7 +347,7 @@ const PlanSidebar = memo(function PlanSidebar({
) : null}

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