feat: enhance MarkdownViewer and task dialogs with loading state management and performance logging

- Introduced character limits for Markdown content in MarkdownViewer to prevent UI freezes with large content.
- Added state management for raw content display in MarkdownViewer, allowing users to expand and view large markdown files.
- Implemented performance logging in GlobalTaskDetailDialog and TaskDetailDialog to track loading states and improve debugging.
- Updated loading state handling in TaskDetailDialog to ensure accurate representation of loading conditions.
This commit is contained in:
iliya 2026-02-28 22:48:08 +02:00
parent ca03b14c51
commit c528b07fec
4 changed files with 157 additions and 2 deletions

View file

@ -53,7 +53,6 @@
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
],
"src/**/*.{json,css}": [

View file

@ -268,6 +268,9 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
/** Default components without search highlighting */
const defaultComponents = createViewerMarkdownComponents(null);
const MAX_MARKDOWN_CHARS = 200_000;
const LARGE_PREVIEW_CHARS = 50_000;
// =============================================================================
// Component
// =============================================================================
@ -281,6 +284,11 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
copyable = false,
bare = false,
}) => {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
// Only subscribe to search store when itemId is provided
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => ({
@ -290,6 +298,128 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}))
);
// Guard: very large markdown can freeze the renderer (remark/rehype + highlighting).
// For large content, default to a lightweight raw preview with manual expansion.
if (isTooLarge || showRaw) {
const shown = content.slice(0, Math.min(rawLimit, content.length));
const isTruncated = shown.length < content.length;
return (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
style={
bare
? undefined
: {
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}
}
>
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: CODE_HEADER_BG,
borderBottom: `1px solid ${CODE_BORDER}`,
}}
>
<FileText className="size-4 shrink-0" style={{ color: COLOR_TEXT_MUTED }} />
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
<span className="ml-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Raw
</span>
<span className="flex-1" />
<button
type="button"
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
{copyable && <CopyButton text={content} inline />}
</div>
)}
{!label && (
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
<span>Raw preview</span>
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
</div>
)}
{isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to
keep the UI responsive.
</div>
)}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<pre
className="min-w-0 whitespace-pre-wrap break-words p-4 font-mono text-xs leading-relaxed"
style={{ color: PROSE_BODY }}
>
{shown}
</pre>
{isTruncated && (
<div className="flex items-center justify-between gap-2 px-4 pb-4 text-xs">
<span style={{ color: COLOR_TEXT_MUTED }}>
Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit((v) => Math.min(content.length, v * 2))}
>
Show more
</button>
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
Show all
</button>
</div>
</div>
)}
</div>
</div>
);
}
// Create search context (fresh each render so counter starts at 0)
const searchCtx =
searchQuery && itemId

View file

@ -65,6 +65,24 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
]);
const isFullTeamLoaded = selectedTeamName === teamName && !!selectedTeamData;
const isThisTeamLoading =
selectedTeamName === teamName && selectedTeamLoading && !selectedTeamData;
useEffect(() => {
if (!globalTaskDetail) return;
console.warn(
`[GlobalTaskDetailDialog] team=${teamName} taskId=${taskId} selectedTeamName=${selectedTeamName ?? ''} loading=${selectedTeamLoading} hasData=${!!selectedTeamData} isFull=${isFullTeamLoaded} isThisTeamLoading=${isThisTeamLoading}`
);
}, [
globalTaskDetail,
isFullTeamLoaded,
isThisTeamLoading,
selectedTeamData,
selectedTeamLoading,
selectedTeamName,
taskId,
teamName,
]);
const taskMap = useMemo(() => {
const map = new Map<string, TeamTaskWithKanban>();
@ -111,7 +129,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
<TaskDetailDialog
open
variant={isFullTeamLoaded ? 'team' : 'global'}
loading={!isFullTeamLoaded && selectedTeamLoading}
loading={!isFullTeamLoaded && isThisTeamLoading}
task={task}
teamName={teamName}
kanbanTaskState={kanbanTaskState}

View file

@ -231,6 +231,14 @@ export const TaskDetailDialog = ({
onScrollToTask?.(taskId);
};
useEffect(() => {
if (!open) return;
if (!loading) return;
console.warn(
`[TaskDetailDialog] loading=true variant=${variant} team=${teamName} taskId=${task?.id ?? ''}`
);
}, [loading, open, task?.id, teamName, variant]);
if (loading) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>