Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@
"react-markdown": "10.1.0",
"react-resizable-panels": "3.0.6",
"rehype-raw": "7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "2.0.7",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",
Expand Down
98 changes: 96 additions & 2 deletions apps/ui/src/components/ui/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,97 @@
import ReactMarkdown from 'react-markdown';
import ReactMarkdown, { Components } from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
import { Square, CheckSquare } from 'lucide-react';

interface MarkdownProps {
children: string;
className?: string;
}

/**
* Renders a tasks code block as a proper task list with checkboxes
*/
function TasksBlock({ content }: { content: string }) {
const lines = content.split('\n');

return (
<div className="my-4 space-y-1">
{lines.map((line, idx) => {
const trimmed = line.trim();

// Check for phase/section headers (## Phase 1: ...)
const headerMatch = trimmed.match(/^##\s+(.+)$/);
if (headerMatch) {
return (
<div key={idx} className="text-foreground font-semibold mt-4 mb-2 text-sm">
{headerMatch[1]}
</div>
);
}

// Check for task items (- [ ] or - [x])
const taskMatch = trimmed.match(/^-\s*\[([ xX])\]\s*(.+)$/);
if (taskMatch) {
const isChecked = taskMatch[1].toLowerCase() === 'x';
const taskText = taskMatch[2];

return (
<div key={idx} className="flex items-start gap-2 py-1">
{isChecked ? (
<CheckSquare className="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0" />
) : (
<Square className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
)}
<span
className={cn(
'text-sm',
isChecked ? 'text-muted-foreground line-through' : 'text-foreground-secondary'
)}
>
{taskText}
</span>
</div>
);
}

// Empty lines
if (!trimmed) {
return <div key={idx} className="h-2" />;
}

// Other content (render as-is)
return (
<div key={idx} className="text-sm text-foreground-secondary">
{trimmed}
</div>
);
})}
</div>
);
}

/**
* Custom components for ReactMarkdown
*/
const markdownComponents: Components = {
// Handle code blocks - special case for 'tasks' language
code({ className, children }) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
const content = String(children).replace(/\n$/, '');

// Special handling for tasks code blocks
if (language === 'tasks') {
return <TasksBlock content={content} />;
}

// Regular code (inline or block)
return <code className={className}>{children}</code>;
},
};

/**
* Reusable Markdown component for rendering markdown content
* Theme-aware styling that adapts to all predefined themes
Expand Down Expand Up @@ -42,10 +126,20 @@ export function Markdown({ children, className }: MarkdownProps) {
'[&_hr]:border-border [&_hr]:my-4',
// Images
'[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-2 [&_img]:border [&_img]:border-border',
// Tables
'[&_table]:w-full [&_table]:border-collapse [&_table]:my-4',
'[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:text-foreground [&_th]:font-semibold',
'[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:text-foreground-secondary',
className
)}
>
<ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{children}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
{children}
</ReactMarkdown>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Markdown } from '@/components/ui/markdown';
import { PlanContentViewer } from './plan-content-viewer';
import { Label } from '@/components/ui/label';
import { Feature } from '@/store/app-store';
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
Expand Down Expand Up @@ -42,6 +42,10 @@ export function PlanApprovalDialog({
const [editedPlan, setEditedPlan] = useState(planContent);
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
const [rejectFeedback, setRejectFeedback] = useState('');
const [showFullDescription, setShowFullDescription] = useState(false);

const DESCRIPTION_LIMIT = 250;
const TITLE_LIMIT = 50;

// Reset state when dialog opens or plan content changes
useEffect(() => {
Expand All @@ -50,6 +54,7 @@ export function PlanApprovalDialog({
setIsEditMode(false);
setShowRejectFeedback(false);
setRejectFeedback('');
setShowFullDescription(false);
}
}, [open, planContent]);

Expand Down Expand Up @@ -82,15 +87,31 @@ export function PlanApprovalDialog({
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
<DialogHeader>
<DialogTitle>{viewOnly ? 'View Plan' : 'Review Plan'}</DialogTitle>
<DialogTitle>
{viewOnly ? 'View Plan' : 'Review Plan'}
{feature?.title && feature.title.length <= TITLE_LIMIT && (
<span className="font-normal text-muted-foreground"> - {feature.title}</span>
)}
</DialogTitle>
<DialogDescription>
{viewOnly
? 'View the generated plan for this feature.'
: 'Review the generated plan before implementation begins.'}
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 150)}
{feature.description.length > 150 ? '...' : ''}
Feature:{' '}
{showFullDescription || feature.description.length <= DESCRIPTION_LIMIT
? feature.description
: `${feature.description.slice(0, DESCRIPTION_LIMIT)}...`}
{feature.description.length > DESCRIPTION_LIMIT && (
<button
type="button"
onClick={() => setShowFullDescription(!showFullDescription)}
className="ml-1 text-muted-foreground hover:text-foreground underline text-sm"
>
{showFullDescription ? 'show less' : 'show more'}
</button>
)}
</span>
)}
</DialogDescription>
Expand Down Expand Up @@ -135,9 +156,7 @@ export function PlanApprovalDialog({
disabled={isLoading}
/>
) : (
<div className="p-4 overflow-auto">
<Markdown>{editedPlan || 'No plan content available.'}</Markdown>
</div>
<PlanContentViewer content={editedPlan || ''} className="p-4" />
)}
</div>

Expand Down
Loading