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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user