Files
kandev/apps/web/components/diff/walkthrough-step-card.tsx
T
2026-09-22 23:36:47 +01:00

360 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { IconX, IconFileCode } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { useAppStore } from "@/components/state-provider";
import { useCommentsStore } from "@/lib/state/slices/comments";
import { useFileEditors } from "@/hooks/use-file-editors";
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
import { revealFileAtLine, type OpenFileFn } from "@/lib/diff/walkthrough-reveal";
import { generateUUID } from "@/lib/utils";
import { useToast } from "@/components/toast-provider";
import {
markdownComponents,
normalizeMarkdown,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import type { TaskWalkthrough, WalkthroughStep } from "@/lib/types/http";
import type { WalkthroughComment } from "@/lib/state/slices/comments";
import { CommentForm } from "./comment-form";
import { cn } from "@kandev/ui/lib/utils";
import { useTranslation } from "react-i18next";
export const WALKTHROUGH_STEP_BODY_CLASS =
"prose prose-sm dark:prose-invert max-w-none text-left text-sm leading-6 [overflow-wrap:anywhere] [text-align:left] [&_li]:text-left [&_p]:my-2 [&_p]:text-left";
function StepBody({ step, onOpenFile }: { step: WalkthroughStep; onOpenFile: () => void }) {
const { t } = useTranslation();
const lineLabel = step.line_end ? `${step.line}–${step.line_end}` : `${step.line}`;
const fileName = step.file.split("/").pop() || step.file;
return (
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
{step.title ? (
<h4 className="text-sm font-semibold mb-1.5" data-testid="walkthrough-step-title">
{step.title}
</h4>
) : null}
<button
type="button"
onClick={onOpenFile}
title={t("diff:openFileAtLine", { file: step.file, line: step.line })}
data-testid="walkthrough-step-file"
className="mb-2.5 flex max-w-full cursor-pointer items-center gap-1.5 rounded-md bg-muted/60 px-2 py-1 text-xs font-medium hover:bg-muted"
>
<IconFileCode className="size-3.5 shrink-0 text-primary" />
<span className="truncate font-mono">{fileName}</span>
<span className="shrink-0 text-muted-foreground">:{lineLabel}</span>
</button>
<div className={WALKTHROUGH_STEP_BODY_CLASS} data-testid="walkthrough-step-body">
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{normalizeMarkdown(step.text)}
</ReactMarkdown>
</div>
</div>
);
}
function StepHeader({
activeStep,
stepCount,
lineLabel,
onClose,
}: {
activeStep: number;
stepCount: number;
lineLabel: string;
onClose: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="flex cursor-move touch-none items-center gap-2 border-b border-border px-3 pt-2 pb-1.5"
data-testid="walkthrough-drag-handle"
data-walkthrough-drag-handle
>
<Badge variant="secondary" data-testid="walkthrough-badge">
{t("diff:walkthrough")}
</Badge>
<span className="text-xs text-muted-foreground" data-testid="walkthrough-step-header">
{t("diff:stepProgress", { current: activeStep + 1, total: stepCount, lineLabel })}
</span>
<Button
variant="ghost"
size="icon"
className="ml-auto size-6 cursor-pointer"
aria-label={t("diff:closeWalkthrough")}
data-testid="walkthrough-close"
onClick={onClose}
>
<IconX className="size-4" />
</Button>
</div>
);
}
function StepNavigation({
activeStep,
stepCount,
onPrevious,
onNext,
}: {
activeStep: number;
stepCount: number;
onPrevious: () => void;
onNext: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2"
data-testid="walkthrough-navigation"
>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
disabled={activeStep <= 0}
data-testid="walkthrough-prev"
onClick={onPrevious}
>
{t("diff:previous")}
</Button>
<Button
size="sm"
className="cursor-pointer"
disabled={activeStep >= stepCount - 1}
data-testid="walkthrough-next"
onClick={onNext}
>
{t("diff:next")}
</Button>
</div>
);
}
/** True when the active task has a walkthrough with a valid active step. */
export function useHasActiveWalkthroughStep(): boolean {
return useAppStore((s) => {
const taskId = s.tasks.activeTaskId;
if (!taskId) return false;
const wt = s.walkthroughs.byTaskId[taskId];
const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0;
return !!wt?.steps[idx];
});
}
function useWalkthroughStepFeedback(params: {
activeStep: number;
activeTaskId: string | null;
sessionId: string | null | undefined;
step: WalkthroughStep | undefined;
stepCount: number;
walkthrough: TaskWalkthrough | null | undefined;
}) {
const { t } = useTranslation();
const { activeStep, activeTaskId, sessionId, step, stepCount, walkthrough } = params;
const addComment = useCommentsStore((s) => s.addComment);
const { runComment } = useRunComment({ sessionId: sessionId ?? null, taskId: activeTaskId });
const { toast } = useToast();
const buildComment = (text: string): WalkthroughComment | null => {
if (!sessionId || !activeTaskId || !walkthrough || !step) return null;
return buildWalkthroughComment({
sessionId,
taskId: activeTaskId,
walkthrough,
step,
activeStep,
stepCount,
text,
});
};
const showMissingSessionError = () => {
toast({ title: t("diff:noActiveSessionForWalkthroughNote"), variant: "error" });
};
const addWalkthroughFeedback = (text: string) => {
const comment = buildComment(text);
if (!comment) {
showMissingSessionError();
return;
}
addComment(comment);
toast({ title: t("diff:walkthroughNoteAdded"), variant: "success" });
};
const runWalkthroughFeedback = (text: string) => {
const comment = buildComment(text);
if (!comment) {
showMissingSessionError();
return;
}
addComment(comment);
void runComment(comment)
.then(({ queued }) => {
toast({
title: queued ? t("diff:walkthroughNoteQueued") : t("diff:walkthroughNoteSent"),
variant: "success",
});
})
.catch(() => {
toast({ title: t("diff:failedToSendWalkthroughNote"), variant: "error" });
});
};
return { addWalkthroughFeedback, runWalkthroughFeedback };
}
/**
* The shared inner card body (header, markdown, Prev/Next, ask box) used by both
* the inline diff-anchored card and the editor-mode floating window. Reads all
* state from the store; renders nothing when there is no active step. The note
* box mirrors other agent feedback surfaces: "Add" creates a pending
* walkthrough context item, and "Run" sends the same context to the agent.
*/
export function WalkthroughStepInner({
onClose,
onSelectFile,
layout = "inline",
}: {
onClose: () => void;
onSelectFile?: OpenFileFn;
layout?: "inline" | "floating";
}) {
const { t } = useTranslation();
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const sessionId = useAppStore((s) => s.tasks.activeSessionId);
const walkthrough = useAppStore((s) =>
activeTaskId ? s.walkthroughs.byTaskId[activeTaskId] : null,
);
const activeStep = useAppStore((s) =>
activeTaskId ? (s.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0,
);
const setActiveStep = useAppStore((s) => s.setWalkthroughActiveStep);
const markSeen = useAppStore((s) => s.markWalkthroughSeen);
const { openFile: defaultOpenFile } = useFileEditors();
const openFile = onSelectFile ?? defaultOpenFile;
const stepCount = walkthrough?.steps.length ?? 0;
const step = walkthrough?.steps[activeStep];
const { addWalkthroughFeedback, runWalkthroughFeedback } = useWalkthroughStepFeedback({
activeStep,
activeTaskId,
sessionId,
step,
stepCount,
walkthrough,
});
useEffect(() => {
if (activeTaskId && walkthrough) markSeen(activeTaskId);
}, [activeTaskId, walkthrough, markSeen]);
if (!activeTaskId || !walkthrough) return null;
if (!step) return null;
const isFloating = layout === "floating";
// Two keys rather than one plural: a range and a single line are structurally
// different messages, not two counts of the same one. Left English, this fed
// the translated `stepProgress` header as a hardcoded "Line"/"Lines".
const lineLabel = step.line_end
? t("diff:lineRangeLabel", { start: step.line, end: step.line_end })
: t("diff:lineLabel", { line: step.line });
const navigation = (
<StepNavigation
activeStep={activeStep}
stepCount={stepCount}
onPrevious={() => setActiveStep(activeTaskId, activeStep - 1)}
onNext={() => setActiveStep(activeTaskId, activeStep + 1)}
/>
);
return (
<div
className={cn(
"flex max-h-[calc(100dvh-2rem)] flex-col rounded-xl border border-border border-l-2 border-primary/60 bg-card shadow-lg sm:max-h-[min(78vh,720px)]",
isFloating &&
"h-[calc(100dvh-1rem-var(--app-status-bar-height)-env(safe-area-inset-bottom,0px))] overflow-hidden sm:h-[min(78vh,720px)]",
)}
>
<StepHeader
activeStep={activeStep}
stepCount={stepCount}
lineLabel={lineLabel}
onClose={onClose}
/>
<StepBody
step={step}
onOpenFile={() => revealFileAtLine(openFile, step.file, step.line, step.repo)}
/>
{!isFloating ? navigation : null}
<div className="shrink-0 px-4 pb-3 pt-1">
<CommentForm
key={`${walkthrough.id}:${activeStep}`}
onSubmit={addWalkthroughFeedback}
onSubmitAndRun={runWalkthroughFeedback}
autoFocus={false}
/>
</div>
{isFloating ? navigation : null}
</div>
);
}
function buildWalkthroughComment(params: {
sessionId: string;
taskId: string;
walkthrough: TaskWalkthrough;
step: WalkthroughStep;
activeStep: number;
stepCount: number;
text: string;
}): WalkthroughComment {
const { sessionId, taskId, walkthrough, step, activeStep, stepCount, text } = params;
const startLine = step.line;
const endLine = step.line_end ?? step.line;
return {
id: generateUUID(),
source: "walkthrough",
sessionId,
taskId,
walkthroughId: walkthrough.id,
walkthroughTitle: walkthrough.title,
stepIndex: activeStep,
stepCount,
repo: step.repo,
filePath: step.file,
startLine: Math.min(startLine, endLine),
endLine: Math.max(startLine, endLine),
stepText: step.text,
text,
createdAt: new Date().toISOString(),
status: "pending",
};
}
/**
* Inline walkthrough popover rendered as a diff annotation, anchored directly
* beneath the step's line. A caret + left accent visually bind it to the line
* above.
*/
export function WalkthroughStepCard() {
const hasStep = useHasActiveWalkthroughStep();
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const activeStep = useAppStore((s) =>
activeTaskId ? (s.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0,
);
const updatedAt = useAppStore((s) =>
activeTaskId ? s.walkthroughs.byTaskId[activeTaskId]?.updated_at : undefined,
);
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
const stepKey = activeTaskId ? `${activeTaskId}:${updatedAt ?? ""}:${activeStep}` : "";
if (!hasStep || dismissedKey === stepKey) return null;
return (
<div className="relative my-2 ml-2 mr-2" data-testid="walkthrough-overlay">
<div className="absolute -top-1.5 left-6 size-3 rotate-45 border-l border-t border-primary/60 bg-card" />
<WalkthroughStepInner onClose={() => setDismissedKey(stepKey)} />
</div>
);
}