mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-30 21:49:30 -05:00
feat: Enhance knowledge base cards with inline editing and smart navigation
Implements comprehensive knowledge base card improvements addressing GitHub issue #658: - **Inline tag management**: Display, add, edit, and delete tags directly on cards - **Inline title editing**: Click titles to edit with keyboard shortcuts and auto-save - **Inline type editing**: Click technical/business badges to change type via dropdown - **Description tooltips**: Show database summaries via info icons with type-matched styling - **Smart navigation**: Click stat pills to open inspector to correct tab (documents/code examples) - **Responsive design**: Tags collapse after 6 items with "show more" functionality - **Enhanced UX**: Proper error handling, optimistic updates, and visual feedback Backend improvements: - Return summary field in knowledge item API responses - Support updating tags, titles, and knowledge types Frontend improvements: - Created reusable components: KnowledgeCardTags, KnowledgeCardTitle, KnowledgeCardType - Fixed React ref warnings with forwardRef in Badge component - Improved TanStack Query cache management for optimistic updates - Added proper error toast notifications and loading states - Color-themed tooltips matching card accent colors - Protected user input from being overwritten during editing Fixes #658 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,13 @@ interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
color?: 'purple' | 'green' | 'pink' | 'blue' | 'gray' | 'orange';
|
||||
variant?: 'solid' | 'outline';
|
||||
}
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
children,
|
||||
color = 'gray',
|
||||
variant = 'outline',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const colorMap = {
|
||||
solid: {
|
||||
purple: 'bg-purple-500/10 text-purple-500 dark:bg-purple-500/10 dark:text-purple-500',
|
||||
@@ -29,11 +29,17 @@ export const Badge: React.FC<BadgeProps> = ({
|
||||
orange: 'border border-orange-500 text-orange-500 dark:border-orange-500 dark:text-orange-500 shadow-[0_0_10px_rgba(251,146,60,0.3)]'
|
||||
}
|
||||
};
|
||||
return <span className={`
|
||||
return <span
|
||||
ref={ref}
|
||||
className={`
|
||||
inline-flex items-center text-xs px-2 py-1 rounded
|
||||
${colorMap[variant][color]}
|
||||
${className}
|
||||
`} {...props}>
|
||||
{children}
|
||||
</span>;
|
||||
};
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>;
|
||||
});
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
@@ -17,6 +17,9 @@ import type { ActiveOperation } from "../progress/types";
|
||||
import type { KnowledgeItem } from "../types";
|
||||
import { extractDomain } from "../utils/knowledge-utils";
|
||||
import { KnowledgeCardActions } from "./KnowledgeCardActions";
|
||||
import { KnowledgeCardTags } from "./KnowledgeCardTags";
|
||||
import { KnowledgeCardTitle } from "./KnowledgeCardTitle";
|
||||
import { KnowledgeCardType } from "./KnowledgeCardType";
|
||||
|
||||
interface KnowledgeCardProps {
|
||||
item: KnowledgeItem;
|
||||
@@ -199,19 +202,10 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
|
||||
<span>{isUrl ? "Web Page" : "Document"}</span>
|
||||
</div>
|
||||
</SimpleTooltip>
|
||||
<SimpleTooltip content={isTechnical ? "Technical documentation" : "Business/general content"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium",
|
||||
isTechnical
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"
|
||||
: "bg-pink-100 text-pink-700 dark:bg-pink-500/10 dark:text-pink-400",
|
||||
)}
|
||||
>
|
||||
{isTechnical ? <Terminal className="w-3.5 h-3.5" /> : <Briefcase className="w-3.5 h-3.5" />}
|
||||
<span>{getTypeLabel()}</span>
|
||||
</div>
|
||||
</SimpleTooltip>
|
||||
<KnowledgeCardType
|
||||
sourceId={item.source_id}
|
||||
knowledgeType={item.knowledge_type}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -237,7 +231,14 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white/90 line-clamp-2 mb-2">{item.title}</h3>
|
||||
<div className="mb-2">
|
||||
<KnowledgeCardTitle
|
||||
sourceId={item.source_id}
|
||||
title={item.title}
|
||||
description={(item as any).summary}
|
||||
accentColor={getAccentColorName()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL/Source */}
|
||||
{item.url &&
|
||||
@@ -258,6 +259,20 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
|
||||
<span className="truncate">{item.url.replace("file://", "")}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Tags */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
role="none"
|
||||
className="mt-2"
|
||||
>
|
||||
<KnowledgeCardTags sourceId={item.source_id} tags={item.tags || item.metadata?.tags || []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spacer to push footer to bottom */}
|
||||
@@ -285,8 +300,14 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
|
||||
</div>
|
||||
{/* Right: pills */}
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip content={`${documentCount} document${documentCount !== 1 ? "s" : ""} indexed`}>
|
||||
<div>
|
||||
<SimpleTooltip content={`${documentCount} document${documentCount !== 1 ? "s" : ""} indexed - Click to view`}>
|
||||
<div
|
||||
className="cursor-pointer hover:scale-105 transition-transform"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDocument();
|
||||
}}
|
||||
>
|
||||
<StatPill
|
||||
color="orange"
|
||||
value={documentCount}
|
||||
@@ -297,9 +318,20 @@ export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
|
||||
</div>
|
||||
</SimpleTooltip>
|
||||
<SimpleTooltip
|
||||
content={`${codeExamplesCount} code example${codeExamplesCount !== 1 ? "s" : ""} extracted`}
|
||||
content={`${codeExamplesCount} code example${codeExamplesCount !== 1 ? "s" : ""} extracted - ${onViewCodeExamples ? "Click to view" : "No examples available"}`}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-transform",
|
||||
onViewCodeExamples && "cursor-pointer hover:scale-105"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onViewCodeExamples) {
|
||||
onViewCodeExamples();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StatPill
|
||||
color="blue"
|
||||
value={codeExamplesCount}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Knowledge Card Tags Component
|
||||
* Displays and allows inline editing of tags for knowledge items
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronUp, Plus, Tag, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "../../../components/ui/Badge";
|
||||
import { Input } from "../../ui/primitives";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import { SimpleTooltip } from "../../ui/primitives/tooltip";
|
||||
import { useUpdateKnowledgeItem } from "../hooks";
|
||||
|
||||
interface KnowledgeCardTagsProps {
|
||||
sourceId: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const KnowledgeCardTags: React.FC<KnowledgeCardTagsProps> = ({ sourceId, tags }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingTags, setEditingTags] = useState<string[]>(tags);
|
||||
const [newTagValue, setNewTagValue] = useState("");
|
||||
const [originalTagBeingEdited, setOriginalTagBeingEdited] = useState<string | null>(null);
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const updateMutation = useUpdateKnowledgeItem();
|
||||
|
||||
// Determine how many tags to show (2 rows worth, approximately 6-8 tags depending on length)
|
||||
const MAX_TAGS_COLLAPSED = 6;
|
||||
|
||||
// Update local state when props change
|
||||
useEffect(() => {
|
||||
setEditingTags(tags);
|
||||
}, [tags]);
|
||||
|
||||
// Focus input when starting to add a new tag
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSaveTags = async () => {
|
||||
const updatedTags = editingTags.filter((tag) => tag.trim().length > 0);
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
sourceId,
|
||||
updates: {
|
||||
tags: updatedTags,
|
||||
},
|
||||
});
|
||||
setIsEditing(false);
|
||||
setNewTagValue("");
|
||||
} catch (_error) {
|
||||
// Reset on error
|
||||
setEditingTags(tags);
|
||||
setNewTagValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingTags(tags);
|
||||
setNewTagValue("");
|
||||
setOriginalTagBeingEdited(null);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (newTagValue.trim()) {
|
||||
handleAddTag();
|
||||
} else {
|
||||
handleSaveTags();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
const trimmed = newTagValue.trim();
|
||||
if (trimmed) {
|
||||
let newTags = [...editingTags];
|
||||
|
||||
// If we're editing an existing tag, remove the original first
|
||||
if (originalTagBeingEdited) {
|
||||
newTags = newTags.filter(tag => tag !== originalTagBeingEdited);
|
||||
}
|
||||
|
||||
// Add the new/modified tag if it doesn't already exist
|
||||
if (!newTags.includes(trimmed)) {
|
||||
newTags.push(trimmed);
|
||||
}
|
||||
|
||||
setEditingTags(newTags);
|
||||
setNewTagValue("");
|
||||
setOriginalTagBeingEdited(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setEditingTags(editingTags.filter((tag) => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tagToDelete: string) => {
|
||||
// Remove the tag and save immediately
|
||||
const updatedTags = tags.filter((tag) => tag !== tagToDelete);
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
sourceId,
|
||||
updates: {
|
||||
tags: updatedTags,
|
||||
},
|
||||
});
|
||||
} catch (_error) {
|
||||
// Error handling is done by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagClick = () => {
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTag = (tagToEdit: string) => {
|
||||
// When clicking an existing tag in edit mode, put it in the input for editing
|
||||
if (isEditing) {
|
||||
setNewTagValue(tagToEdit);
|
||||
setOriginalTagBeingEdited(tagToEdit);
|
||||
// Focus the input
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select(); // Select all text for easy editing
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const displayTags = isEditing ? editingTags : tags;
|
||||
const visibleTags = showAllTags || isEditing ? displayTags : displayTags.slice(0, MAX_TAGS_COLLAPSED);
|
||||
const hasMoreTags = displayTags.length > MAX_TAGS_COLLAPSED;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{/* Display tags */}
|
||||
{visibleTags.map((tag) => (
|
||||
<div key={tag} className="relative">
|
||||
{isEditing ? (
|
||||
<SimpleTooltip content={`Click to edit "${tag}" or hover to remove`}>
|
||||
<Badge
|
||||
color="gray"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-[10px] cursor-pointer group pr-0.5 px-1.5 py-0.5 h-5"
|
||||
onClick={() => handleEditTag(tag)}
|
||||
>
|
||||
<Tag className="w-2.5 h-2.5" />
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering the edit when clicking remove
|
||||
handleRemoveTag(tag);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity ml-0.5 hover:text-red-500"
|
||||
aria-label={`Remove ${tag} tag`}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
</SimpleTooltip>
|
||||
) : (
|
||||
<div className="relative group">
|
||||
<SimpleTooltip content={`Click to edit "${tag}" or hover to delete`}>
|
||||
<Badge
|
||||
color="gray"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-[10px] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors group pr-0.5 px-1.5 py-0.5 h-5"
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
// Load this specific tag for editing
|
||||
setNewTagValue(tag);
|
||||
setOriginalTagBeingEdited(tag);
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
<Tag className="w-2.5 h-2.5" />
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering the edit when clicking delete
|
||||
handleDeleteTag(tag);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity ml-0.5 hover:text-red-500"
|
||||
aria-label={`Delete ${tag} tag`}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Show more/less button */}
|
||||
{!isEditing && hasMoreTags && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllTags(!showAllTags)}
|
||||
className="flex items-center gap-0.5 text-[10px] text-gray-500 dark:text-gray-400 hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors px-1 py-0.5 rounded"
|
||||
>
|
||||
{showAllTags ? (
|
||||
<>
|
||||
<span>Show less</span>
|
||||
<ChevronUp className="w-2.5 h-2.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>+{displayTags.length - MAX_TAGS_COLLAPSED} more</span>
|
||||
<ChevronDown className="w-2.5 h-2.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add tag input */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={newTagValue}
|
||||
onChange={(e) => setNewTagValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
if (newTagValue.trim()) {
|
||||
handleAddTag();
|
||||
}
|
||||
}}
|
||||
placeholder={originalTagBeingEdited ? "Edit tag..." : "Add tag..."}
|
||||
className={cn(
|
||||
"h-6 text-xs px-2 w-20 min-w-0",
|
||||
"border-cyan-400 dark:border-cyan-600",
|
||||
"focus:ring-1 focus:ring-cyan-400",
|
||||
)}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (newTagValue.trim()) {
|
||||
handleAddTag();
|
||||
}
|
||||
}}
|
||||
className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 dark:hover:text-cyan-300"
|
||||
disabled={!newTagValue.trim() || updateMutation.isPending}
|
||||
aria-label="Add tag"
|
||||
>
|
||||
<Plus className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add tag button when not editing */}
|
||||
{!isEditing && (
|
||||
<SimpleTooltip content="Click to add or edit tags">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
setOriginalTagBeingEdited(null); // Clear any existing edit state
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:text-cyan-600 dark:hover:text-cyan-400 hover:border-cyan-400 dark:hover:border-cyan-600 transition-colors h-5"
|
||||
aria-label="Add tags"
|
||||
>
|
||||
<Plus className="w-2.5 h-2.5" />
|
||||
<span>Tags</span>
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
|
||||
{/* Save/Cancel buttons when editing */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveTags}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-2 py-1 text-xs bg-cyan-600 text-white rounded hover:bg-cyan-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-2 py-1 text-xs bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Knowledge Card Title Component
|
||||
* Displays and allows inline editing of knowledge item titles
|
||||
*/
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Input } from "../../ui/primitives";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import { SimpleTooltip, Tooltip, TooltipTrigger, TooltipContent } from "../../ui/primitives/tooltip";
|
||||
import { useUpdateKnowledgeItem } from "../hooks";
|
||||
|
||||
// Centralized color class mappings
|
||||
const ICON_COLOR_CLASSES: Record<string, string> = {
|
||||
cyan: "text-gray-400 hover:!text-cyan-600 dark:text-gray-500 dark:hover:!text-cyan-400",
|
||||
purple: "text-gray-400 hover:!text-purple-600 dark:text-gray-500 dark:hover:!text-purple-400",
|
||||
blue: "text-gray-400 hover:!text-blue-600 dark:text-gray-500 dark:hover:!text-blue-400",
|
||||
pink: "text-gray-400 hover:!text-pink-600 dark:text-gray-500 dark:hover:!text-pink-400",
|
||||
red: "text-gray-400 hover:!text-red-600 dark:text-gray-500 dark:hover:!text-red-400",
|
||||
yellow: "text-gray-400 hover:!text-yellow-600 dark:text-gray-500 dark:hover:!text-yellow-400",
|
||||
default: "text-gray-400 hover:!text-blue-600 dark:text-gray-500 dark:hover:!text-blue-400",
|
||||
};
|
||||
|
||||
const TOOLTIP_COLOR_CLASSES: Record<string, string> = {
|
||||
cyan: "border-cyan-500/50 shadow-[0_0_15px_rgba(34,211,238,0.5)] dark:border-cyan-400/50 dark:shadow-[0_0_15px_rgba(34,211,238,0.7)]",
|
||||
purple: "border-purple-500/50 shadow-[0_0_15px_rgba(168,85,247,0.5)] dark:border-purple-400/50 dark:shadow-[0_0_15px_rgba(168,85,247,0.7)]",
|
||||
blue: "border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.5)] dark:border-blue-400/50 dark:shadow-[0_0_15px_rgba(59,130,246,0.7)]",
|
||||
pink: "border-pink-500/50 shadow-[0_0_15px_rgba(236,72,153,0.5)] dark:border-pink-400/50 dark:shadow-[0_0_15px_rgba(236,72,153,0.7)]",
|
||||
red: "border-red-500/50 shadow-[0_0_15px_rgba(239,68,68,0.5)] dark:border-red-400/50 dark:shadow-[0_0_15px_rgba(239,68,68,0.7)]",
|
||||
yellow: "border-yellow-500/50 shadow-[0_0_15px_rgba(234,179,8,0.5)] dark:border-yellow-400/50 dark:shadow-[0_0_15px_rgba(234,179,8,0.7)]",
|
||||
default: "border-cyan-500/50 shadow-[0_0_15px_rgba(34,211,238,0.5)] dark:border-cyan-400/50 dark:shadow-[0_0_15px_rgba(34,211,238,0.7)]",
|
||||
};
|
||||
|
||||
interface KnowledgeCardTitleProps {
|
||||
sourceId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
accentColor: "cyan" | "purple" | "blue" | "pink" | "red" | "yellow";
|
||||
}
|
||||
|
||||
export const KnowledgeCardTitle: React.FC<KnowledgeCardTitleProps> = ({
|
||||
sourceId,
|
||||
title,
|
||||
description,
|
||||
accentColor,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const updateMutation = useUpdateKnowledgeItem();
|
||||
|
||||
// Simple lookups using centralized color mappings
|
||||
const getIconColorClass = () => ICON_COLOR_CLASSES[accentColor] ?? ICON_COLOR_CLASSES.default;
|
||||
const getTooltipColorClass = () => TOOLTIP_COLOR_CLASSES[accentColor] ?? TOOLTIP_COLOR_CLASSES.default;
|
||||
|
||||
// Update local state when props change, but only when not editing to avoid overwriting user input
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setEditValue(title);
|
||||
}
|
||||
}, [title, isEditing]);
|
||||
|
||||
// Focus input when editing starts
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = editValue.trim();
|
||||
if (trimmedValue === title) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedValue) {
|
||||
// Don't allow empty titles, revert to original
|
||||
setEditValue(title);
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
sourceId,
|
||||
updates: {
|
||||
title: trimmedValue,
|
||||
},
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (_error) {
|
||||
// Reset on error
|
||||
setEditValue(title);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(title);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Stop all key events from bubbling to prevent card interactions
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
// For all other keys (including space), let them work normally in the input
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent card click
|
||||
if (!isEditing && !updateMutation.isPending) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
onInput={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
disabled={updateMutation.isPending}
|
||||
className={cn(
|
||||
"text-base font-semibold bg-transparent border-cyan-400 dark:border-cyan-600",
|
||||
"focus:ring-1 focus:ring-cyan-400 px-2 py-1"
|
||||
)}
|
||||
/>
|
||||
{(description && description.trim()) && (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Info
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 transition-colors flex-shrink-0 opacity-70 hover:opacity-100 cursor-help",
|
||||
getIconColorClass()
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className={cn("max-w-xs whitespace-pre-wrap", getTooltipColorClass())}>
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SimpleTooltip content="Click to edit title">
|
||||
<h3
|
||||
className={cn(
|
||||
"text-base font-semibold text-gray-900 dark:text-white/90 line-clamp-2 cursor-pointer",
|
||||
"hover:text-gray-700 dark:hover:text-white transition-colors",
|
||||
updateMutation.isPending && "opacity-50"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</SimpleTooltip>
|
||||
{(description && description.trim()) && (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Info
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 transition-colors flex-shrink-0 opacity-70 hover:opacity-100 cursor-help",
|
||||
getIconColorClass()
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className={cn("max-w-xs whitespace-pre-wrap", getTooltipColorClass())}>
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Knowledge Card Type Component
|
||||
* Displays and allows inline editing of knowledge item type (technical/business)
|
||||
*/
|
||||
|
||||
import { Briefcase, Terminal } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/primitives";
|
||||
import { cn } from "../../ui/primitives/styles";
|
||||
import { SimpleTooltip } from "../../ui/primitives/tooltip";
|
||||
import { useToast } from "../../ui/hooks/useToast";
|
||||
import { useUpdateKnowledgeItem } from "../hooks";
|
||||
|
||||
interface KnowledgeCardTypeProps {
|
||||
sourceId: string;
|
||||
knowledgeType: "technical" | "business";
|
||||
}
|
||||
|
||||
export const KnowledgeCardType: React.FC<KnowledgeCardTypeProps> = ({
|
||||
sourceId,
|
||||
knowledgeType,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
const updateMutation = useUpdateKnowledgeItem();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Handle click outside to cancel editing
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isEditing && selectRef.current && event.target) {
|
||||
const target = event.target as Element;
|
||||
|
||||
// Don't close if clicking on the select component or its dropdown content
|
||||
if (!selectRef.current.contains(target) &&
|
||||
!target.closest('[data-radix-select-content]') &&
|
||||
!target.closest('[data-radix-select-item]') &&
|
||||
!target.closest('[data-radix-popper-content-wrapper]')) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const isTechnical = knowledgeType === "technical";
|
||||
|
||||
const handleTypeChange = async (newType: "technical" | "business") => {
|
||||
if (newType === knowledgeType) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
sourceId,
|
||||
updates: {
|
||||
knowledge_type: newType,
|
||||
},
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
// Show user-facing error toast with detailed message
|
||||
const errorMessage = error instanceof Error ? error.message : "Update failed";
|
||||
showToast(`Failed to update knowledge type: ${errorMessage}`, "error");
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent card click
|
||||
if (!isEditing && !updateMutation.isPending) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = () => {
|
||||
return isTechnical ? "Technical" : "Business";
|
||||
};
|
||||
|
||||
const getTypeIcon = () => {
|
||||
return isTechnical ? <Terminal className="w-3.5 h-3.5" /> : <Briefcase className="w-3.5 h-3.5" />;
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div
|
||||
ref={selectRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Select
|
||||
value={knowledgeType}
|
||||
onValueChange={(value) => handleTypeChange(value as "technical" | "business")}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"w-auto h-auto text-xs font-medium px-2 py-1 rounded-md",
|
||||
"border-cyan-400 dark:border-cyan-600",
|
||||
"focus:ring-1 focus:ring-cyan-400",
|
||||
isTechnical
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"
|
||||
: "bg-pink-100 text-pink-700 dark:bg-pink-500/10 dark:text-pink-400"
|
||||
)}
|
||||
>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getTypeIcon()}
|
||||
<span>{getTypeLabel()}</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="technical">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
<span>Technical</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="business">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Briefcase className="w-3.5 h-3.5" />
|
||||
<span>Business</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip content={`${isTechnical ? "Technical documentation" : "Business/general content"} - Click to change`}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium cursor-pointer",
|
||||
"hover:ring-1 hover:ring-cyan-400/50 transition-all",
|
||||
isTechnical
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"
|
||||
: "bg-pink-100 text-pink-700 dark:bg-pink-500/10 dark:text-pink-400",
|
||||
updateMutation.isPending && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{getTypeIcon()}
|
||||
<span>{getTypeLabel()}</span>
|
||||
</div>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ interface KnowledgeListProps {
|
||||
error: Error | null;
|
||||
onRetry: () => void;
|
||||
onViewDocument: (sourceId: string) => void;
|
||||
onViewCodeExamples?: (sourceId: string) => void;
|
||||
onDeleteSuccess: () => void;
|
||||
activeOperations?: ActiveOperation[];
|
||||
onRefreshStarted?: (progressId: string) => void;
|
||||
@@ -54,6 +55,7 @@ export const KnowledgeList: React.FC<KnowledgeListProps> = ({
|
||||
error,
|
||||
onRetry,
|
||||
onViewDocument,
|
||||
onViewCodeExamples,
|
||||
onDeleteSuccess,
|
||||
activeOperations = [],
|
||||
onRefreshStarted,
|
||||
@@ -168,6 +170,7 @@ export const KnowledgeList: React.FC<KnowledgeListProps> = ({
|
||||
<KnowledgeCard
|
||||
item={item}
|
||||
onViewDocument={() => onViewDocument(item.source_id)}
|
||||
onViewCodeExamples={onViewCodeExamples ? () => onViewCodeExamples(item.source_id) : undefined}
|
||||
onDeleteSuccess={onDeleteSuccess}
|
||||
activeOperation={activeOperation}
|
||||
onRefreshStarted={onRefreshStarted}
|
||||
|
||||
@@ -548,25 +548,62 @@ export function useUpdateKnowledgeItem() {
|
||||
onMutate: async ({ sourceId, updates }) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: knowledgeKeys.detail(sourceId) });
|
||||
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summary() });
|
||||
|
||||
// Snapshot the previous value
|
||||
// Snapshot the previous values
|
||||
const previousItem = queryClient.getQueryData<KnowledgeItem>(knowledgeKeys.detail(sourceId));
|
||||
const previousSummaries = queryClient.getQueriesData({ queryKey: knowledgeKeys.summary() });
|
||||
|
||||
// Optimistically update the item
|
||||
// Optimistically update the detail item
|
||||
if (previousItem) {
|
||||
queryClient.setQueryData<KnowledgeItem>(knowledgeKeys.detail(sourceId), {
|
||||
...previousItem,
|
||||
...updates,
|
||||
});
|
||||
const updatedItem = { ...previousItem };
|
||||
|
||||
// Handle metadata updates properly
|
||||
if ('tags' in updates) {
|
||||
const newTags = updates.tags as string[];
|
||||
// Update both top-level tags and metadata.tags for consistency
|
||||
(updatedItem as any).tags = newTags;
|
||||
updatedItem.metadata = { ...updatedItem.metadata, tags: newTags };
|
||||
}
|
||||
|
||||
queryClient.setQueryData<KnowledgeItem>(knowledgeKeys.detail(sourceId), updatedItem);
|
||||
}
|
||||
|
||||
return { previousItem };
|
||||
// Optimistically update summaries cache
|
||||
queryClient.setQueriesData({ queryKey: knowledgeKeys.summary() }, (old: any) => {
|
||||
if (!old?.items) return old;
|
||||
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((item: any) => {
|
||||
if (item.source_id === sourceId) {
|
||||
const updatedItem = { ...item };
|
||||
if ('tags' in updates) {
|
||||
const newTags = updates.tags as string[];
|
||||
// Update both top-level tags and metadata.tags for consistency with summary API
|
||||
updatedItem.tags = newTags;
|
||||
updatedItem.metadata = { ...updatedItem.metadata, tags: newTags };
|
||||
}
|
||||
return updatedItem;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousItem, previousSummaries };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData(knowledgeKeys.detail(variables.sourceId), context.previousItem);
|
||||
}
|
||||
if (context?.previousSummaries) {
|
||||
// Rollback all summary queries
|
||||
for (const [queryKey, data] of context.previousSummaries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to update item";
|
||||
showToast(errorMessage, "error");
|
||||
@@ -574,9 +611,10 @@ export function useUpdateKnowledgeItem() {
|
||||
onSuccess: (_data, { sourceId }) => {
|
||||
showToast("Item updated successfully", "success");
|
||||
|
||||
// Invalidate both detail and list queries
|
||||
// Invalidate all related queries
|
||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(sourceId) });
|
||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summary() }); // Add summaries cache
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,16 +15,28 @@ interface KnowledgeInspectorProps {
|
||||
item: KnowledgeItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialTab?: "documents" | "code";
|
||||
}
|
||||
|
||||
type ViewMode = "documents" | "code";
|
||||
|
||||
export const KnowledgeInspector: React.FC<KnowledgeInspectorProps> = ({ item, open, onOpenChange }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("documents");
|
||||
export const KnowledgeInspector: React.FC<KnowledgeInspectorProps> = ({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
initialTab = "documents"
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(initialTab);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedItem, setSelectedItem] = useState<InspectorSelectedItem | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
// Reset view mode when item or initialTab changes
|
||||
useEffect(() => {
|
||||
setViewMode(initialTab);
|
||||
setSelectedItem(null); // Clear selected item when switching tabs
|
||||
}, [item.source_id, initialTab]);
|
||||
|
||||
// Use pagination hook for current view mode
|
||||
const paginationData = useInspectorPagination({
|
||||
sourceId: item.source_id,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const KnowledgeView = () => {
|
||||
// Dialog state
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [inspectorItem, setInspectorItem] = useState<KnowledgeItem | null>(null);
|
||||
const [inspectorInitialTab, setInspectorInitialTab] = useState<"documents" | "code">("documents");
|
||||
|
||||
// Build filter object for API - memoize to prevent recreating on every render
|
||||
const filter = useMemo<KnowledgeItemsFilter>(() => {
|
||||
@@ -96,9 +97,19 @@ export const KnowledgeView = () => {
|
||||
};
|
||||
|
||||
const handleViewDocument = (sourceId: string) => {
|
||||
// Find the item and open inspector instead of document browser
|
||||
// Find the item and open inspector to documents tab
|
||||
const item = knowledgeItems.find((k) => k.source_id === sourceId);
|
||||
if (item) {
|
||||
setInspectorInitialTab("documents");
|
||||
setInspectorItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewCodeExamples = (sourceId: string) => {
|
||||
// Open the inspector to code examples tab
|
||||
const item = knowledgeItems.find((k) => k.source_id === sourceId);
|
||||
if (item) {
|
||||
setInspectorInitialTab("code");
|
||||
setInspectorItem(item);
|
||||
}
|
||||
};
|
||||
@@ -146,6 +157,7 @@ export const KnowledgeView = () => {
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
onViewDocument={handleViewDocument}
|
||||
onViewCodeExamples={handleViewCodeExamples}
|
||||
onDeleteSuccess={handleDeleteSuccess}
|
||||
activeOperations={activeOperations}
|
||||
onRefreshStarted={(progressId) => {
|
||||
@@ -177,6 +189,7 @@ export const KnowledgeView = () => {
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setInspectorItem(null);
|
||||
}}
|
||||
initialTab={inspectorInitialTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Reduces bandwidth by 70-90% through HTTP 304 responses
|
||||
*/
|
||||
|
||||
import { ProjectServiceError } from "./api";
|
||||
import { API_BASE_URL } from "../../../config/api";
|
||||
import { ProjectServiceError } from "./api";
|
||||
|
||||
// ETag and data cache stores - ensure they're initialized
|
||||
const etagCache = typeof Map !== "undefined" ? new Map<string, string>() : null;
|
||||
|
||||
Reference in New Issue
Block a user