Merge pull request #230 from presenton/fix/better_outline_ux

Fix/better outline ux
This commit is contained in:
Suraj Jha 2025-08-19 01:06:03 +05:45 committed by GitHub
commit b9b4241b48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 140 additions and 14 deletions

View file

@ -15,7 +15,7 @@ import {
} from "@dnd-kit/sortable";
import { OutlineItem } from "./OutlineItem";
import { Button } from "@/components/ui/button";
import { FileText } from "lucide-react";
import { FileText, Loader2 } from "lucide-react";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
@ -23,6 +23,8 @@ interface OutlineContentProps {
outlines: { content: string }[] | null;
isLoading: boolean;
isStreaming: boolean;
activeSlideIndex: number | null;
highestActiveIndex: number;
onDragEnd: (event: any) => void;
onAddSlide: () => void;
}
@ -31,6 +33,8 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
outlines,
isLoading,
isStreaming,
activeSlideIndex,
highestActiveIndex,
onDragEnd,
onAddSlide
}) => {
@ -45,6 +49,14 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
return (
<div className="space-y-6 font-instrument_sans">
{isLoading && (!outlines || outlines.length === 0) && (
<div className="flex items-center justify-center">
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 text-blue-600 px-2 py-0.5 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
Thinking
</span>
</div>
)}
{/* <div className="flex items-center justify-between">
<h5 className="text-lg font-medium">
Presentation Outline
@ -94,6 +106,8 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
index={index + 1}
slideOutline={item}
isStreaming={isStreaming}
isActiveStreaming={activeSlideIndex === index}
isStableStreaming={highestActiveIndex >= 0 && index < highestActiveIndex}
/>
))
) :
@ -107,6 +121,8 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
index={index + 1}
slideOutline={item}
isStreaming={isStreaming}
isActiveStreaming={false}
isStableStreaming={false}
/>
))}
</SortableContext>}

View file

@ -6,7 +6,8 @@ import { useDispatch, useSelector } from "react-redux"
import { deleteSlideOutline, setOutlines } from "@/store/slices/presentationGeneration"
import ToolTip from "@/components/ToolTip"
import MarkdownEditor from "../../components/MarkdownEditor"
import { useEffect } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { marked } from "marked"
interface OutlineItemProps {
@ -15,12 +16,16 @@ interface OutlineItemProps {
},
index: number
isStreaming: boolean
isActiveStreaming?: boolean
isStableStreaming?: boolean
}
export function OutlineItem({
index,
slideOutline,
isStreaming,
isActiveStreaming = false,
isStableStreaming = false,
}: OutlineItemProps) {
const {
outlines,
@ -73,6 +78,42 @@ export function OutlineItem({
dispatch(deleteSlideOutline({ index: index - 1 }))
}
// Throttled markdown rendering only for the active streaming item to avoid flicker
const [renderedHtml, setRenderedHtml] = useState<string>("")
const throttleRef = useRef<number | null>(null)
useEffect(() => {
if (!isStreaming || !isActiveStreaming) return
const content = slideOutline.content || ""
// Throttle updates to ~60ms to reduce reflows/flicker
if (throttleRef.current) {
window.clearTimeout(throttleRef.current)
}
throttleRef.current = window.setTimeout(() => {
try {
setRenderedHtml(marked.parse(content) as string)
} catch {
setRenderedHtml("")
}
}, 60)
return () => {
if (throttleRef.current) {
window.clearTimeout(throttleRef.current)
}
}
}, [isStreaming, isActiveStreaming, slideOutline.content])
// Memoized stable HTML for previous (already completed) items during streaming
const stableHtml = useMemo(() => {
if (!isStreaming || isActiveStreaming) return null
if (!isStableStreaming) return null
try {
return marked.parse(slideOutline.content || "") as string
} catch {
return null
}
}, [isStreaming, isActiveStreaming, isStableStreaming, slideOutline.content])
return (
<div className="mb-2">
{/* Main Title Row */}
@ -99,15 +140,27 @@ export function OutlineItem({
{/* Main Title Input - Add onFocus handler */}
<div id={`outline-item-${index}`} className="flex flex-col basis-full gap-2">
{/* Editable Markdown Content */}
{isStreaming ? <p
className="text-sm flex-1 font-normal"
>
{slideOutline.content || ''}
</p> : <MarkdownEditor
key={index}
content={slideOutline.content || ''}
onChange={(content) => handleSlideChange(content)}
/>}
{isStreaming ? (
isActiveStreaming ? (
<div
className="text-sm flex-1 font-normal prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: renderedHtml || "" }}
/>
) : stableHtml ? (
<div
className="text-sm flex-1 font-normal prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: stableHtml }}
/>
) : (
<p className="text-sm flex-1 font-normal">{slideOutline.content || ''}</p>
)
) : (
<MarkdownEditor
key={index}
content={slideOutline.content || ''}
onChange={(content) => handleSlideChange(content)}
/>
)}
</div>

View file

@ -61,6 +61,8 @@ const OutlinePage: React.FC = () => {
outlines={outlines}
isLoading={streamState.isLoading}
isStreaming={streamState.isStreaming}
activeSlideIndex={streamState.activeSlideIndex}
highestActiveIndex={streamState.highestActiveIndex}
onDragEnd={handleDragEnd}
onAddSlide={handleAddSlide}
/>

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { toast } from "sonner";
import { setOutlines } from "@/store/slices/presentationGeneration";
@ -12,6 +12,11 @@ export const useOutlineStreaming = (presentationId: string | null) => {
const { outlines } = useSelector((state: RootState) => state.presentationGeneration);
const [isStreaming, setIsStreaming] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [activeSlideIndex, setActiveSlideIndex] = useState<number | null>(null);
const [highestActiveIndex, setHighestActiveIndex] = useState<number>(-1);
const prevSlidesRef = useRef<{ content: string }[]>([]);
const activeIndexRef = useRef<number>(-1);
const highestIndexRef = useRef<number>(-1);
useEffect(() => {
if (!presentationId || outlines.length > 0) return;
@ -39,7 +44,36 @@ export const useOutlineStreaming = (presentationId: string | null) => {
const partialData = JSON.parse(repairedJson);
if (partialData.slides) {
dispatch(setOutlines(partialData.slides));
const nextSlides: { content: string }[] = partialData.slides || [];
// Determine which slide index changed to minimize live parsing
try {
const prev = prevSlidesRef.current || [];
let changedIndex: number | null = null;
const maxLen = Math.max(prev.length, nextSlides.length);
for (let i = 0; i < maxLen; i++) {
const prevContent = prev[i]?.content;
const nextContent = nextSlides[i]?.content;
if (nextContent !== prevContent) {
changedIndex = i;
}
}
// Keep active index stable if no change detected; and ensure non-decreasing
const prevActive = activeIndexRef.current;
let nextActive = changedIndex ?? prevActive;
if (nextActive < prevActive) {
nextActive = prevActive;
}
activeIndexRef.current = nextActive;
setActiveSlideIndex(nextActive);
if (nextActive > highestIndexRef.current) {
highestIndexRef.current = nextActive;
setHighestActiveIndex(nextActive);
}
} catch {}
prevSlidesRef.current = nextSlides;
dispatch(setOutlines(nextSlides));
setIsLoading(false)
}
} catch (error) {
@ -54,6 +88,11 @@ export const useOutlineStreaming = (presentationId: string | null) => {
dispatch(setOutlines(outlinesData));
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
setHighestActiveIndex(-1)
prevSlidesRef.current = outlinesData;
activeIndexRef.current = -1;
highestIndexRef.current = -1;
eventSource.close();
} catch (error) {
console.error("Error parsing accumulated chunks:", error);
@ -67,12 +106,20 @@ export const useOutlineStreaming = (presentationId: string | null) => {
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
setHighestActiveIndex(-1)
activeIndexRef.current = -1;
highestIndexRef.current = -1;
eventSource.close();
break;
case "error":
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
setHighestActiveIndex(-1)
activeIndexRef.current = -1;
highestIndexRef.current = -1;
eventSource.close();
toast.error('Error in outline streaming',
{
@ -87,6 +134,10 @@ export const useOutlineStreaming = (presentationId: string | null) => {
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
setHighestActiveIndex(-1)
activeIndexRef.current = -1;
highestIndexRef.current = -1;
eventSource.close();
toast.error("Failed to connect to the server. Please try again.");
};
@ -94,6 +145,10 @@ export const useOutlineStreaming = (presentationId: string | null) => {
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
setHighestActiveIndex(-1)
activeIndexRef.current = -1;
highestIndexRef.current = -1;
toast.error("Failed to initialize connection");
}
};
@ -105,5 +160,5 @@ export const useOutlineStreaming = (presentationId: string | null) => {
};
}, [presentationId, dispatch]);
return { isStreaming, isLoading };
return { isStreaming, isLoading, activeSlideIndex, highestActiveIndex };
};