feat: tag/cat mapping tools

This commit is contained in:
2026-02-13 14:56:07 +01:00
parent 4dc1a9f058
commit e7b4a5d90f
8 changed files with 769 additions and 37 deletions

View File

@@ -382,6 +382,213 @@
color: #75beff;
}
/* Mapped taxonomy items - green background */
.import-taxonomy-pill.mapped {
background: rgba(115, 201, 145, 0.25);
color: #73c991;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.import-taxonomy-pill .pill-arrow {
opacity: 0.6;
font-size: 10px;
}
.import-taxonomy-pill .pill-mapped-to {
font-weight: 600;
}
.import-taxonomy-pill .pill-clear-btn {
background: none;
border: none;
color: inherit;
opacity: 0.5;
cursor: pointer;
padding: 0 2px;
font-size: 14px;
line-height: 1;
margin-left: 2px;
}
.import-taxonomy-pill .pill-clear-btn:hover {
opacity: 1;
}
/* Editable taxonomy pills */
.import-taxonomy-pill:not(.editing):not(.exists):not(.mapped) {
cursor: pointer;
}
.import-taxonomy-pill:not(.editing):hover {
filter: brightness(1.1);
}
.import-taxonomy-pill.editing {
background: var(--vscode-input-background);
padding: 0 4px 0 8px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.import-taxonomy-pill.editing .pill-name {
color: var(--vscode-foreground);
}
.import-taxonomy-pill .pill-edit-input {
background: transparent;
border: none;
color: #73c991;
font-size: 11px;
padding: 2px 4px;
width: 100px;
outline: none;
}
.import-taxonomy-pill .pill-edit-input::placeholder {
color: var(--vscode-input-placeholderForeground);
}
/* Pill edit container for autocomplete */
.pill-edit-container {
position: relative;
display: inline-block;
}
.pill-suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 150px;
max-width: 250px;
max-height: 200px;
overflow-y: auto;
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 1000;
}
.pill-suggestion-item {
display: block;
width: 100%;
padding: 6px 10px;
font-size: 11px;
text-align: left;
background: none;
border: none;
color: var(--vscode-foreground);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pill-suggestion-item:hover,
.pill-suggestion-item.selected {
background: var(--vscode-list-hoverBackground);
}
.pill-suggestion-item.selected {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
/* Taxonomy analyze row */
.taxonomy-analyze-row {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--vscode-panel-border, rgba(255,255,255,0.06));
}
.taxonomy-analyze-dropdown {
position: relative;
}
.taxonomy-analyze-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 4px;
cursor: pointer;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
white-space: nowrap;
}
.taxonomy-analyze-btn:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground);
}
.taxonomy-analyze-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.taxonomy-analyze-btn .import-spinner.small {
width: 12px;
height: 12px;
border-width: 1.5px;
}
.taxonomy-model-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 100;
min-width: 180px;
max-height: 300px;
overflow-y: auto;
}
.taxonomy-model-option {
display: block;
width: 100%;
padding: 8px 12px;
font-size: 12px;
text-align: left;
background: none;
border: none;
color: var(--vscode-foreground);
cursor: pointer;
}
.taxonomy-model-option:hover {
background: var(--vscode-list-hoverBackground);
}
.taxonomy-analyze-hint {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
.taxonomy-mapped-count {
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
background: rgba(115, 201, 145, 0.2);
color: #73c991;
margin-left: 8px;
}
/* Empty state */
.import-empty-state {
display: flex;

View File

@@ -1,4 +1,5 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import type { ChatModel } from '../../types/electron';
import './ImportAnalysisView.css';
interface AnalysisReport {
@@ -50,6 +51,7 @@ interface TaxonomyItem {
name: string;
slug: string;
existsInProject: boolean;
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
}
interface ImportAnalysisViewProps {
@@ -66,6 +68,62 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const nameInputRef = useRef<HTMLInputElement>(null);
// Save the current report to the definition
const persistReport = useCallback(async (updatedReport: AnalysisReport) => {
await window.electronAPI?.importDefinitions.update(definitionId, {
lastAnalysisResult: JSON.stringify(updatedReport),
});
}, [definitionId]);
// Handler for updating taxonomy mappings
const handleTaxonomyMappingsUpdated = useCallback(async (
categoryMappings: Record<string, string>,
tagMappings: Record<string, string>
) => {
if (!report) return;
const updatedReport: AnalysisReport = {
...report,
categories: report.categories.map(cat => ({
...cat,
mappedTo: categoryMappings[cat.name] || cat.mappedTo,
})),
tags: report.tags.map(tag => ({
...tag,
mappedTo: tagMappings[tag.name] || tag.mappedTo,
})),
};
setReport(updatedReport);
await persistReport(updatedReport);
}, [report, persistReport]);
// Handler for updating a single item mapping
const handleSingleMappingUpdated = useCallback(async (
type: 'category' | 'tag',
itemName: string,
mappedTo: string | undefined
) => {
if (!report) return;
const updatedReport: AnalysisReport = {
...report,
categories: type === 'category'
? report.categories.map(cat =>
cat.name === itemName ? { ...cat, mappedTo } : cat
)
: report.categories,
tags: type === 'tag'
? report.tags.map(tag =>
tag.name === itemName ? { ...tag, mappedTo } : tag
)
: report.tags,
};
setReport(updatedReport);
await persistReport(updatedReport);
}, [report, persistReport]);
// Load definition on mount
useEffect(() => {
const load = async () => {
@@ -247,6 +305,8 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
tags={report.tags}
expanded={expandedSections['taxonomy'] ?? false}
onToggle={() => toggleSection('taxonomy')}
onMappingsAnalyzed={handleTaxonomyMappingsUpdated}
onMappingUpdated={handleSingleMappingUpdated}
/>
)}
</>
@@ -453,43 +513,343 @@ const TaxonomySection: React.FC<{
tags: TaxonomyItem[];
expanded: boolean;
onToggle: () => void;
}> = ({ categories, tags, expanded, onToggle }) => (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
Categories & Tags
</h3>
{expanded && (
<>
{categories.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
Categories
onMappingsAnalyzed: (categoryMappings: Record<string, string>, tagMappings: Record<string, string>) => void;
onMappingUpdated: (type: 'category' | 'tag', itemName: string, mappedTo: string | undefined) => void;
}> = ({ categories, tags, expanded, onToggle, onMappingsAnalyzed, onMappingUpdated }) => {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [showModelSelector, setShowModelSelector] = useState(false);
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
const [editingItem, setEditingItem] = useState<{ type: 'category' | 'tag'; name: string } | null>(null);
const [editValue, setEditValue] = useState('');
const [existingTags, setExistingTags] = useState<string[]>([]);
const [existingCategories, setExistingCategories] = useState<string[]>([]);
const modelSelectorRef = useRef<HTMLDivElement>(null);
// Load available models and existing taxonomy on mount
useEffect(() => {
const loadData = async () => {
const [modelsResult, tagsResult, categoriesResult] = await Promise.all([
window.electronAPI?.chat.getAvailableModels(),
window.electronAPI?.tags.getAll(),
window.electronAPI?.meta.getCategories(),
]);
if (modelsResult?.models) {
setAvailableModels(modelsResult.models);
}
if (tagsResult) {
setExistingTags(tagsResult.map(t => t.name));
}
if (categoriesResult) {
setExistingCategories(categoriesResult);
}
};
loadData();
}, []);
// Close model selector when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modelSelectorRef.current && !modelSelectorRef.current.contains(e.target as Node)) {
setShowModelSelector(false);
}
};
if (showModelSelector) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showModelSelector]);
const handleAnalyze = async (modelId: string) => {
setShowModelSelector(false);
setIsAnalyzing(true);
try {
const result = await window.electronAPI?.chat.analyzeTaxonomy(categories, tags, modelId);
if (result?.success) {
onMappingsAnalyzed(result.categoryMappings || {}, result.tagMappings || {});
} else {
console.error('Taxonomy analysis failed:', result?.error);
}
} catch (error) {
console.error('Taxonomy analysis error:', error);
} finally {
setIsAnalyzing(false);
}
};
const handleStartEdit = (type: 'category' | 'tag', item: TaxonomyItem) => {
setEditingItem({ type, name: item.name });
setEditValue(item.mappedTo || '');
};
const handleSaveEdit = (directValue?: string) => {
if (editingItem) {
const valueToSave = directValue !== undefined ? directValue : editValue;
onMappingUpdated(editingItem.type, editingItem.name, valueToSave.trim() || undefined);
setEditingItem(null);
setEditValue('');
}
};
const handleClearMapping = (type: 'category' | 'tag', name: string) => {
onMappingUpdated(type, name, undefined);
};
// Build suggestions list: existing project items + import items (deduplicated)
const categorySuggestions = [...new Set([...existingCategories, ...categories.map(c => c.name)])].sort();
const tagSuggestions = [...new Set([...existingTags, ...tags.map(t => t.name)])].sort();
const mappedCategoriesCount = categories.filter(c => c.mappedTo).length;
const mappedTagsCount = tags.filter(t => t.mappedTo).length;
return (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
Categories & Tags
{(mappedCategoriesCount > 0 || mappedTagsCount > 0) && (
<span className="taxonomy-mapped-count">
{mappedCategoriesCount + mappedTagsCount} mapped
</span>
)}
</h3>
{expanded && (
<>
<div className="taxonomy-analyze-row">
<div className="taxonomy-analyze-dropdown" ref={modelSelectorRef}>
<button
className="taxonomy-analyze-btn"
onClick={() => setShowModelSelector(!showModelSelector)}
disabled={isAnalyzing}
>
{isAnalyzing ? (
<>
<span className="import-spinner small" />
Analyzing...
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
Analyze with...
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 4 }}>
<path d="M7 10l5 5 5-5z"/>
</svg>
</>
)}
</button>
{showModelSelector && (
<div className="taxonomy-model-dropdown">
{availableModels.map(model => (
<button
key={model.id}
className="taxonomy-model-option"
onClick={() => handleAnalyze(model.id)}
>
{model.name}
</button>
))}
</div>
)}
</div>
<div className="import-taxonomy-list">
{categories.map((cat, idx) => (
<span key={idx} className={`import-taxonomy-pill ${cat.existsInProject ? 'exists' : 'new-tax'}`}>
{cat.name}
</span>
<span className="taxonomy-analyze-hint">
AI will suggest mappings to consolidate similar tags and categories
</span>
</div>
{categories.length > 0 && (
<div style={{ marginBottom: 12, marginTop: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
Categories
</div>
<div className="import-taxonomy-list">
{categories.map((cat, idx) => (
<TaxonomyPill
key={idx}
item={cat}
type="category"
isEditing={editingItem?.type === 'category' && editingItem?.name === cat.name}
editValue={editValue}
suggestions={categorySuggestions}
onEditValueChange={setEditValue}
onStartEdit={() => handleStartEdit('category', cat)}
onSaveEdit={handleSaveEdit}
onCancelEdit={() => setEditingItem(null)}
onClearMapping={() => handleClearMapping('category', cat.name)}
/>
))}
</div>
</div>
)}
{tags.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
Tags
</div>
<div className="import-taxonomy-list">
{tags.map((tag, idx) => (
<TaxonomyPill
key={idx}
item={tag}
type="tag"
isEditing={editingItem?.type === 'tag' && editingItem?.name === tag.name}
editValue={editValue}
suggestions={tagSuggestions}
onEditValueChange={setEditValue}
onStartEdit={() => handleStartEdit('tag', tag)}
onSaveEdit={handleSaveEdit}
onCancelEdit={() => setEditingItem(null)}
onClearMapping={() => handleClearMapping('tag', tag.name)}
/>
))}
</div>
</div>
)}
</>
)}
</div>
);
};
const TaxonomyPill: React.FC<{
item: TaxonomyItem;
type: 'category' | 'tag';
isEditing: boolean;
editValue: string;
suggestions: string[];
onEditValueChange: (value: string) => void;
onStartEdit: () => void;
onSaveEdit: (directValue?: string) => void;
onCancelEdit: () => void;
onClearMapping: () => void;
}> = ({ item, type: _type, isEditing, editValue, suggestions, onEditValueChange, onStartEdit, onSaveEdit, onCancelEdit, onClearMapping }) => {
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [showDropdown, setShowDropdown] = useState(false);
// Filter suggestions based on input, exclude current item name
const filteredSuggestions = suggestions.filter(s =>
s.toLowerCase() !== item.name.toLowerCase() &&
s.toLowerCase().includes(editValue.toLowerCase())
);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
setShowDropdown(true);
setSelectedIndex(-1);
}
}, [isEditing]);
// Reset selection when filtered results change
useEffect(() => {
setSelectedIndex(-1);
}, [editValue]);
const handleSelectSuggestion = (suggestion: string) => {
setShowDropdown(false);
onSaveEdit(suggestion);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < filteredSuggestions.length) {
handleSelectSuggestion(filteredSuggestions[selectedIndex]);
} else {
onSaveEdit();
}
} else if (e.key === 'Escape') {
onCancelEdit();
} else if (e.key === 'Tab') {
// Allow tab to close dropdown and save
setShowDropdown(false);
}
};
const handleBlur = (e: React.FocusEvent) => {
// Check if focus is moving to the dropdown
if (dropdownRef.current?.contains(e.relatedTarget as Node)) {
return;
}
// Small delay to allow click on dropdown item
setTimeout(() => {
setShowDropdown(false);
onSaveEdit();
}, 150);
};
if (isEditing) {
return (
<span className="import-taxonomy-pill editing">
<span className="pill-name">{item.name}</span>
<span className="pill-arrow"></span>
<div className="pill-edit-container">
<input
ref={inputRef}
type="text"
className="pill-edit-input"
value={editValue}
onChange={(e) => {
onEditValueChange(e.target.value);
setShowDropdown(true);
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={() => setShowDropdown(true)}
placeholder="Map to..."
/>
{showDropdown && filteredSuggestions.length > 0 && (
<div className="pill-suggestions-dropdown" ref={dropdownRef}>
{filteredSuggestions.slice(0, 10).map((suggestion, idx) => (
<button
key={suggestion}
className={`pill-suggestion-item ${idx === selectedIndex ? 'selected' : ''}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelectSuggestion(suggestion);
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
{suggestion}
</button>
))}
</div>
</div>
)}
{tags.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
Tags
</div>
<div className="import-taxonomy-list">
{tags.map((tag, idx) => (
<span key={idx} className={`import-taxonomy-pill ${tag.existsInProject ? 'exists' : 'new-tax'}`}>
{tag.name}
</span>
))}
</div>
</div>
)}
</>
)}
</div>
);
)}
</div>
</span>
);
}
const hasMaping = !!item.mappedTo;
const className = `import-taxonomy-pill ${item.existsInProject ? 'exists' : 'new-tax'} ${hasMaping ? 'mapped' : ''}`;
return (
<span className={className} onClick={onStartEdit} title={`Click to ${hasMaping ? 'edit' : 'add'} mapping`}>
{item.name}
{hasMaping && (
<>
<span className="pill-arrow"></span>
<span className="pill-mapped-to">{item.mappedTo}</span>
<button
className="pill-clear-btn"
onClick={(e) => { e.stopPropagation(); onClearMapping(); }}
title="Clear mapping"
>
×
</button>
</>
)}
</span>
);
};

View File

@@ -432,6 +432,9 @@ export interface ElectronAPI {
clearMessages: (conversationId: string) => Promise<void>;
setConversationModel: (conversationId: string, modelId: string) => Promise<void>;
// Taxonomy Analysis
analyzeTaxonomy: (categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => Promise<{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }>;
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;