From 918034fdc89a7a9cef8921aafee3d9079b713ff9 Mon Sep 17 00:00:00 2001 From: tianyao Date: Tue, 21 Jul 2026 14:05:15 +0000 Subject: [PATCH 1/2] feat(server): accept comma-separated source/sourceTool filter values Adds a shared buildInCondition() utility (TDD, tests in utils.test.ts) and uses it in place of exact-equality source_tool filters across shared-aggregation, sessions, analytics, and facets routes. A single CSV value still produces an equivalent IN (?) clause, so existing single-value behavior is unchanged; a comma-separated value now filters across multiple source tools in one query. Prepares the server for the dashboard's source-tool multi-select filter. --- server/src/routes/analytics.test.ts | 29 ++++++++++++++++ server/src/routes/analytics.ts | 8 +++-- server/src/routes/facets.ts | 8 +++-- server/src/routes/sessions.test.ts | 14 ++++++++ server/src/routes/sessions.ts | 9 ++--- server/src/routes/shared-aggregation.test.ts | 10 ++++-- server/src/routes/shared-aggregation.ts | 9 ++--- server/src/utils.test.ts | 36 +++++++++++++++++++- server/src/utils.ts | 18 ++++++++++ 9 files changed, 124 insertions(+), 17 deletions(-) diff --git a/server/src/routes/analytics.test.ts b/server/src/routes/analytics.test.ts index 779d1b28..ceaeaafe 100644 --- a/server/src/routes/analytics.test.ts +++ b/server/src/routes/analytics.test.ts @@ -258,6 +258,35 @@ describe('Analytics routes', () => { expect(cursor.cacheReadTokens).toBe(8000); }); + it('filters by a comma-separated source list (multi-select)', async () => { + insertSessionWithCache(testDb, { + id: 's1', + startedAt: '2026-07-19T10:00:00.000Z', + sourceTool: 'kilo', + cacheReadTokens: 1000, + }); + insertSessionWithCache(testDb, { + id: 's2', + startedAt: '2026-07-19T11:00:00.000Z', + sourceTool: 'cursor', + cacheReadTokens: 2000, + }); + insertSessionWithCache(testDb, { + id: 's3', + startedAt: '2026-07-19T12:00:00.000Z', + sourceTool: 'claude-code', + cacheReadTokens: 3000, + }); + + const app = createApp(); + const res = await app.request('/api/analytics/cache-by-source?range=all&source=kilo,cursor'); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.rows.length).toBe(2); + expect(body.rows.map((r: any) => r.sourceTool).sort()).toEqual(['cursor', 'kilo']); + }); + it('orders by cache_read_tokens DESC', async () => { insertSessionWithCache(testDb, { id: 's1', diff --git a/server/src/routes/analytics.ts b/server/src/routes/analytics.ts index 4cd359a5..6199d8fb 100644 --- a/server/src/routes/analytics.ts +++ b/server/src/routes/analytics.ts @@ -1,5 +1,6 @@ import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; import { getDb } from '@code-insights/cli/db/client'; +import { buildInCondition } from '../utils.js'; import { ErrorSchema } from '../schemas/common.js'; import { RangeQuerySchema, @@ -241,9 +242,10 @@ app.openapi(cacheBySourceRoute, (c) => { conditions.push('home_id = ?'); params.push(homeId); } - if (source) { - conditions.push('source_tool = ?'); - params.push(source); + const sourceCondition = buildInCondition('source_tool', source); + if (sourceCondition) { + conditions.push(sourceCondition.clause); + params.push(...sourceCondition.params); } const where = `WHERE ${conditions.join(' AND ')}`; diff --git a/server/src/routes/facets.ts b/server/src/routes/facets.ts index 6d574dad..59df7612 100644 --- a/server/src/routes/facets.ts +++ b/server/src/routes/facets.ts @@ -2,6 +2,7 @@ import { OpenAPIHono, createRoute } from '@hono/zod-openapi'; import { getDb } from '@code-insights/cli/db/client'; import { extractFacetsOnly, analyzePromptQuality } from '../llm/analysis.js'; import { buildWhereClause, getAggregatedData } from './shared-aggregation.js'; +import { buildInCondition } from '../utils.js'; import { ErrorSchema } from '../schemas/common.js'; import { AggregatedDataSchema } from '../schemas/aggregation.js'; import { @@ -142,9 +143,10 @@ app.openapi(missingRoute, (c) => { conditions.push('s.project_id = ?'); params.push(project); } - if (source) { - conditions.push('s.source_tool = ?'); - params.push(source); + const sourceCondition = buildInCondition('s.source_tool', source); + if (sourceCondition) { + conditions.push(sourceCondition.clause); + params.push(...sourceCondition.params); } const where = `WHERE ${conditions.join(' AND ')}`; diff --git a/server/src/routes/sessions.test.ts b/server/src/routes/sessions.test.ts index c71c81f7..b13ecdb5 100644 --- a/server/src/routes/sessions.test.ts +++ b/server/src/routes/sessions.test.ts @@ -120,6 +120,20 @@ describe('Sessions routes', () => { expect(body.sessions).toHaveLength(1); expect(body.sessions[0].id).toBe('sess-cur'); }); + + it('filters by a comma-separated sourceTool list (multi-select)', async () => { + seedProject('proj-1', 'alpha'); + seedSession('sess-cc', 'proj-1', { source_tool: 'claude-code' }); + seedSession('sess-cur', 'proj-1', { source_tool: 'cursor' }); + seedSession('sess-codex', 'proj-1', { source_tool: 'codex-cli' }); + + const app = createApp(); + const res = await app.request('/api/sessions?sourceTool=cursor,claude-code'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.sessions).toHaveLength(2); + expect(body.sessions.map((s: any) => s.id).sort()).toEqual(['sess-cc', 'sess-cur']); + }); }); describe('GET /api/sessions/:id', () => { diff --git a/server/src/routes/sessions.ts b/server/src/routes/sessions.ts index d21de6a0..f7e268ac 100644 --- a/server/src/routes/sessions.ts +++ b/server/src/routes/sessions.ts @@ -1,6 +1,6 @@ import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; import { getDb } from '@code-insights/cli/db/client'; -import { parseIntParam } from '../utils.js'; +import { parseIntParam, buildInCondition } from '../utils.js'; import { ErrorSchema, OkSchema } from '../schemas/common.js'; import { SessionSchema, @@ -68,9 +68,10 @@ app.openapi(listRoute, (c) => { conditions.push('project_id = ?'); params.push(projectId); } - if (sourceTool) { - conditions.push('source_tool = ?'); - params.push(sourceTool); + const sourceToolCondition = buildInCondition('source_tool', sourceTool); + if (sourceToolCondition) { + conditions.push(sourceToolCondition.clause); + params.push(...sourceToolCondition.params); } if (homeId) { conditions.push('home_id = ?'); diff --git a/server/src/routes/shared-aggregation.test.ts b/server/src/routes/shared-aggregation.test.ts index 9594b46b..13410b18 100644 --- a/server/src/routes/shared-aggregation.test.ts +++ b/server/src/routes/shared-aggregation.test.ts @@ -153,13 +153,19 @@ describe('buildWhereClause', () => { it('adds source filter when source is provided', () => { const { where, params } = buildWhereClause('all', undefined, 'cursor'); - expect(where).toBe('WHERE s.deleted_at IS NULL AND s.source_tool = ?'); + expect(where).toBe('WHERE s.deleted_at IS NULL AND s.source_tool IN (?)'); expect(params).toEqual(['cursor']); }); + it('adds an IN clause for a comma-separated source list (multi-select)', () => { + const { where, params } = buildWhereClause('all', undefined, 'cursor,claude-code'); + expect(where).toBe('WHERE s.deleted_at IS NULL AND s.source_tool IN (?, ?)'); + expect(params).toEqual(['cursor', 'claude-code']); + }); + it('combines all filters with AND', () => { const { where, params } = buildWhereClause('7d', 'proj-abc', 'claude-code'); - expect(where).toMatch(/^WHERE s\.deleted_at IS NULL AND s\.started_at >= \? AND s\.project_id = \? AND s\.source_tool = \?$/); + expect(where).toMatch(/^WHERE s\.deleted_at IS NULL AND s\.started_at >= \? AND s\.project_id = \? AND s\.source_tool IN \(\?\)$/); expect(params).toHaveLength(3); expect(params[1]).toBe('proj-abc'); expect(params[2]).toBe('claude-code'); diff --git a/server/src/routes/shared-aggregation.ts b/server/src/routes/shared-aggregation.ts index f02f1155..137b5c86 100644 --- a/server/src/routes/shared-aggregation.ts +++ b/server/src/routes/shared-aggregation.ts @@ -6,7 +6,7 @@ import { normalizeFrictionCategory } from '../llm/friction-normalize.js'; import { normalizePatternCategory, getPatternCategoryLabel } from '../llm/pattern-normalize.js'; import { normalizePromptQualityCategory, PQ_CATEGORY_LABELS } from '../llm/prompt-quality-normalize.js'; import { CANONICAL_PQ_STRENGTH_CATEGORIES } from '../llm/prompt-constants.js'; -import { safeParseJson } from '../utils.js'; +import { safeParseJson, buildInCondition } from '../utils.js'; // ISO week regex: matches YYYY-WNN format (e.g., 2026-W10) const ISO_WEEK_RE = /^(\d{4})-W(\d{2})$/; @@ -98,9 +98,10 @@ export function buildWhereClause( conditions.push('s.project_id = ?'); params.push(project); } - if (source) { - conditions.push('s.source_tool = ?'); - params.push(source); + const sourceCondition = buildInCondition('s.source_tool', source); + if (sourceCondition) { + conditions.push(sourceCondition.clause); + params.push(...sourceCondition.params); } if (homeId) { conditions.push('s.home_id = ?'); diff --git a/server/src/utils.test.ts b/server/src/utils.test.ts index 633d53d3..b12bdf09 100644 --- a/server/src/utils.test.ts +++ b/server/src/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseIntParam } from './utils.js'; +import { parseIntParam, buildInCondition } from './utils.js'; describe('parseIntParam', () => { it('returns parsed integer for a valid string', () => { @@ -30,3 +30,37 @@ describe('parseIntParam', () => { expect(parseIntParam('Infinity', 10)).toBe(10); }); }); + +describe('buildInCondition', () => { + it('returns null when value is undefined', () => { + expect(buildInCondition('source_tool', undefined)).toBeNull(); + }); + + it('returns null for an empty string', () => { + expect(buildInCondition('source_tool', '')).toBeNull(); + }); + + it('returns null when value is only commas/whitespace', () => { + expect(buildInCondition('source_tool', ' , , ')).toBeNull(); + }); + + it('builds an equality-shaped single-value IN clause (single value behaves like today)', () => { + const result = buildInCondition('source_tool', 'cursor'); + expect(result).toEqual({ clause: 'source_tool IN (?)', params: ['cursor'] }); + }); + + it('splits a comma-separated value into multiple IN params', () => { + const result = buildInCondition('source_tool', 'cursor,claude-code'); + expect(result).toEqual({ clause: 'source_tool IN (?, ?)', params: ['cursor', 'claude-code'] }); + }); + + it('trims whitespace around each value and drops empty entries', () => { + const result = buildInCondition('source_tool', ' cursor , claude-code ,, '); + expect(result).toEqual({ clause: 'source_tool IN (?, ?)', params: ['cursor', 'claude-code'] }); + }); + + it('uses the provided column name verbatim in the clause', () => { + const result = buildInCondition('s.source_tool', 'kilo'); + expect(result?.clause).toBe('s.source_tool IN (?)'); + }); +}); diff --git a/server/src/utils.ts b/server/src/utils.ts index 6561df93..b9a81da2 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -7,6 +7,24 @@ export function parseIntParam(value: string | undefined, defaultVal: number): nu return Number.isFinite(n) && n >= 0 ? n : defaultVal; } +/** + * Build a SQL `IN (...)` condition from a comma-separated filter value (e.g. + * `?source=cursor,claude-code`). Returns null when there's nothing to filter + * on (undefined, empty, or only commas/whitespace) — callers should skip + * adding the condition entirely in that case, same as today's single-value + * behavior for a missing/`'all'` filter. + * + * A single value (no commas) still produces an `IN (?)` clause, which SQLite + * evaluates identically to `= ?` — so existing single-value filter behavior + * is unchanged. + */ +export function buildInCondition(column: string, value: string | undefined): { clause: string; params: string[] } | null { + if (!value) return null; + const values = value.split(',').map((v) => v.trim()).filter(Boolean); + if (values.length === 0) return null; + return { clause: `${column} IN (${values.map(() => '?').join(', ')})`, params: values }; +} + /** * Safely parse a JSON-encoded string field from SQLite. * Returns defaultValue if the field is null, empty, or invalid JSON. From 1b2e4c952aa9c20d8f1192f1c76fc9cf264e1728 Mon Sep 17 00:00:00 2001 From: tianyao Date: Tue, 21 Jul 2026 14:07:59 +0000 Subject: [PATCH 2/2] 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). --- .../filters/SourceToolMultiSelect.tsx | 80 +++++++++++++++++++ .../components/filters/SourceToolSelect.tsx | 65 --------------- .../src/components/sessions/ProjectNav.tsx | 30 +++---- .../components/sessions/SessionListPanel.tsx | 13 ++- dashboard/src/hooks/useFacets.ts | 14 +++- dashboard/src/pages/AnalyticsPage.tsx | 12 ++- dashboard/src/pages/InsightsPage.tsx | 18 +++-- dashboard/src/pages/JournalPage.tsx | 18 +++-- 8 files changed, 143 insertions(+), 107 deletions(-) create mode 100644 dashboard/src/components/filters/SourceToolMultiSelect.tsx delete mode 100644 dashboard/src/components/filters/SourceToolSelect.tsx diff --git a/dashboard/src/components/filters/SourceToolMultiSelect.tsx b/dashboard/src/components/filters/SourceToolMultiSelect.tsx new file mode 100644 index 00000000..b2063442 --- /dev/null +++ b/dashboard/src/components/filters/SourceToolMultiSelect.tsx @@ -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 = { + '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 ( + + + + + +
+ 選擇一或多個來源 + {value.length > 0 && } +
+
+ {sourceTools.map((tool) => ( + + ))} + {sourceTools.length === 0 &&

沒有可用的來源。

} +
+
+
+ ); +} diff --git a/dashboard/src/components/filters/SourceToolSelect.tsx b/dashboard/src/components/filters/SourceToolSelect.tsx deleted file mode 100644 index 8e4beb0f..00000000 --- a/dashboard/src/components/filters/SourceToolSelect.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { SOURCE_TOOL_COLORS } from '@/lib/constants/colors'; - -export const SOURCE_TOOLS = [ - { value: 'claude-code', label: 'Claude Code' }, - { value: 'cursor', label: 'Cursor' }, - { value: 'codex-cli', label: 'Codex CLI' }, - { value: 'copilot-cli', label: 'Copilot CLI' }, - { value: 'copilot', label: 'Copilot' }, - { value: 'opencode', label: 'OpenCode' }, - { value: 'antigravity', label: 'Antigravity' }, - { value: 'crush', label: 'Crush' }, - { value: 'hermes-agent', label: 'Hermes Agent' }, - { value: 'mistral-vibe', label: 'Mistral Vibe' }, - { value: 'kilo', label: 'Kilo' }, -] as const; - -// 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 = { - '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', -}; - -interface SourceToolSelectProps { - value: string; - onValueChange: (value: string) => void; - className?: string; -} - -export function SourceToolSelect({ value, onValueChange, className }: SourceToolSelectProps) { - return ( - - ); -} diff --git a/dashboard/src/components/sessions/ProjectNav.tsx b/dashboard/src/components/sessions/ProjectNav.tsx index fa01af78..01c02d69 100644 --- a/dashboard/src/components/sessions/ProjectNav.tsx +++ b/dashboard/src/components/sessions/ProjectNav.tsx @@ -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, @@ -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'; @@ -40,6 +33,11 @@ export function ProjectNav({ const [editingProject, setEditingProject] = useState(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] @@ -136,17 +134,11 @@ export function ProjectNav({ {/* Source filter at bottom */}
- + onSelectSource(ids.length > 0 ? ids.join(',') : 'all')} + className="h-8 text-xs w-full" + />
{editingProject && ( diff --git a/dashboard/src/components/sessions/SessionListPanel.tsx b/dashboard/src/components/sessions/SessionListPanel.tsx index 32acff1e..ad685f28 100644 --- a/dashboard/src/components/sessions/SessionListPanel.tsx +++ b/dashboard/src/components/sessions/SessionListPanel.tsx @@ -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'; @@ -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 @@ -332,9 +337,9 @@ export function SessionListPanel({ {/* Row 4: Source + Home + Save */}
- onFilterChange('source', v)} + onFilterChange('source', ids.length > 0 ? ids.join(',') : 'all')} className="h-7 text-xs flex-1 min-w-0" /> diff --git a/dashboard/src/hooks/useFacets.ts b/dashboard/src/hooks/useFacets.ts index af7cee5d..cbd33b4e 100644 --- a/dashboard/src/hooks/useFacets.ts +++ b/dashboard/src/hooks/useFacets.ts @@ -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; @@ -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({ diff --git a/dashboard/src/pages/AnalyticsPage.tsx b/dashboard/src/pages/AnalyticsPage.tsx index 2e54dcbb..2bda5823 100644 --- a/dashboard/src/pages/AnalyticsPage.tsx +++ b/dashboard/src/pages/AnalyticsPage.tsx @@ -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, @@ -37,6 +37,10 @@ export default function AnalyticsPage() { const [range, setRange] = useState('7d'); const [source, setSource] = useState('all'); const [homeId, setHomeId] = useState('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 }), @@ -268,9 +272,9 @@ export default function AnalyticsPage() { ))}
- setSource(ids.length > 0 ? ids.join(',') : 'all')} className="w-[140px] h-7 text-xs" /> 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; @@ -298,9 +302,9 @@ 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); @@ -308,7 +312,7 @@ export default function InsightsPage() { } 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'; @@ -455,9 +459,9 @@ export default function InsightsPage() { onValueChange={(ids) => setFilter('project', ids.length > 0 ? ids.join(',') : 'all')} /> - setFilter('source', v)} + setFilter('source', ids.length > 0 ? ids.join(',') : 'all')} className="w-[140px]" /> diff --git a/dashboard/src/pages/JournalPage.tsx b/dashboard/src/pages/JournalPage.tsx index 15520d9b..0ebb7636 100644 --- a/dashboard/src/pages/JournalPage.tsx +++ b/dashboard/src/pages/JournalPage.tsx @@ -9,7 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Sparkles, Target, Lightbulb, GitBranch, Clock } from 'lucide-react'; import { Link } from 'react-router'; import { ErrorCard } from '@/components/ErrorCard'; -import { SourceToolSelect } from '@/components/filters/SourceToolSelect'; +import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect'; import { HomeSelect } from '@/components/filters/HomeSelect'; import type { Insight } from '@/lib/types'; @@ -37,6 +37,10 @@ function getWeekLabel(weekKey: string): string { export default function JournalPage() { const [source, setSource] = useState('all'); + const selectedSourceTools = useMemo( + () => source === 'all' ? [] : source.split(',').filter(Boolean), + [source] + ); const [homeId, setHomeId] = useState('all'); const { data: insights = [], isLoading, isError, refetch } = useInsights(); // limit: 500 matches Analytics page pattern; server default is 50 which would silently miss sessions @@ -67,9 +71,9 @@ export default function JournalPage() { const insightsByWeek = useMemo(() => { const relevant = insights.filter((i) => { if (i.type !== 'learning' && i.type !== 'decision' && i.type !== 'technique') return false; - if (source !== 'all') { + if (selectedSourceTools.length > 0) { const sourceTool = sessionSourceMap.get(i.session_id); - if (sourceTool !== source) return false; + if (!sourceTool || !selectedSourceTools.includes(sourceTool)) return false; } if (homeId !== 'all') { const sessionHomeId = sessionHomeMap.get(i.session_id); @@ -84,7 +88,7 @@ export default function JournalPage() { grouped[weekKey].push(insight); }); return grouped; - }, [insights, source, sessionSourceMap, homeId, sessionHomeMap]); + }, [insights, selectedSourceTools, sessionSourceMap, homeId, sessionHomeMap]); const sortedWeeks = useMemo( () => Object.keys(insightsByWeek).sort((a, b) => b.localeCompare(a)), @@ -101,9 +105,9 @@ export default function JournalPage() {

- setSource(ids.length > 0 ? ids.join(',') : 'all')} className="w-[140px] h-8 text-xs" />