diff --git a/cli/src/providers/codex.ts b/cli/src/providers/codex.ts index 84edb06e..0f95d950 100644 --- a/cli/src/providers/codex.ts +++ b/cli/src/providers/codex.ts @@ -231,7 +231,7 @@ function parseFormatA(content: string): ParsedSession | null { } : null; messages.push({ - id: `codex-assistant-${messages.length}`, + id: `${sessionId}:assistant-${messages.length}`, sessionId: sessionId, type: 'assistant', content: text.slice(0, 10000), @@ -306,7 +306,7 @@ function parseFormatA(content: string): ParsedSession | null { const msgText = (payload.message as string) || ''; if (msgText && !isSystemContextMessage(msgText)) { messages.push({ - id: (payload.id as string) || `codex-user-${messages.length}`, + id: `${sessionId}:${(payload.id as string) || `user-${messages.length}`}`, sessionId: sessionId, type: 'user', content: msgText.slice(0, 10000), @@ -522,7 +522,7 @@ function parseFormatB(content: string): ParsedSession | null { if (currentToolCalls.length === 0 && !currentThinking) return; messages.push({ - id: `codex-assistant-${messages.length}`, + id: `${sessionId}:assistant-${messages.length}`, sessionId: sessionId, type: 'assistant', content: '', @@ -548,7 +548,7 @@ function parseFormatB(content: string): ParsedSession | null { const userContent = extractFormatBContent(item.content); if (userContent && !isSystemContextMessage(userContent)) { messages.push({ - id: `codex-user-${messages.length}`, + id: `${sessionId}:user-${messages.length}`, sessionId: sessionId, type: 'user', content: userContent.slice(0, 10000), diff --git a/cli/src/providers/copilot-cli.ts b/cli/src/providers/copilot-cli.ts index 9a64e33d..7f199da3 100644 --- a/cli/src/providers/copilot-cli.ts +++ b/cli/src/providers/copilot-cli.ts @@ -188,7 +188,7 @@ function parseCopilotSession(filePath: string): ParsedSession | null { if (!text && currentToolCalls.length === 0) return; messages.push({ - id: `copilot-assistant-${messages.length}`, + id: `${sessionId}:assistant-${messages.length}`, sessionId: sessionId, type: 'assistant', content: text.slice(0, 10000), @@ -251,7 +251,7 @@ function parseCopilotSession(filePath: string): ParsedSession | null { const userContent = extractText(data); if (userContent) { messages.push({ - id: (data.id as string) || `copilot-user-${messages.length}`, + id: `${sessionId}:${(data.id as string) || `user-${messages.length}`}`, sessionId: sessionId, type: 'user', content: userContent.slice(0, 10000), diff --git a/dashboard/src/components/sessions/SessionListPanel.tsx b/dashboard/src/components/sessions/SessionListPanel.tsx index 66738707..32acff1e 100644 --- a/dashboard/src/components/sessions/SessionListPanel.tsx +++ b/dashboard/src/components/sessions/SessionListPanel.tsx @@ -21,6 +21,7 @@ import { extractPQScore } from '@/lib/score-utils'; import { SearchX, Terminal, EyeOff, CalendarDays } from 'lucide-react'; import { useDeletedSessionCount } from '@/hooks/useSessions'; 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'; @@ -100,10 +101,11 @@ export function SessionListPanel({ const { data: deletedCount = 0 } = useDeletedSessionCount(projectId); const queuedSessionIds = useQueuedSessionIds(); - const analyzedSessionIds = useMemo( - () => new Set(insights.map((i) => i.session_id)), - [insights] - ); + // Sourced from analysis_usage, not `insights` — insights has no safe row cap to + // rely on for "is this session analyzed" at scale (a single session's analysis + // produces 5-10+ insight rows), so a capped insights query would silently + // misclassify already-analyzed sessions as unanalyzed on large histories. + const { data: analyzedSessionIds = new Set() } = useAnalyzedSessionIds(); const insightCountsBySession = useMemo(() => { const map = new Map>(); diff --git a/dashboard/src/hooks/useAnalyzedSessionIds.ts b/dashboard/src/hooks/useAnalyzedSessionIds.ts new file mode 100644 index 00000000..cb2dd221 --- /dev/null +++ b/dashboard/src/hooks/useAnalyzedSessionIds.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchAnalyzedSessionIds } from '@/lib/api'; + +/** + * Session IDs with a completed session analysis, sourced from analysis_usage + * (one row per session) rather than the insights table — insights has no row + * cap safe to rely on for "is this session analyzed" checks at scale, since a + * single session can produce 5-10+ insight rows. + */ +export function useAnalyzedSessionIds() { + return useQuery({ + queryKey: ['analyzedSessionIds'], + queryFn: () => fetchAnalyzedSessionIds().then((r) => new Set(r.sessionIds)), + }); +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 31260ffa..5893be62 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -552,6 +552,12 @@ export function fetchAnalysisQueue() { return request('/analysis/queue'); } +// ── Analyzed session IDs ─────────────────────────────────────────────────────── + +export function fetchAnalyzedSessionIds() { + return request<{ sessionIds: string[] }>('/analysis/analyzed-session-ids'); +} + // ── Facets ───────────────────────────────────────────────────────────────────── export interface FacetRow { diff --git a/dashboard/src/pages/DashboardPage.tsx b/dashboard/src/pages/DashboardPage.tsx index f80a172c..4e641d14 100644 --- a/dashboard/src/pages/DashboardPage.tsx +++ b/dashboard/src/pages/DashboardPage.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router'; import { useDashboardStats, useDailyStats } from '@/hooks/useAnalytics'; import { useSessions } from '@/hooks/useSessions'; import { useInsights } from '@/hooks/useInsights'; +import { useAnalyzedSessionIds } from '@/hooks/useAnalyzedSessionIds'; import { useProjects } from '@/hooks/useProjects'; import { StatsHero } from '@/components/dashboard/StatsHero'; import { DashboardActivityChart } from '@/components/dashboard/DashboardActivityChart'; @@ -34,9 +35,10 @@ export default function DashboardPage() { const { data: dailyStats = [], isLoading: dailyLoading, isError: dailyError, refetch: refetchDaily } = useDailyStats(range, effectiveHomeId); const { data: sessions = [], isLoading: sessionsLoading, isError: sessionsError, refetch: refetchSessions } = useSessions({ limit: 500, ...(homeId !== 'all' && { homeId }) }); const { data: insights = [], isLoading: insightsLoading } = useInsights(); + const { data: analyzedSessionIds, isLoading: analyzedIdsLoading } = useAnalyzedSessionIds(); const { data: projects = [] } = useProjects(); - const loading = statsLoading || sessionsLoading || insightsLoading || dailyLoading; + const loading = statsLoading || sessionsLoading || insightsLoading || dailyLoading || analyzedIdsLoading; const hasError = statsError || sessionsError || dailyError; const todayLabel = new Date().toLocaleDateString(undefined, { @@ -44,9 +46,13 @@ export default function DashboardPage() { day: 'numeric', }); - // Sessions not yet analyzed - const analyzedSessionIds = new Set(insights.map((i) => i.session_id)); - const unanalyzedSessions = sessions.filter((s) => !analyzedSessionIds.has(s.id)); + // Sessions not yet analyzed. Sourced from analysis_usage (via useAnalyzedSessionIds), + // not the insights list — insights has no safe row cap to rely on at scale (a single + // session's analysis produces 5-10+ insight rows), so a capped insights query would + // silently misclassify already-analyzed sessions as unanalyzed on large histories. + const unanalyzedSessions = analyzedSessionIds + ? sessions.filter((s) => !analyzedSessionIds.has(s.id)) + : []; // Compute stats for hero — all from dashStats (range-filtered) const totalTokens = dashStats diff --git a/server/src/routes/analysis.ts b/server/src/routes/analysis.ts index 10ff803b..041bfe2d 100644 --- a/server/src/routes/analysis.ts +++ b/server/src/routes/analysis.ts @@ -11,6 +11,7 @@ import { AnalysisResultSchema, AnalysisUsageResponseSchema, AnalysisUsageQuerySchema, + AnalyzedSessionIdsResponseSchema, RecurringInsightResultSchema, } from '../schemas/analysis.js'; import { @@ -100,6 +101,32 @@ app.openapi(usageRoute, (c) => { }, 200); }); +// GET /api/analysis/analyzed-session-ids +// Returns every session_id that has a completed 'session' analysis in analysis_usage. +// Used by the dashboard to determine which sessions still need analysis — deliberately +// sourced from analysis_usage (one row per session, PRIMARY KEY (session_id, analysis_type)) +// rather than the insights table, which has no such bound: a single session's analysis +// produces 5-10+ insight rows, so a capped/paginated insights query silently truncates +// on large histories and misclassifies already-analyzed sessions as unanalyzed. +const analyzedSessionIdsRoute = createRoute({ + method: 'get', + path: '/analyzed-session-ids', + responses: { + 200: { + content: { 'application/json': { schema: AnalyzedSessionIdsResponseSchema } }, + description: 'Session IDs with a completed session analysis', + }, + }, +}); + +app.openapi(analyzedSessionIdsRoute, (c) => { + const db = getDb(); + const rows = db.prepare( + `SELECT session_id FROM analysis_usage WHERE analysis_type = 'session'` + ).all() as Array<{ session_id: string }>; + return c.json({ sessionIds: rows.map((r) => r.session_id) }, 200); +}); + // POST /api/analysis/session // Body: { sessionId: string } // Fetches session + messages from SQLite, runs LLM analysis, saves insights, returns results. diff --git a/server/src/schemas/analysis.ts b/server/src/schemas/analysis.ts index 9efd50ce..8f751aec 100644 --- a/server/src/schemas/analysis.ts +++ b/server/src/schemas/analysis.ts @@ -83,6 +83,12 @@ export const AnalysisUsageQuerySchema = z.object({ export const SessionIdBodyResponseSchema = AnalysisResultSchema; +export const AnalyzedSessionIdsResponseSchema = z + .object({ + sessionIds: z.array(z.string()), + }) + .openapi('AnalyzedSessionIdsResponse'); + /** Mirrors server/src/llm/recurring-insights.ts RecurringInsightResult. */ export const RecurringInsightResultSchema = z .object({