284 lines · 10.0 KB · TypeScript
RawDownload
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 (      <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-success/10 text-success-foreground">        <CheckIcon className="size-3" />      </span>    );  }  if (status === "inProgress") {    return (      <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">        <LoaderIcon className="size-3 animate-spin" />      </span>    );  }  return (    <span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30">      <span className="size-1.5 rounded-full bg-muted-foreground/30" />    </span>  );} 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 (    <div      className={cn(        "flex min-h-0 flex-col bg-card/50",        mode === "sidebar"          ? "h-full w-[340px] shrink-0 border-l border-border/70"          : "h-full w-full",      )}    >      {/* Header */}      <div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3">        <div className="flex items-center gap-2">          <Badge            variant="info"            size="sm"            className="rounded-md px-1.5 py-0 font-semibold tracking-wide uppercase"          >            {label}          </Badge>          {activePlan ? (            <span className="text-[11px] text-muted-foreground/60 tabular-nums">              {formatTimestamp(activePlan.createdAt, timestampFormat)}            </span>          ) : null}        </div>        <div className="flex items-center gap-1">          {planMarkdown ? (            <Menu>              <MenuTrigger                render={                  <Button                    size="icon-xs"                    variant="ghost"                    className="text-muted-foreground/50 hover:text-foreground/70"                    aria-label="Plan actions"                  />                }              >                <EllipsisIcon className="size-3.5" />              </MenuTrigger>              <MenuPopup align="end">                <MenuItem onClick={handleCopyPlan}>                  {isCopied ? "Copied!" : "Copy to clipboard"}                </MenuItem>                <MenuItem onClick={handleDownload}>Download as markdown</MenuItem>                <MenuItem                  onClick={handleSaveToWorkspace}                  disabled={!workspaceRoot || isSavingToWorkspace}                >                  Save to workspace                </MenuItem>              </MenuPopup>            </Menu>          ) : null}        </div>      </div>       {/* Content */}      <ScrollArea className="min-h-0 flex-1">        <div className="p-3 space-y-4">          {/* Explanation */}          {activePlan?.explanation ? (            <p className="text-[13px] leading-relaxed text-muted-foreground/80">              {activePlan.explanation}            </p>          ) : null}           {/* Plan Steps */}          {activePlan && activePlan.steps.length > 0 ? (            <div className="space-y-1">              <p className="mb-2 text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase">                Steps              </p>              {activePlan.steps.map((step) => (                <div                  key={`${step.status}:${step.step}`}                  className={cn(                    "flex items-center gap-2.5 rounded-lg px-2.5 py-2 transition-colors duration-200",                    step.status === "inProgress" && "bg-blue-500/5",                    step.status === "completed" && "bg-emerald-500/5",                  )}                >                  {stepStatusIcon(step.status)}                  <p                    className={cn(                      "text-[13px] leading-snug",                      step.status === "completed"                        ? "text-muted-foreground/50 line-through decoration-muted-foreground/20"                        : step.status === "inProgress"                          ? "text-foreground/90"                          : "text-muted-foreground/70",                    )}                  >                    {step.step}                  </p>                </div>              ))}            </div>          ) : null}           {/* Proposed Plan Markdown */}          {planMarkdown ? (            <div className="space-y-2">              <button                type="button"                className="group flex w-full items-center gap-1.5 text-left"                onClick={() => setProposedPlanExpanded((v) => !v)}              >                {proposedPlanExpanded ? (                  <ChevronDownIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" />                ) : (                  <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"}                </span>              </button>              {proposedPlanExpanded ? (                <div className="rounded-lg border border-border/50 bg-background/50 p-3">                  <ChatMarkdown                    text={displayedPlanMarkdown ?? ""}                    cwd={markdownCwd}                    threadRef={threadRef}                    isStreaming={false}                  />                </div>              ) : null}            </div>          ) : null}           {/* Empty state */}          {!activePlan && !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">                Plans will appear here when generated.              </p>            </div>          ) : null}        </div>      </ScrollArea>    </div>  );}); export default PlanSidebar;export type { PlanSidebarProps };