Fix document inspector UI issues and improve accessibility (#644)
* Fix document inspector UI issues and improve accessibility - Fixed copy button visibility when summaries overflow - Added proper flex constraints and text truncation - Enhanced metadata display in footer with relevance scores - Improved accessibility with ARIA labels and semantic HTML - Added better loading states and error handling - Enhanced visual feedback with motion effects * Fix CodeRabbit review issues - Fixed copy timeout race condition using functional setState - Added role="option" for proper aria-selected semantics * Fix document metadata inconsistency in manual selection - Align manual selection with auto-selection pattern - Use top-level fields as primary source with metadata as fallback - Ensures consistent display of titles, sections, and URLs
This commit is contained in:
parent
94aed6b9fa
commit
3d5753f8a7
@ -27,29 +27,36 @@ export const ContentViewer: React.FC<ContentViewerProps> = ({ selectedItem, onCo
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Content Header */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedItem.type === "document" ? (
|
||||
<>
|
||||
{/* Content Header - Fixed with proper overflow handling */}
|
||||
<div className="p-4 border-b border-white/10 flex items-center gap-3 flex-shrink-0">
|
||||
{/* Icon and Metadata - Allow to grow and shrink with min-w-0 for proper truncation */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* Icon - Fixed size, no shrink */}
|
||||
<div className="flex-shrink-0">
|
||||
{selectedItem.type === "document" ? (
|
||||
<FileText className="w-5 h-5 text-cyan-400" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-white/90">
|
||||
) : (
|
||||
<Code className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata Content - Can shrink with proper overflow */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{selectedItem.type === "document" ? (
|
||||
<>
|
||||
<h4 className="text-sm font-medium text-white/90 truncate">
|
||||
{selectedItem.metadata && "title" in selectedItem.metadata
|
||||
? selectedItem.metadata.title || "Document"
|
||||
: "Document"}
|
||||
</h4>
|
||||
{selectedItem.metadata && "section" in selectedItem.metadata && selectedItem.metadata.section && (
|
||||
<p className="text-xs text-gray-500">{selectedItem.metadata.section}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{selectedItem.metadata.section}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Code className="w-5 h-5 text-green-400" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-green-500/10 text-green-400 text-xs font-mono rounded">
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="px-2 py-0.5 bg-green-500/10 text-green-400 text-xs font-mono rounded flex-shrink-0">
|
||||
{selectedItem.type === "code" && selectedItem.metadata && "language" in selectedItem.metadata
|
||||
? selectedItem.metadata.language || "unknown"
|
||||
: "unknown"}
|
||||
@ -58,24 +65,28 @@ export const ContentViewer: React.FC<ContentViewerProps> = ({ selectedItem, onCo
|
||||
selectedItem.metadata &&
|
||||
"file_path" in selectedItem.metadata &&
|
||||
selectedItem.metadata.file_path && (
|
||||
<span className="text-xs text-gray-500 font-mono">{selectedItem.metadata.file_path}</span>
|
||||
<span className="text-xs text-gray-500 font-mono truncate min-w-0">
|
||||
{selectedItem.metadata.file_path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedItem.type === "code" &&
|
||||
selectedItem.metadata &&
|
||||
"summary" in selectedItem.metadata &&
|
||||
selectedItem.metadata.summary && (
|
||||
<p className="text-xs text-gray-400 mt-1">{selectedItem.metadata.summary}</p>
|
||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">{selectedItem.metadata.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy Button - Never shrinks, always visible */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onCopy(selectedItem.content, selectedItem.id)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
className="text-gray-400 hover:text-white flex-shrink-0"
|
||||
>
|
||||
{copiedId === selectedItem.id ? (
|
||||
<>
|
||||
@ -95,21 +106,45 @@ export const ContentViewer: React.FC<ContentViewerProps> = ({ selectedItem, onCo
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-6 scrollbar-thin">
|
||||
{selectedItem.type === "document" ? (
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-300 font-sans">{selectedItem.content}</pre>
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-300 font-sans leading-relaxed">
|
||||
{selectedItem.content || "No content available"}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="bg-black/30 border border-white/10 rounded-lg p-4 overflow-x-auto">
|
||||
<code className="text-sm text-gray-300 font-mono">{selectedItem.content}</code>
|
||||
</pre>
|
||||
<div className="relative">
|
||||
<pre className="bg-black/30 border border-white/10 rounded-lg p-4 overflow-x-auto scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
|
||||
<code className="text-sm text-gray-300 font-mono">
|
||||
{selectedItem.content || "// No code content available"}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Footer */}
|
||||
{selectedItem.metadata?.relevance_score != null && (
|
||||
<div className="p-3 border-t border-white/10 text-xs text-gray-500 flex-shrink-0">
|
||||
Relevance Score: {(selectedItem.metadata.relevance_score * 100).toFixed(0)}%
|
||||
{/* Content Footer - Show metadata */}
|
||||
<div className="border-t border-white/10 flex-shrink-0">
|
||||
<div className="px-4 py-3 flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-4">
|
||||
{selectedItem.metadata?.relevance_score != null && (
|
||||
<span>
|
||||
Relevance:{" "}
|
||||
<span className="text-cyan-400">{(selectedItem.metadata.relevance_score * 100).toFixed(0)}%</span>
|
||||
</span>
|
||||
)}
|
||||
{selectedItem.type === "document" && "url" in selectedItem.metadata && selectedItem.metadata.url && (
|
||||
<a
|
||||
href={selectedItem.metadata.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-400 hover:text-cyan-300 transition-colors underline"
|
||||
>
|
||||
View Source
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-600">{selectedItem.type === "document" ? "Document Chunk" : "Code Example"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -61,16 +61,20 @@ export const InspectorSidebar: React.FC<InspectorSidebarProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 border-r border-white/10 flex flex-col bg-black/40">
|
||||
<aside className="w-80 border-r border-white/10 flex flex-col bg-black/40" aria-label="Document and code browser">
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="p-4 border-b border-white/10 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
placeholder={`Search ${viewMode}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 bg-black/30"
|
||||
aria-label={`Search ${viewMode}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -78,9 +82,15 @@ export const InspectorSidebar: React.FC<InspectorSidebarProps> = ({
|
||||
{/* Item List */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 scrollbar-thin">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-gray-500">Loading...</div>
|
||||
<div className="p-4 text-center text-gray-500" aria-live="polite">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" aria-hidden="true" />
|
||||
<span>Loading {viewMode}...</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500">No {viewMode} found</div>
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
No {viewMode} found
|
||||
{searchQuery && <p className="text-xs mt-1">Try adjusting your search</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{items.map((item) => (
|
||||
@ -88,34 +98,47 @@ export const InspectorSidebar: React.FC<InspectorSidebarProps> = ({
|
||||
type="button"
|
||||
key={item.id}
|
||||
whileHover={{ x: 2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => onItemSelect(item)}
|
||||
className={cn(
|
||||
"w-full text-left p-3 rounded-lg mb-1 transition-colors",
|
||||
"hover:bg-white/5",
|
||||
selectedItemId === item.id ? "bg-cyan-500/10 border border-cyan-500/30" : "border border-transparent",
|
||||
"w-full text-left p-3 rounded-lg mb-1 transition-all",
|
||||
"hover:bg-white/5 focus:outline-none focus:ring-2 focus:ring-cyan-500/50",
|
||||
selectedItemId === item.id
|
||||
? "bg-cyan-500/10 border border-cyan-500/30 ring-1 ring-cyan-500/20"
|
||||
: "border border-transparent",
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={selectedItemId === item.id}
|
||||
aria-label={`${getItemTitle(item)}. ${getItemDescription(item)}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{/* Icon - Fixed size */}
|
||||
<div className="mt-0.5 flex-shrink-0" aria-hidden="true">
|
||||
{viewMode === "documents" ? (
|
||||
<FileText className="w-4 h-4 text-cyan-400" />
|
||||
) : (
|
||||
<Code className="w-4 h-4 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content - Can shrink with proper overflow */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-white/90 truncate">{getItemTitle(item)}</span>
|
||||
<div className="flex items-center gap-2 mb-1 min-w-0">
|
||||
<span className="text-sm font-medium text-white/90 truncate flex-1" title={getItemTitle(item)}>
|
||||
{getItemTitle(item)}
|
||||
</span>
|
||||
{viewMode === "code" && (item as CodeExample).language && (
|
||||
<span className="px-1.5 py-0.5 bg-green-500/10 text-green-400 text-xs rounded">
|
||||
<span className="px-1.5 py-0.5 bg-green-500/10 text-green-400 text-xs rounded flex-shrink-0">
|
||||
{(item as CodeExample).language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 line-clamp-2">{getItemDescription(item)}</p>
|
||||
<p className="text-xs text-gray-500 line-clamp-2" title={getItemDescription(item)}>
|
||||
{getItemDescription(item)}
|
||||
</p>
|
||||
{item.metadata?.relevance_score != null && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Hash className="w-3 h-3 text-gray-600" />
|
||||
<Hash className="w-3 h-3 text-gray-600" aria-hidden="true" />
|
||||
<span className="text-xs text-gray-600">
|
||||
{(item.metadata.relevance_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
@ -128,21 +151,25 @@ export const InspectorSidebar: React.FC<InspectorSidebarProps> = ({
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasNextPage && !isLoading && (
|
||||
<div className="p-3 border-t border-white/10">
|
||||
<div className="p-3 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
className="w-full text-cyan-400 hover:text-white hover:bg-cyan-500/10"
|
||||
className="w-full text-cyan-400 hover:text-white hover:bg-cyan-500/10 transition-all"
|
||||
aria-label={`Load more ${viewMode}`}
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
`Load More ${viewMode}`
|
||||
<>
|
||||
<span>Load More {viewMode}</span>
|
||||
<span className="sr-only">. Press to load additional items.</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@ -150,6 +177,6 @@ export const InspectorSidebar: React.FC<InspectorSidebarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* Orchestrates split-view design with sidebar navigation and content viewer
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { InspectorDialog, InspectorDialogContent, InspectorDialogTitle } from "../../../ui/primitives";
|
||||
import type { CodeExample, DocumentChunk, InspectorSelectedItem, KnowledgeItem } from "../../types";
|
||||
import { useInspectorPagination } from "../hooks/useInspectorPagination";
|
||||
@ -79,49 +79,56 @@ export const KnowledgeInspector: React.FC<KnowledgeInspectorProps> = ({ item, op
|
||||
}
|
||||
}, [viewMode, currentItems, selectedItem]);
|
||||
|
||||
const handleCopy = async (text: string, id: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const handleItemSelect = (item: DocumentChunk | CodeExample) => {
|
||||
if (viewMode === "documents") {
|
||||
const doc = item as DocumentChunk;
|
||||
setSelectedItem({
|
||||
type: "document",
|
||||
id: doc.id || "",
|
||||
content: doc.content || "",
|
||||
metadata: {
|
||||
title: doc.metadata?.title,
|
||||
section: doc.metadata?.section,
|
||||
relevance_score: doc.metadata?.relevance_score,
|
||||
url: doc.metadata?.url,
|
||||
tags: doc.metadata?.tags,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const code = item as CodeExample;
|
||||
setSelectedItem({
|
||||
type: "code",
|
||||
id: String(code.id),
|
||||
content: code.content || code.code || "",
|
||||
metadata: {
|
||||
language: code.language,
|
||||
file_path: code.file_path,
|
||||
summary: code.summary,
|
||||
relevance_score: code.metadata?.relevance_score,
|
||||
title: code.title || code.example_name,
|
||||
},
|
||||
});
|
||||
const handleCopy = useCallback(async (text: string, id: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId((v) => (v === id ? null : v)), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleViewModeChange = (mode: ViewMode) => {
|
||||
const handleItemSelect = useCallback(
|
||||
(item: DocumentChunk | CodeExample) => {
|
||||
if (viewMode === "documents") {
|
||||
const doc = item as DocumentChunk;
|
||||
setSelectedItem({
|
||||
type: "document",
|
||||
id: doc.id || "",
|
||||
content: doc.content || "",
|
||||
metadata: {
|
||||
title: doc.title || doc.metadata?.title,
|
||||
section: doc.section || doc.metadata?.section,
|
||||
relevance_score: doc.metadata?.relevance_score,
|
||||
url: doc.url || doc.metadata?.url,
|
||||
tags: doc.metadata?.tags,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const code = item as CodeExample;
|
||||
setSelectedItem({
|
||||
type: "code",
|
||||
id: String(code.id),
|
||||
content: code.content || code.code || "",
|
||||
metadata: {
|
||||
language: code.language,
|
||||
file_path: code.file_path,
|
||||
summary: code.summary,
|
||||
relevance_score: code.metadata?.relevance_score,
|
||||
title: code.title || code.example_name,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[viewMode],
|
||||
);
|
||||
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
setSelectedItem(null);
|
||||
setSearchQuery("");
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InspectorDialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user