Skip to content

Commit e27ff89

Browse files
committed
feat(dashboard): convert source-tool filter to dynamic multi-select
Replaces the hardcoded single-select SourceToolSelect (11 static entries) with SourceToolMultiSelect, populated from useAvailableSourceTools() (a thin wrapper around fetchFacetAggregation({period:'all'}) -> sourceTools), so the option list reflects what's actually in the DB and new source tools appear without a code change. Converts all 5 call sites (InsightsPage, JournalPage, AnalyticsPage, SessionListPanel, ProjectNav/SessionsPage) to the existing comma-joined-string filter-state convention already used for filters.project, converting to/from string[] only at the component boundary. Deletes the now-unused SourceToolSelect.tsx and its hardcoded SOURCE_TOOLS array. Depends on the server accepting CSV source/sourceTool filter values (prior commit).
1 parent 6c06807 commit e27ff89

8 files changed

Lines changed: 143 additions & 107 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Button } from '@/components/ui/button';
2+
import { Checkbox } from '@/components/ui/checkbox';
3+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
4+
import { useAvailableSourceTools } from '@/hooks/useFacets';
5+
import { SOURCE_TOOL_DISPLAY_NAMES } from '@/lib/share-card-icons';
6+
7+
// Extract the dot color class from SOURCE_TOOL_COLORS badge string (e.g. "bg-orange-500/10 text-orange-600 ...")
8+
// We only need the text color for the dot background — use the bg-*-500/10 converted to bg-*-500
9+
const DOT_COLORS: Record<string, string> = {
10+
'claude-code': 'bg-orange-500',
11+
'cursor': 'bg-blue-500',
12+
'codex-cli': 'bg-green-500',
13+
'copilot-cli': 'bg-cyan-500',
14+
'copilot': 'bg-violet-500',
15+
'opencode': 'bg-purple-500',
16+
'antigravity': 'bg-red-500',
17+
'crush': 'bg-yellow-500',
18+
'hermes-agent': 'bg-pink-500',
19+
'mistral-vibe': 'bg-indigo-500',
20+
'kilo': 'bg-teal-500',
21+
};
22+
23+
function toTitleCase(id: string): string {
24+
return id
25+
.split(/[-_]/)
26+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
27+
.join(' ');
28+
}
29+
30+
function labelFor(id: string): string {
31+
return SOURCE_TOOL_DISPLAY_NAMES[id] ?? toTitleCase(id);
32+
}
33+
34+
function dotColorFor(id: string): string {
35+
return DOT_COLORS[id] ?? 'bg-gray-400';
36+
}
37+
38+
interface SourceToolMultiSelectProps {
39+
value: string[];
40+
onValueChange: (value: string[]) => void;
41+
className?: string;
42+
}
43+
44+
export function SourceToolMultiSelect({ value, onValueChange, className }: SourceToolMultiSelectProps) {
45+
const { data: sourceTools = [] } = useAvailableSourceTools();
46+
47+
const toggle = (id: string) => {
48+
onValueChange(value.includes(id) ? value.filter((item) => item !== id) : [...value, id]);
49+
};
50+
51+
const label = value.length === 0
52+
? '所有來源'
53+
: value.length === 1
54+
? labelFor(value[0])
55+
: `已選 ${value.length} 個來源`;
56+
57+
return (
58+
<Popover>
59+
<PopoverTrigger asChild>
60+
<Button variant="outline" className={`justify-between font-normal ${className ?? ''}`}>{label}</Button>
61+
</PopoverTrigger>
62+
<PopoverContent align="start" className="w-64 p-2">
63+
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-muted-foreground">
64+
<span>選擇一或多個來源</span>
65+
{value.length > 0 && <button type="button" className="hover:text-foreground" onClick={() => onValueChange([])}>清除</button>}
66+
</div>
67+
<div className="max-h-56 overflow-y-auto">
68+
{sourceTools.map((tool) => (
69+
<label key={tool} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-sm hover:bg-accent">
70+
<Checkbox checked={value.includes(tool)} onCheckedChange={() => toggle(tool)} />
71+
<span className={`h-2 w-2 rounded-full shrink-0 ${dotColorFor(tool)}`} />
72+
<span className="truncate">{labelFor(tool)}</span>
73+
</label>
74+
))}
75+
{sourceTools.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground">沒有可用的來源。</p>}
76+
</div>
77+
</PopoverContent>
78+
</Popover>
79+
);
80+
}

dashboard/src/components/filters/SourceToolSelect.tsx

Lines changed: 0 additions & 65 deletions
This file was deleted.

dashboard/src/components/sessions/ProjectNav.tsx

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,6 @@ import { useState, useMemo } from 'react';
22
import { Folder, FolderOpen, MoreVertical, Pencil } from 'lucide-react';
33
import { Input } from '@/components/ui/input';
44
import { Button } from '@/components/ui/button';
5-
import {
6-
Select,
7-
SelectContent,
8-
SelectItem,
9-
SelectTrigger,
10-
SelectValue,
11-
} from '@/components/ui/select';
125
import {
136
DropdownMenu,
147
DropdownMenuContent,
@@ -17,7 +10,7 @@ import {
1710
} from '@/components/ui/dropdown-menu';
1811
import { Separator } from '@/components/ui/separator';
1912
import { cn } from '@/lib/utils';
20-
import { SOURCE_TOOLS } from '@/components/filters/SourceToolSelect';
13+
import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect';
2114
import type { Project } from '@/lib/types';
2215
import { EditProjectDialog } from '@/components/projects/EditProjectDialog';
2316

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

36+
const selectedSourceTools = useMemo(
37+
() => selectedSource === 'all' ? [] : selectedSource.split(',').filter(Boolean),
38+
[selectedSource]
39+
);
40+
4341
const totalSessions = useMemo(
4442
() => projects.reduce((sum, p) => sum + p.session_count, 0),
4543
[projects]
@@ -136,17 +134,11 @@ export function ProjectNav({
136134

137135
{/* Source filter at bottom */}
138136
<div className="p-3 border-t">
139-
<Select value={selectedSource} onValueChange={onSelectSource}>
140-
<SelectTrigger className="h-8 text-xs">
141-
<SelectValue placeholder="All Sources" />
142-
</SelectTrigger>
143-
<SelectContent>
144-
<SelectItem value="all">All Sources</SelectItem>
145-
{SOURCE_TOOLS.map((tool) => (
146-
<SelectItem key={tool.value} value={tool.value}>{tool.label}</SelectItem>
147-
))}
148-
</SelectContent>
149-
</Select>
137+
<SourceToolMultiSelect
138+
value={selectedSourceTools}
139+
onValueChange={(ids) => onSelectSource(ids.length > 0 ? ids.join(',') : 'all')}
140+
className="h-8 text-xs w-full"
141+
/>
150142
</div>
151143

152144
{editingProject && (

dashboard/src/components/sessions/SessionListPanel.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { useQueuedSessionIds } from '@/hooks/useAnalysisQueue';
2424
import { useAnalyzedSessionIds } from '@/hooks/useAnalyzedSessionIds';
2525
import { SaveFilterPopover } from '@/components/filters/SaveFilterPopover';
2626
import { SavedFiltersDropdown } from '@/components/filters/SavedFiltersDropdown';
27-
import { SourceToolSelect } from '@/components/filters/SourceToolSelect';
27+
import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect';
2828
import { HomeSelect } from '@/components/filters/HomeSelect';
2929
import { useSavedFilters } from '@/hooks/useSavedFilters';
3030

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

102+
const selectedSourceTools = useMemo(
103+
() => (!filters.source || filters.source === 'all') ? [] : filters.source.split(',').filter(Boolean),
104+
[filters.source]
105+
);
106+
102107
const { data: deletedCount = 0 } = useDeletedSessionCount(projectId);
103108
const queuedSessionIds = useQueuedSessionIds();
104109
// Sourced from analysis_usage, not `insights` — insights has no safe row cap to
@@ -332,9 +337,9 @@ export function SessionListPanel({
332337

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

dashboard/src/hooks/useFacets.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2-
import { fetchMissingFacetSessionIds, backfillFacets } from '@/lib/api';
2+
import { fetchMissingFacetSessionIds, backfillFacets, fetchFacetAggregation } from '@/lib/api';
33

44
export function useMissingFacets(params?: {
55
project?: string;
@@ -13,6 +13,18 @@ export function useMissingFacets(params?: {
1313
});
1414
}
1515

16+
// Distinct source_tool values actually present in the DB — drives the source-tool
17+
// multi-select filter so it never shows tools with zero sessions, and automatically
18+
// picks up new tools without a code change. Long staleTime since this rarely
19+
// changes within a session (new sessions from a brand-new tool are rare).
20+
export function useAvailableSourceTools() {
21+
return useQuery({
22+
queryKey: ['facets', 'aggregated', 'sourceTools'],
23+
queryFn: () => fetchFacetAggregation({ period: 'all' }).then((r) => r.sourceTools),
24+
staleTime: 5 * 60_000,
25+
});
26+
}
27+
1628
export function useBackfillFacets() {
1729
const queryClient = useQueryClient();
1830
return useMutation({

dashboard/src/pages/AnalyticsPage.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { ErrorCard } from '@/components/ErrorCard';
1111
import { formatTokenCount, formatModelName } from '@/lib/utils';
1212
import { Button } from '@/components/ui/button';
1313
import { CHART_COLORS } from '@/lib/constants/colors';
14-
import { SourceToolSelect } from '@/components/filters/SourceToolSelect';
14+
import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect';
1515
import { HomeSelect } from '@/components/filters/HomeSelect';
1616
import {
1717
BarChart,
@@ -37,6 +37,10 @@ export default function AnalyticsPage() {
3737
const [range, setRange] = useState<AnalyticsRange>('7d');
3838
const [source, setSource] = useState<string>('all');
3939
const [homeId, setHomeId] = useState<string>('all');
40+
const selectedSourceTools = useMemo(
41+
() => source === 'all' ? [] : source.split(',').filter(Boolean),
42+
[source]
43+
);
4044
const { data: sessions = [], isLoading: sessionsLoading, isError: sessionsError, refetch: refetchSessions } = useSessions({
4145
limit: 500,
4246
...(source !== 'all' && { sourceTool: source }),
@@ -268,9 +272,9 @@ export default function AnalyticsPage() {
268272
</Button>
269273
))}
270274
</div>
271-
<SourceToolSelect
272-
value={source}
273-
onValueChange={setSource}
275+
<SourceToolMultiSelect
276+
value={selectedSourceTools}
277+
onValueChange={(ids) => setSource(ids.length > 0 ? ids.join(',') : 'all')}
274278
className="w-[140px] h-7 text-xs"
275279
/>
276280
<HomeSelect

dashboard/src/pages/InsightsPage.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import type { Insight, InsightType, DispatchPrefill, SessionCharacter, Effective
3030
import { InsightTypePills } from '@/components/filters/InsightTypePills';
3131
import { SaveFilterPopover } from '@/components/filters/SaveFilterPopover';
3232
import { SavedFiltersDropdown } from '@/components/filters/SavedFiltersDropdown';
33-
import { SourceToolSelect } from '@/components/filters/SourceToolSelect';
33+
import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect';
3434
import { HomeMultiSelect } from '@/components/filters/HomeMultiSelect';
3535
import { ProjectMultiSelect } from '@/components/filters/ProjectMultiSelect';
3636
import { useSavedFilters } from '@/hooks/useSavedFilters';
@@ -260,6 +260,10 @@ export default function InsightsPage() {
260260
() => filters.project === 'all' ? [] : filters.project.split(',').filter(Boolean),
261261
[filters.project]
262262
);
263+
const selectedSourceTools = useMemo(
264+
() => filters.source === 'all' ? [] : filters.source.split(',').filter(Boolean),
265+
[filters.source]
266+
);
263267

264268
const availableProjects = useMemo(() => {
265269
if (selectedHomeIds.length === 0) return projects;
@@ -298,17 +302,17 @@ export default function InsightsPage() {
298302
return false;
299303
}
300304
}
301-
if (filters.source !== 'all') {
305+
if (selectedSourceTools.length > 0) {
302306
const sourceTool = sessionSourceMap.get(i.session_id);
303-
if (sourceTool !== filters.source) return false;
307+
if (!sourceTool || !selectedSourceTools.includes(sourceTool)) return false;
304308
}
305309
if (selectedHomeIds.length > 0) {
306310
const sessionHomeId = sessionHomeMap.get(i.session_id);
307311
if (!sessionHomeId || !selectedHomeIds.includes(sessionHomeId)) return false;
308312
}
309313
return true;
310314
});
311-
}, [insights, activeTypes, filters.q, filters.source, patternInsightIds, selectedHomeIds, selectedProjectIds, sessionSourceMap, sessionHomeMap]);
315+
}, [insights, activeTypes, filters.q, selectedSourceTools, patternInsightIds, selectedHomeIds, selectedProjectIds, sessionSourceMap, sessionHomeMap]);
312316

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

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

458-
<SourceToolSelect
459-
value={filters.source}
460-
onValueChange={(v) => setFilter('source', v)}
462+
<SourceToolMultiSelect
463+
value={selectedSourceTools}
464+
onValueChange={(ids) => setFilter('source', ids.length > 0 ? ids.join(',') : 'all')}
461465
className="w-[140px]"
462466
/>
463467

0 commit comments

Comments
 (0)