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

View File

@@ -7,7 +7,6 @@ import { mapEntityStatus } from "@/components/StatusDot";
import { PhaseNumberBadge } from "@/components/DependencyChip"; import { PhaseNumberBadge } from "@/components/DependencyChip";
import { type SerializedTask } from "@/components/TaskRow"; import { type SerializedTask } from "@/components/TaskRow";
import { TaskGraph } from "./TaskGraph"; import { TaskGraph } from "./TaskGraph";
import { TaskSlideOver } from "./TaskSlideOver";
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor"; import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
import { ChangeSetBanner } from "@/components/ChangeSetBanner"; import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
@@ -210,7 +209,6 @@ export function PhaseDetailPanel({
detailAgent?.status === "idle" && !!latestChangeSet; detailAgent?.status === "idle" && !!latestChangeSet;
return ( return (
<div className="relative overflow-hidden">
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -424,8 +422,6 @@ export function PhaseDetailPanel({
)} )}
</div> </div>
</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 { motion, AnimatePresence } from "motion/react";
import { X, Trash2 } from "lucide-react"; import { X, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot"; import { StatusDot } from "@/components/StatusDot";
import { getCategoryConfig } from "@/lib/category"; import { getCategoryConfig } from "@/lib/category";
import { markdownToSafeHtml } from "@/lib/markdown-to-tiptap";
import { useExecutionContext } from "./ExecutionContext"; import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -35,13 +36,22 @@ export function TaskSlideOver() {
dependencies.every((d) => d.status === "completed"); dependencies.every((d) => d.status === "completed");
const canQueue = task !== null && task.status === "pending" && allDepsComplete; 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 ( return (
<AnimatePresence> <AnimatePresence>
{selectedEntry && task && ( {selectedEntry && task && (
<> <>
{/* Backdrop */} {/* Backdrop */}
<motion.div <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 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@@ -51,7 +61,7 @@ export function TaskSlideOver() {
{/* Panel */} {/* Panel */}
<motion.div <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%" }} initial={{ x: "100%" }}
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: "100%" }} exit={{ x: "100%" }}
@@ -100,9 +110,16 @@ export function TaskSlideOver() {
{/* Description */} {/* Description */}
<Section title="Description"> <Section title="Description">
{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"> <p className="text-sm text-muted-foreground">
{task.description ?? "No description"} No description
</p> </p>
)}
</Section> </Section>
{/* Dependencies */} {/* Dependencies */}

View File

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

View File

@@ -5,16 +5,27 @@
* Uses @tiptap/html's generateJSON to parse HTML into Tiptap nodes. * 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 StarterKit from '@tiptap/starter-kit';
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'; import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
const extensions = [StarterKit, Table, TableRow, TableCell, TableHeader];
/** /**
* Convert markdown string to Tiptap JSON document. * Convert markdown string to Tiptap JSON document.
*/ */
export function markdownToTiptapJson(markdown: string): object { export function markdownToTiptapJson(markdown: string): object {
const html = markdownToHtml(markdown); 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);
} }
/** /**