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
8 changes: 4 additions & 4 deletions cli/src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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: '',
Expand All @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions cli/src/providers/copilot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 6 additions & 4 deletions dashboard/src/components/sessions/SessionListPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>() } = useAnalyzedSessionIds();

const insightCountsBySession = useMemo(() => {
const map = new Map<string, Record<string, number>>();
Expand Down
15 changes: 15 additions & 0 deletions dashboard/src/hooks/useAnalyzedSessionIds.ts
Original file line number Diff line number Diff line change
@@ -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)),
});
}
6 changes: 6 additions & 0 deletions dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,12 @@ export function fetchAnalysisQueue() {
return request<AnalysisQueueStatus>('/analysis/queue');
}

// ── Analyzed session IDs ───────────────────────────────────────────────────────

export function fetchAnalyzedSessionIds() {
return request<{ sessionIds: string[] }>('/analysis/analyzed-session-ids');
}

// ── Facets ─────────────────────────────────────────────────────────────────────

export interface FacetRow {
Expand Down
14 changes: 10 additions & 4 deletions dashboard/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,19 +35,24 @@ 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, {
month: 'long',
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
Expand Down
27 changes: 27 additions & 0 deletions server/src/routes/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
AnalysisResultSchema,
AnalysisUsageResponseSchema,
AnalysisUsageQuerySchema,
AnalyzedSessionIdsResponseSchema,
RecurringInsightResultSchema,
} from '../schemas/analysis.js';
import {
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions server/src/schemas/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading