feat: isolate Claude and Codex session sources
Persist platform_source across session creation, transcript ingestion, API query paths, and viewer state so Claude and Codex data can coexist without bleeding into each other. - add platform-source normalization helpers and persist platform_source in sdk_sessions via migration 24 with backfill and indexing - thread platformSource through CLI hooks, transcript processing, context generation, pagination, search routes, SSE payloads, and session management - expose source-aware project catalogs, viewer tabs, context preview selectors, and source badges for observations, prompts, and summaries - start the transcript watcher from the worker for transcript-based clients and preserve platform source during Codex ingestion - auto-start the worker from the MCP server for MCP-only clients and tighten stdio-driven cleanup during shutdown - keep createSDKSession backward compatible with existing custom-title callers while allowing explicit platform source forwarding
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Settings } from '../types';
|
||||
import type { ProjectCatalog, Settings } from '../types';
|
||||
|
||||
interface UseContextPreviewResult {
|
||||
preview: string;
|
||||
@@ -7,15 +7,31 @@ interface UseContextPreviewResult {
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
projects: string[];
|
||||
sources: string[];
|
||||
selectedSource: string | null;
|
||||
setSelectedSource: (source: string) => void;
|
||||
selectedProject: string | null;
|
||||
setSelectedProject: (project: string) => void;
|
||||
}
|
||||
|
||||
function getPreferredSource(sources: string[]): string | null {
|
||||
if (sources.includes('claude')) return 'claude';
|
||||
if (sources.includes('codex')) return 'codex';
|
||||
return sources[0] || null;
|
||||
}
|
||||
|
||||
function withDefaultSources(sources: string[]): string[] {
|
||||
const merged = ['claude', 'codex', ...sources];
|
||||
return Array.from(new Set(merged));
|
||||
}
|
||||
|
||||
export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
const [preview, setPreview] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [catalog, setCatalog] = useState<ProjectCatalog>({ projects: [], sources: [], projectsBySource: {} });
|
||||
const [projects, setProjects] = useState<string[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||
|
||||
// Fetch projects on mount
|
||||
@@ -23,11 +39,27 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
const data = await response.json();
|
||||
if (data.projects && data.projects.length > 0) {
|
||||
setProjects(data.projects);
|
||||
setSelectedProject(data.projects[0]); // Default to first project
|
||||
const data = await response.json() as ProjectCatalog;
|
||||
const nextCatalog: ProjectCatalog = {
|
||||
projects: data.projects || [],
|
||||
sources: withDefaultSources(data.sources || []),
|
||||
projectsBySource: data.projectsBySource || {}
|
||||
};
|
||||
|
||||
setCatalog(nextCatalog);
|
||||
|
||||
const preferredSource = getPreferredSource(nextCatalog.sources);
|
||||
setSelectedSource(preferredSource);
|
||||
|
||||
if (preferredSource) {
|
||||
const sourceProjects = nextCatalog.projectsBySource[preferredSource] || [];
|
||||
setProjects(sourceProjects);
|
||||
setSelectedProject(sourceProjects[0] || null);
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects(nextCatalog.projects);
|
||||
setSelectedProject(nextCatalog.projects[0] || null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch projects:', err);
|
||||
}
|
||||
@@ -35,6 +67,18 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSource) {
|
||||
setProjects(catalog.projects);
|
||||
setSelectedProject(prev => (prev && catalog.projects.includes(prev) ? prev : catalog.projects[0] || null));
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceProjects = catalog.projectsBySource[selectedSource] || [];
|
||||
setProjects(sourceProjects);
|
||||
setSelectedProject(prev => (prev && sourceProjects.includes(prev) ? prev : sourceProjects[0] || null));
|
||||
}, [catalog, selectedSource]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!selectedProject) {
|
||||
setPreview('No project selected');
|
||||
@@ -48,6 +92,10 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
project: selectedProject
|
||||
});
|
||||
|
||||
if (selectedSource) {
|
||||
params.append('platformSource', selectedSource);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/context/preview?${params}`);
|
||||
const text = await response.text();
|
||||
|
||||
@@ -58,7 +106,7 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [selectedProject]);
|
||||
}, [selectedProject, selectedSource]);
|
||||
|
||||
// Debounced refresh when settings or selectedProject change
|
||||
useEffect(() => {
|
||||
@@ -68,5 +116,16 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [settings, refresh]);
|
||||
|
||||
return { preview, isLoading, error, refresh, projects, selectedProject, setSelectedProject };
|
||||
return {
|
||||
preview,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
projects,
|
||||
sources: catalog.sources,
|
||||
selectedSource,
|
||||
setSelectedSource,
|
||||
selectedProject,
|
||||
setSelectedProject
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type DataItem = Observation | Summary | UserPrompt;
|
||||
/**
|
||||
* Generic pagination hook for observations, summaries, and prompts
|
||||
*/
|
||||
function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: string) {
|
||||
function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: string, currentSource: string) {
|
||||
const [state, setState] = useState<PaginationState>({
|
||||
isLoading: false,
|
||||
hasMore: true
|
||||
@@ -22,7 +22,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
|
||||
// Track offset and filter in refs to handle synchronous resets
|
||||
const offsetRef = useRef(0);
|
||||
const lastFilterRef = useRef(currentFilter);
|
||||
const lastSelectionRef = useRef(`${currentSource}::${currentFilter}`);
|
||||
const stateRef = useRef(state);
|
||||
|
||||
/**
|
||||
@@ -31,16 +31,17 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
*/
|
||||
const loadMore = useCallback(async (): Promise<DataItem[]> => {
|
||||
// Check if filter changed - if so, reset pagination synchronously
|
||||
const filterChanged = lastFilterRef.current !== currentFilter;
|
||||
const selectionKey = `${currentSource}::${currentFilter}`;
|
||||
const filterChanged = lastSelectionRef.current !== selectionKey;
|
||||
|
||||
if (filterChanged) {
|
||||
offsetRef.current = 0;
|
||||
lastFilterRef.current = currentFilter;
|
||||
lastSelectionRef.current = selectionKey;
|
||||
|
||||
// Reset state both in React state and ref synchronously
|
||||
const newState = { isLoading: false, hasMore: true };
|
||||
setState(newState);
|
||||
stateRef.current = newState; // Update ref immediately to avoid stale checks
|
||||
stateRef.current = newState; // Update ref immediately to avoid stale checks
|
||||
}
|
||||
|
||||
// Prevent concurrent requests using ref (always current)
|
||||
@@ -49,6 +50,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
return [];
|
||||
}
|
||||
|
||||
stateRef.current = { ...stateRef.current, isLoading: true };
|
||||
setState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
// Build query params using current offset from ref
|
||||
@@ -62,6 +64,10 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
params.append('project', currentFilter);
|
||||
}
|
||||
|
||||
if (currentSource && currentSource !== 'all') {
|
||||
params.append('platformSource', currentSource);
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -70,6 +76,13 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
|
||||
const data = await response.json() as { items: DataItem[], hasMore: boolean };
|
||||
|
||||
const nextState = {
|
||||
...stateRef.current,
|
||||
isLoading: false,
|
||||
hasMore: data.hasMore
|
||||
};
|
||||
stateRef.current = nextState;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
@@ -80,7 +93,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
offsetRef.current += UI.PAGINATION_PAGE_SIZE;
|
||||
|
||||
return data.items;
|
||||
}, [currentFilter, endpoint, dataType]);
|
||||
}, [currentFilter, currentSource, endpoint, dataType]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -91,10 +104,10 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
/**
|
||||
* Hook for paginating observations
|
||||
*/
|
||||
export function usePagination(currentFilter: string) {
|
||||
const observations = usePaginationFor(API_ENDPOINTS.OBSERVATIONS, 'observations', currentFilter);
|
||||
const summaries = usePaginationFor(API_ENDPOINTS.SUMMARIES, 'summaries', currentFilter);
|
||||
const prompts = usePaginationFor(API_ENDPOINTS.PROMPTS, 'prompts', currentFilter);
|
||||
export function usePagination(currentFilter: string, currentSource: string) {
|
||||
const observations = usePaginationFor(API_ENDPOINTS.OBSERVATIONS, 'observations', currentFilter, currentSource);
|
||||
const summaries = usePaginationFor(API_ENDPOINTS.SUMMARIES, 'summaries', currentFilter, currentSource);
|
||||
const prompts = usePaginationFor(API_ENDPOINTS.PROMPTS, 'prompts', currentFilter, currentSource);
|
||||
|
||||
return {
|
||||
observations,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Observation, Summary, UserPrompt, StreamEvent } from '../types';
|
||||
import { Observation, Summary, UserPrompt, StreamEvent, ProjectCatalog } from '../types';
|
||||
import { API_ENDPOINTS } from '../constants/api';
|
||||
import { TIMING } from '../constants/timing';
|
||||
|
||||
@@ -7,16 +7,42 @@ export function useSSE() {
|
||||
const [observations, setObservations] = useState<Observation[]>([]);
|
||||
const [summaries, setSummaries] = useState<Summary[]>([]);
|
||||
const [prompts, setPrompts] = useState<UserPrompt[]>([]);
|
||||
const [projects, setProjects] = useState<string[]>([]);
|
||||
const [catalog, setCatalog] = useState<ProjectCatalog>({
|
||||
projects: [],
|
||||
sources: [],
|
||||
projectsBySource: {}
|
||||
});
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [queueDepth, setQueueDepth] = useState(0);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const updateCatalogForItem = (project: string, platformSource: string) => {
|
||||
setCatalog(prev => {
|
||||
const nextProjects = prev.projects.includes(project)
|
||||
? prev.projects
|
||||
: [...prev.projects, project];
|
||||
const nextSources = prev.sources.includes(platformSource)
|
||||
? prev.sources
|
||||
: [...prev.sources, platformSource];
|
||||
const sourceProjects = prev.projectsBySource[platformSource] || [];
|
||||
|
||||
return {
|
||||
projects: nextProjects,
|
||||
sources: nextSources,
|
||||
projectsBySource: {
|
||||
...prev.projectsBySource,
|
||||
[platformSource]: sourceProjects.includes(project)
|
||||
? sourceProjects
|
||||
: [...sourceProjects, project]
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const connect = () => {
|
||||
// Clean up existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
@@ -27,7 +53,6 @@ export function useSSE() {
|
||||
eventSource.onopen = () => {
|
||||
console.log('[SSE] Connected');
|
||||
setIsConnected(true);
|
||||
// Clear any pending reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
@@ -38,9 +63,8 @@ export function useSSE() {
|
||||
setIsConnected(false);
|
||||
eventSource.close();
|
||||
|
||||
// Reconnect after delay
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectTimeoutRef.current = undefined; // Clear before reconnecting
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
console.log('[SSE] Attempting to reconnect...');
|
||||
connect();
|
||||
}, TIMING.SSE_RECONNECT_DELAY_MS);
|
||||
@@ -52,32 +76,37 @@ export function useSSE() {
|
||||
switch (data.type) {
|
||||
case 'initial_load':
|
||||
console.log('[SSE] Initial load:', {
|
||||
projects: data.projects?.length || 0
|
||||
projects: data.projects?.length || 0,
|
||||
sources: data.sources?.length || 0
|
||||
});
|
||||
setCatalog({
|
||||
projects: data.projects || [],
|
||||
sources: data.sources || [],
|
||||
projectsBySource: data.projectsBySource || {}
|
||||
});
|
||||
// Only load projects list - data will come via pagination
|
||||
setProjects(data.projects || []);
|
||||
break;
|
||||
|
||||
case 'new_observation':
|
||||
if (data.observation) {
|
||||
console.log('[SSE] New observation:', data.observation.id);
|
||||
setObservations(prev => [data.observation, ...prev]);
|
||||
updateCatalogForItem(data.observation.project, data.observation.platform_source || 'claude');
|
||||
setObservations(prev => [data.observation!, ...prev]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new_summary':
|
||||
if (data.summary) {
|
||||
const summary = data.summary;
|
||||
console.log('[SSE] New summary:', summary.id);
|
||||
setSummaries(prev => [summary, ...prev]);
|
||||
console.log('[SSE] New summary:', data.summary.id);
|
||||
updateCatalogForItem(data.summary.project, data.summary.platform_source || 'claude');
|
||||
setSummaries(prev => [data.summary!, ...prev]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new_prompt':
|
||||
if (data.prompt) {
|
||||
const prompt = data.prompt;
|
||||
console.log('[SSE] New prompt:', prompt.id);
|
||||
setPrompts(prev => [prompt, ...prev]);
|
||||
console.log('[SSE] New prompt:', data.prompt.id);
|
||||
updateCatalogForItem(data.prompt.project, data.prompt.platform_source || 'claude');
|
||||
setPrompts(prev => [data.prompt!, ...prev]);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -94,7 +123,6 @@ export function useSSE() {
|
||||
|
||||
connect();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
@@ -105,5 +133,15 @@ export function useSSE() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { observations, summaries, prompts, projects, isProcessing, queueDepth, isConnected };
|
||||
return {
|
||||
observations,
|
||||
summaries,
|
||||
prompts,
|
||||
projects: catalog.projects,
|
||||
sources: catalog.sources,
|
||||
projectsBySource: catalog.projectsBySource,
|
||||
isProcessing,
|
||||
queueDepth,
|
||||
isConnected
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user