fix: Use fixed-position slide-over and render markdown descriptions with tiptap

- Switch TaskSlideOver from absolute to fixed positioning so it overlays
  the viewport instead of fighting with phase panel scroll
- Render task descriptions as rich HTML via markdownToSafeHtml (markdown
  → tiptap JSON → HTML roundtrip) with prose typography classes
- Move TaskSlideOver render to ExecutionProvider level in ExecutionTab
- Remove unnecessary relative overflow-hidden wrapper from PhaseDetailPanel
- Export markdownToSafeHtml from markdown-to-tiptap utility
This commit is contained in:
Lukas May
2026-03-04 08:08:53 +01:00
parent 2d15dcf368
commit b223427bd3
5 changed files with 45 additions and 21 deletions

View File

@@ -13,6 +13,7 @@ import {
PhaseDetailPanel,
PhaseDetailEmpty,
} from "@/components/execution/PhaseDetailPanel";
import { TaskSlideOver } from "@/components/execution/TaskSlideOver";
import { Skeleton } from "@/components/Skeleton";
interface ExecutionTabProps {
@@ -179,6 +180,7 @@ export function ExecutionTab({
phases={sortedPhases}
onAddPhase={handleStartAdd}
/>
<TaskSlideOver />
</ExecutionProvider>
);
}
@@ -252,7 +254,7 @@ export function ExecutionTab({
)}
</div>
</div>
<TaskSlideOver />
</ExecutionProvider>
);
}

View File

@@ -7,7 +7,6 @@ import { mapEntityStatus } from "@/components/StatusDot";
import { PhaseNumberBadge } from "@/components/DependencyChip";
import { type SerializedTask } from "@/components/TaskRow";
import { TaskGraph } from "./TaskGraph";
import { TaskSlideOver } from "./TaskSlideOver";
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { Skeleton } from "@/components/Skeleton";
@@ -210,7 +209,6 @@ export function PhaseDetailPanel({
detailAgent?.status === "idle" && !!latestChangeSet;
return (
<div className="relative overflow-hidden">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
@@ -424,8 +422,6 @@ export function PhaseDetailPanel({
)}
</div>
</div>
<TaskSlideOver />
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
import { getCategoryConfig } from "@/lib/category";
import { markdownToSafeHtml } from "@/lib/markdown-to-tiptap";
import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
@@ -35,13 +36,22 @@ export function TaskSlideOver() {
dependencies.every((d) => d.status === "completed");
const canQueue = task !== null && task.status === "pending" && allDepsComplete;
const descriptionHtml = useMemo(() => {
if (!task?.description) return null;
try {
return markdownToSafeHtml(task.description);
} catch {
return null;
}
}, [task?.description]);
return (
<AnimatePresence>
{selectedEntry && task && (
<>
{/* Backdrop */}
<motion.div
className="absolute inset-0 z-10 bg-background/60 backdrop-blur-[2px]"
className="fixed inset-0 z-40 bg-background/60 backdrop-blur-[2px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -51,7 +61,7 @@ export function TaskSlideOver() {
{/* Panel */}
<motion.div
className="absolute inset-y-0 right-0 z-20 flex w-full max-w-md flex-col border-l border-border bg-background shadow-xl"
className="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l border-border bg-background shadow-xl"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
@@ -100,9 +110,16 @@ export function TaskSlideOver() {
{/* Description */}
<Section title="Description">
<p className="text-sm text-muted-foreground">
{task.description ?? "No description"}
</p>
{descriptionHtml ? (
<div
className="prose prose-sm prose-p:my-1 prose-headings:mb-1 prose-headings:mt-3 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-blockquote:my-1 prose-pre:my-1 prose-hr:my-2 dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
/>
) : (
<p className="text-sm text-muted-foreground">
No description
</p>
)}
</Section>
{/* Dependencies */}

View File

@@ -27,14 +27,12 @@ interface PipelineTabProps {
export function PipelineTab({ initiativeId, phases, phasesLoading }: PipelineTabProps) {
return (
<ExecutionProvider>
<div className="relative overflow-hidden">
<PipelineTabInner
initiativeId={initiativeId}
phases={phases}
phasesLoading={phasesLoading}
/>
<TaskSlideOver />
</div>
<PipelineTabInner
initiativeId={initiativeId}
phases={phases}
phasesLoading={phasesLoading}
/>
<TaskSlideOver />
</ExecutionProvider>
);
}

View File

@@ -5,16 +5,27 @@
* Uses @tiptap/html's generateJSON to parse HTML into Tiptap nodes.
*/
import { generateJSON } from '@tiptap/html';
import { generateJSON, generateHTML } from '@tiptap/html';
import StarterKit from '@tiptap/starter-kit';
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
const extensions = [StarterKit, Table, TableRow, TableCell, TableHeader];
/**
* Convert markdown string to Tiptap JSON document.
*/
export function markdownToTiptapJson(markdown: string): object {
const html = markdownToHtml(markdown);
return generateJSON(html, [StarterKit, Table, TableRow, TableCell, TableHeader]);
return generateJSON(html, extensions);
}
/**
* Convert markdown string to sanitized HTML via tiptap roundtrip.
* Markdown → HTML → tiptap JSON → HTML ensures only known nodes survive.
*/
export function markdownToSafeHtml(markdown: string): string {
const json = markdownToTiptapJson(markdown);
return generateHTML(json as Parameters<typeof generateHTML>[0], extensions);
}
/**