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"
/>