Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions dashboard/src/components/filters/SourceToolMultiSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAvailableSourceTools } from '@/hooks/useFacets';
import { SOURCE_TOOL_DISPLAY_NAMES } from '@/lib/share-card-icons';

// Extract the dot color class from SOURCE_TOOL_COLORS badge string (e.g. "bg-orange-500/10 text-orange-600 ...")
// We only need the text color for the dot background — use the bg-*-500/10 converted to bg-*-500
const DOT_COLORS: Record<string, string> = {
'claude-code': 'bg-orange-500',
'cursor': 'bg-blue-500',
'codex-cli': 'bg-green-500',
'copilot-cli': 'bg-cyan-500',
'copilot': 'bg-violet-500',
'opencode': 'bg-purple-500',
'antigravity': 'bg-red-500',
'crush': 'bg-yellow-500',
'hermes-agent': 'bg-pink-500',
'mistral-vibe': 'bg-indigo-500',
'kilo': 'bg-teal-500',
};

function toTitleCase(id: string): string {
return id
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}

function labelFor(id: string): string {
return SOURCE_TOOL_DISPLAY_NAMES[id] ?? toTitleCase(id);
}

function dotColorFor(id: string): string {
return DOT_COLORS[id] ?? 'bg-gray-400';
}

interface SourceToolMultiSelectProps {
value: string[];
onValueChange: (value: string[]) => void;
className?: string;
}

export function SourceToolMultiSelect({ value, onValueChange, className }: SourceToolMultiSelectProps) {
const { data: sourceTools = [] } = useAvailableSourceTools();

const toggle = (id: string) => {
onValueChange(value.includes(id) ? value.filter((item) => item !== id) : [...value, id]);
};

const label = value.length === 0
? '所有來源'
: value.length === 1
? labelFor(value[0])
: `已選 ${value.length} 個來源`;

return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className={`justify-between font-normal ${className ?? ''}`}>{label}</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-muted-foreground">
<span>選擇一或多個來源</span>
{value.length > 0 && <button type="button" className="hover:text-foreground" onClick={() => onValueChange([])}>清除</button>}
</div>
<div className="max-h-56 overflow-y-auto">
{sourceTools.map((tool) => (
<label key={tool} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-sm hover:bg-accent">
<Checkbox checked={value.includes(tool)} onCheckedChange={() => toggle(tool)} />
<span className={`h-2 w-2 rounded-full shrink-0 ${dotColorFor(tool)}`} />
<span className="truncate">{labelFor(tool)}</span>
</label>
))}
{sourceTools.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground">沒有可用的來源。</p>}
</div>
</PopoverContent>
</Popover>
);
}
65 changes: 0 additions & 65 deletions dashboard/src/components/filters/SourceToolSelect.tsx

This file was deleted.

30 changes: 11 additions & 19 deletions dashboard/src/components/sessions/ProjectNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,6 @@ import { useState, useMemo } from 'react';
import { Folder, FolderOpen, MoreVertical, Pencil } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -17,7 +10,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import { SOURCE_TOOLS } from '@/components/filters/SourceToolSelect';
import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect';
import type { Project } from '@/lib/types';
import { EditProjectDialog } from '@/components/projects/EditProjectDialog';

Expand All @@ -40,6 +33,11 @@ export function ProjectNav({
const [editingProject, setEditingProject] = useState<Project | null>(null);
const showSearch = projects.length > 8;

const selectedSourceTools = useMemo(
() => selectedSource === 'all' ? [] : selectedSource.split(',').filter(Boolean),
[selectedSource]
);

const totalSessions = useMemo(
() => projects.reduce((sum, p) => sum + p.session_count, 0),
[projects]
Expand Down Expand Up @@ -136,17 +134,11 @@ export function ProjectNav({

{/* Source filter at bottom */}
<div className="p-3 border-t">
<Select value={selectedSource} onValueChange={onSelectSource}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="All Sources" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sources</SelectItem>
{SOURCE_TOOLS.map((tool) => (
<SelectItem key={tool.value} value={tool.value}>{tool.label}</SelectItem>
))}
</SelectContent>
</Select>
<SourceToolMultiSelect
value={selectedSourceTools}
onValueChange={(ids) => onSelectSource(ids.length > 0 ? ids.join(',') : 'all')}
className="h-8 text-xs w-full"
/>
</div>

{editingProject && (
Expand Down
13 changes: 9 additions & 4 deletions dashboard/src/components/sessions/SessionListPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { useQueuedSessionIds } from '@/hooks/useAnalysisQueue';
import { useAnalyzedSessionIds } from '@/hooks/useAnalyzedSessionIds';
import { SaveFilterPopover } from '@/components/filters/SaveFilterPopover';
import { SavedFiltersDropdown } from '@/components/filters/SavedFiltersDropdown';
import { SourceToolSelect } from '@/components/filters/SourceToolSelect';
import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect';
import { HomeSelect } from '@/components/filters/HomeSelect';
import { useSavedFilters } from '@/hooks/useSavedFilters';

Expand Down Expand Up @@ -99,6 +99,11 @@ export function SessionListPanel({
const [customDateOpen, setCustomDateOpen] = useState(false);
const { savedFilters, saveFilter, deleteFilter } = useSavedFilters('sessions');

const selectedSourceTools = useMemo(
() => (!filters.source || filters.source === 'all') ? [] : filters.source.split(',').filter(Boolean),
[filters.source]
);

const { data: deletedCount = 0 } = useDeletedSessionCount(projectId);
const queuedSessionIds = useQueuedSessionIds();
// Sourced from analysis_usage, not `insights` — insights has no safe row cap to
Expand Down Expand Up @@ -332,9 +337,9 @@ export function SessionListPanel({

{/* Row 4: Source + Home + Save */}
<div className="flex gap-2 items-center">
<SourceToolSelect
value={filters.source || 'all'}
onValueChange={(v) => onFilterChange('source', v)}
<SourceToolMultiSelect
value={selectedSourceTools}
onValueChange={(ids) => onFilterChange('source', ids.length > 0 ? ids.join(',') : 'all')}
className="h-7 text-xs flex-1 min-w-0"
/>

Expand Down
14 changes: 13 additions & 1 deletion dashboard/src/hooks/useFacets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchMissingFacetSessionIds, backfillFacets } from '@/lib/api';
import { fetchMissingFacetSessionIds, backfillFacets, fetchFacetAggregation } from '@/lib/api';

export function useMissingFacets(params?: {
project?: string;
Expand All @@ -13,6 +13,18 @@ export function useMissingFacets(params?: {
});
}

// Distinct source_tool values actually present in the DB — drives the source-tool
// multi-select filter so it never shows tools with zero sessions, and automatically
// picks up new tools without a code change. Long staleTime since this rarely
// changes within a session (new sessions from a brand-new tool are rare).
export function useAvailableSourceTools() {
return useQuery({
queryKey: ['facets', 'aggregated', 'sourceTools'],
queryFn: () => fetchFacetAggregation({ period: 'all' }).then((r) => r.sourceTools),
staleTime: 5 * 60_000,
});
}

export function useBackfillFacets() {
const queryClient = useQueryClient();
return useMutation({
Expand Down
12 changes: 8 additions & 4 deletions dashboard/src/pages/AnalyticsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ErrorCard } from '@/components/ErrorCard';
import { formatTokenCount, formatModelName } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { CHART_COLORS } from '@/lib/constants/colors';
import { SourceToolSelect } from '@/components/filters/SourceToolSelect';
import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect';
import { HomeSelect } from '@/components/filters/HomeSelect';
import {
BarChart,
Expand All @@ -37,6 +37,10 @@ export default function AnalyticsPage() {
const [range, setRange] = useState<AnalyticsRange>('7d');
const [source, setSource] = useState<string>('all');
const [homeId, setHomeId] = useState<string>('all');
const selectedSourceTools = useMemo(
() => source === 'all' ? [] : source.split(',').filter(Boolean),
[source]
);
const { data: sessions = [], isLoading: sessionsLoading, isError: sessionsError, refetch: refetchSessions } = useSessions({
limit: 500,
...(source !== 'all' && { sourceTool: source }),
Expand Down Expand Up @@ -268,9 +272,9 @@ export default function AnalyticsPage() {
</Button>
))}
</div>
<SourceToolSelect
value={source}
onValueChange={setSource}
<SourceToolMultiSelect
value={selectedSourceTools}
onValueChange={(ids) => setSource(ids.length > 0 ? ids.join(',') : 'all')}
className="w-[140px] h-7 text-xs"
/>
<HomeSelect
Expand Down
18 changes: 11 additions & 7 deletions dashboard/src/pages/InsightsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type { Insight, InsightType, DispatchPrefill, SessionCharacter, Effective
import { InsightTypePills } from '@/components/filters/InsightTypePills';
import { SaveFilterPopover } from '@/components/filters/SaveFilterPopover';
import { SavedFiltersDropdown } from '@/components/filters/SavedFiltersDropdown';
import { SourceToolSelect } from '@/components/filters/SourceToolSelect';
import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect';
import { HomeMultiSelect } from '@/components/filters/HomeMultiSelect';
import { ProjectMultiSelect } from '@/components/filters/ProjectMultiSelect';
import { useSavedFilters } from '@/hooks/useSavedFilters';
Expand Down Expand Up @@ -260,6 +260,10 @@ export default function InsightsPage() {
() => filters.project === 'all' ? [] : filters.project.split(',').filter(Boolean),
[filters.project]
);
const selectedSourceTools = useMemo(
() => filters.source === 'all' ? [] : filters.source.split(',').filter(Boolean),
[filters.source]
);

const availableProjects = useMemo(() => {
if (selectedHomeIds.length === 0) return projects;
Expand Down Expand Up @@ -298,17 +302,17 @@ export default function InsightsPage() {
return false;
}
}
if (filters.source !== 'all') {
if (selectedSourceTools.length > 0) {
const sourceTool = sessionSourceMap.get(i.session_id);
if (sourceTool !== filters.source) return false;
if (!sourceTool || !selectedSourceTools.includes(sourceTool)) return false;
}
if (selectedHomeIds.length > 0) {
const sessionHomeId = sessionHomeMap.get(i.session_id);
if (!sessionHomeId || !selectedHomeIds.includes(sessionHomeId)) return false;
}
return true;
});
}, [insights, activeTypes, filters.q, filters.source, patternInsightIds, selectedHomeIds, selectedProjectIds, sessionSourceMap, sessionHomeMap]);
}, [insights, activeTypes, filters.q, selectedSourceTools, patternInsightIds, selectedHomeIds, selectedProjectIds, sessionSourceMap, sessionHomeMap]);

const hasFilters = !!filters.q || filters.type !== 'all' || filters.project !== 'all' || !!filters.pattern || filters.source !== 'all' || filters.homeId !== 'all';

Expand Down Expand Up @@ -455,9 +459,9 @@ export default function InsightsPage() {
onValueChange={(ids) => setFilter('project', ids.length > 0 ? ids.join(',') : 'all')}
/>

<SourceToolSelect
value={filters.source}
onValueChange={(v) => setFilter('source', v)}
<SourceToolMultiSelect
value={selectedSourceTools}
onValueChange={(ids) => setFilter('source', ids.length > 0 ? ids.join(',') : 'all')}
className="w-[140px]"
/>

Expand Down
Loading
Loading