import { memo, useState, useCallback } from "react";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { ScrollArea } from "./ui/scroll-area";
import ChatMarkdown from "./ChatMarkdown";
import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
EllipsisIcon,
LoaderIcon,
} from "lucide-react";
import { cn } from "~/lib/utils";
import type { ActivePlanState } from "../session-logic";
import type { LatestProposedPlanState } from "../session-logic";
import { formatTimestamp } from "../timestampFormat";
import {
proposedPlanTitle,
buildProposedPlanMarkdownFilename,
normalizePlanMarkdownForExport,
downloadPlanAsTextFile,
stripDisplayedPlanMarkdown,
} from "../proposedPlan";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
import { projectEnvironment } from "~/state/projects";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useAtomCommand } from "~/state/use-atom-command";
function stepStatusIcon(status: string): React.ReactNode {
if (status === "completed") {
return (
);
}
if (status === "inProgress") {
return (
);
}
return (
);
}
interface PlanSidebarProps {
activePlan: ActivePlanState | null;
activeProposedPlan: LatestProposedPlanState | null;
label?: string;
environmentId: EnvironmentId;
threadRef?: ScopedThreadRef | undefined;
markdownCwd: string | undefined;
workspaceRoot: string | undefined;
timestampFormat: TimestampFormat;
mode?: "sheet" | "sidebar" | "embedded";
}
const PlanSidebar = memo(function PlanSidebar({
activePlan,
activeProposedPlan,
label = "Plan",
environmentId,
threadRef,
markdownCwd,
workspaceRoot,
timestampFormat,
mode = "sidebar",
}: PlanSidebarProps) {
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, {
reportFailure: false,
});
const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" });
const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;
const handleCopyPlan = useCallback(() => {
if (!planMarkdown) return;
copyToClipboard(planMarkdown);
}, [planMarkdown, copyToClipboard]);
const handleDownload = useCallback(() => {
if (!planMarkdown) return;
const filename = buildProposedPlanMarkdownFilename(planMarkdown);
downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown));
}, [planMarkdown]);
const handleSaveToWorkspace = useCallback(() => {
if (!workspaceRoot || !planMarkdown) return;
const filename = buildProposedPlanMarkdownFilename(planMarkdown);
setIsSavingToWorkspace(true);
void (async () => {
const result = await writeProjectFile({
environmentId,
input: {
cwd: workspaceRoot,
relativePath: filename,
contents: normalizePlanMarkdownForExport(planMarkdown),
},
});
setIsSavingToWorkspace(false);
if (result._tag === "Success") {
toastManager.add({
type: "success",
title: "Plan saved",
description: result.value.relativePath,
});
return;
}
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not save plan",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
})();
}, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]);
return (
{/* Header */}
{label}
{activePlan ? (
{formatTimestamp(activePlan.createdAt, timestampFormat)}
) : null}
{planMarkdown ? (
) : null}
{/* Content */}
{/* Explanation */}
{activePlan?.explanation ? (
{activePlan.explanation}
) : null}
{/* Plan Steps */}
{activePlan && activePlan.steps.length > 0 ? (
Steps
{activePlan.steps.map((step) => (
{stepStatusIcon(step.status)}
{step.step}
))}
) : null}
{/* Proposed Plan Markdown */}
{planMarkdown ? (
{proposedPlanExpanded ? (
) : null}
) : null}
{/* Empty state */}
{!activePlan && !planMarkdown ? (
No active plan yet.
Plans will appear here when generated.
) : null}
);
});
export default PlanSidebar;
export type { PlanSidebarProps };