feat: add options for bypassing cache in session and subagent detail retrieval

- Updated handleGetSessionDetail and handleGetSubagentDetail functions to accept an optional options parameter for bypassing cache.
- Modified IPC and API methods to support the new options parameter, enhancing flexibility in data retrieval.
- Adjusted MemberLogsTab to utilize the bypassCache option when fetching log details, improving data freshness.
This commit is contained in:
iliya 2026-03-06 10:40:45 +02:00
parent 297780e3a8
commit b093e87c89
7 changed files with 134 additions and 41 deletions

View file

@ -198,7 +198,8 @@ async function handleGetSessionsByIds(
async function handleGetSessionDetail( async function handleGetSessionDetail(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
projectId: string, projectId: string,
sessionId: string sessionId: string,
options?: { bypassCache?: boolean }
): Promise<SessionDetail | null> { ): Promise<SessionDetail | null> {
try { try {
const validatedProject = validateProjectId(projectId); const validatedProject = validateProjectId(projectId);
@ -220,7 +221,7 @@ async function handleGetSessionDetail(
// Check cache first // Check cache first
let sessionDetail = dataCache.get(cacheKey); let sessionDetail = dataCache.get(cacheKey);
if (sessionDetail) { if (sessionDetail && !options?.bypassCache) {
return sessionDetail; return sessionDetail;
} }

View file

@ -56,7 +56,8 @@ async function handleGetSubagentDetail(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
projectId: string, projectId: string,
sessionId: string, sessionId: string,
subagentId: string subagentId: string,
options?: { bypassCache?: boolean }
): Promise<SubagentDetail | null> { ): Promise<SubagentDetail | null> {
try { try {
const validatedProject = validateProjectId(projectId); const validatedProject = validateProjectId(projectId);
@ -85,7 +86,7 @@ async function handleGetSubagentDetail(
// Check cache first // Check cache first
let subagentDetail = dataCache.getSubagent(cacheKey); let subagentDetail = dataCache.getSubagent(cacheKey);
if (subagentDetail) { if (subagentDetail && !options?.bypassCache) {
return subagentDetail; return subagentDetail;
} }

View file

@ -300,30 +300,61 @@ export class TeamDataService {
}); });
} }
// Enrich inbox messages without leadSessionId by propagating from neighboring // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
// messages that have it (lead_session, user_sent). Sort chronologically (asc), // session ID (by timestamp). This avoids the old forward-only propagation bug where
// sweep forward, then sweep backward so orphans at the start also get a session. // messages between two sessions always inherited the *earlier* session, causing a
// spurious "New session" divider even when the message is chronologically closer to
// the later session.
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
// Forward pass: propagate leadSessionId from earlier messages to later ones
let currentSessionId: string | undefined; // Collect indices of messages that already have a leadSessionId (anchors).
for (const msg of messages) { const anchors: { index: number; time: number; sessionId: string }[] = [];
if (msg.leadSessionId) { for (let i = 0; i < messages.length; i++) {
currentSessionId = msg.leadSessionId; if (messages[i].leadSessionId) {
} else if (currentSessionId) { anchors.push({
msg.leadSessionId = currentSessionId; index: i,
time: Date.parse(messages[i].timestamp),
sessionId: messages[i].leadSessionId!,
});
} }
} }
// Backward pass: fill messages before the first known session.
// Seed with config.leadSessionId so that recent messages without an explicit if (anchors.length > 0) {
// session ID inherit the current (most recent) session — this ensures that // For each message without leadSessionId, find the closest anchor by timestamp
// session boundary separators work even when inbox entries lack the field. // and inherit its sessionId.
currentSessionId = config.leadSessionId; let anchorIdx = 0;
for (let i = messages.length - 1; i >= 0; i--) { for (let i = 0; i < messages.length; i++) {
if (messages[i].leadSessionId) { if (messages[i].leadSessionId) {
currentSessionId = messages[i].leadSessionId; // Advance anchorIdx to track current position for efficient lookup
} else if (currentSessionId) { while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) {
messages[i].leadSessionId = currentSessionId; anchorIdx++;
}
continue;
}
const msgTime = Date.parse(messages[i].timestamp);
// Find closest anchor by timestamp (binary-search-like scan from current position)
let bestAnchor = anchors[0];
let bestDist = Math.abs(msgTime - bestAnchor.time);
for (let a = 0; a < anchors.length; a++) {
const dist = Math.abs(msgTime - anchors[a].time);
if (dist < bestDist) {
bestDist = dist;
bestAnchor = anchors[a];
} else if (dist > bestDist && anchors[a].time > msgTime) {
// Anchors are sorted by index (asc time) — once distance grows past the
// message time, further anchors will only be farther.
break;
}
}
messages[i].leadSessionId = bestAnchor.sessionId;
}
} else if (config.leadSessionId) {
// No anchors at all — fall back to config.leadSessionId for everything.
for (const msg of messages) {
msg.leadSessionId = config.leadSessionId;
} }
} }
} }

View file

@ -347,14 +347,18 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke('search-sessions', projectId, query, maxResults), ipcRenderer.invoke('search-sessions', projectId, query, maxResults),
searchAllProjects: (query: string, maxResults?: number) => searchAllProjects: (query: string, maxResults?: number) =>
ipcRenderer.invoke('search-all-projects', query, maxResults), ipcRenderer.invoke('search-all-projects', query, maxResults),
getSessionDetail: (projectId: string, sessionId: string) => getSessionDetail: (projectId: string, sessionId: string, options?: { bypassCache?: boolean }) =>
ipcRenderer.invoke('get-session-detail', projectId, sessionId), ipcRenderer.invoke('get-session-detail', projectId, sessionId, options),
getSessionMetrics: (projectId: string, sessionId: string) => getSessionMetrics: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-session-metrics', projectId, sessionId), ipcRenderer.invoke('get-session-metrics', projectId, sessionId),
getWaterfallData: (projectId: string, sessionId: string) => getWaterfallData: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-waterfall-data', projectId, sessionId), ipcRenderer.invoke('get-waterfall-data', projectId, sessionId),
getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) => getSubagentDetail: (
ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId), projectId: string,
sessionId: string,
subagentId: string,
options?: { bypassCache?: boolean }
) => ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId, options),
getSessionGroups: (projectId: string, sessionId: string) => getSessionGroups: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-session-groups', projectId, sessionId), ipcRenderer.invoke('get-session-groups', projectId, sessionId),
getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) => getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) =>

View file

@ -246,7 +246,11 @@ export class HttpAPIClient implements ElectronAPI {
return this.get<SearchSessionsResult>(`/api/search?${params}`); return this.get<SearchSessionsResult>(`/api/search?${params}`);
}; };
getSessionDetail = (projectId: string, sessionId: string): Promise<SessionDetail | null> => getSessionDetail = (
projectId: string,
sessionId: string,
_options?: { bypassCache?: boolean }
): Promise<SessionDetail | null> =>
this.get<SessionDetail | null>( this.get<SessionDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}` `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
); );
@ -264,7 +268,8 @@ export class HttpAPIClient implements ElectronAPI {
getSubagentDetail = ( getSubagentDetail = (
projectId: string, projectId: string,
sessionId: string, sessionId: string,
subagentId: string subagentId: string,
_options?: { bypassCache?: boolean }
): Promise<SubagentDetail | null> => ): Promise<SubagentDetail | null> =>
this.get<SubagentDetail | null>( this.get<SubagentDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}` `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`

View file

@ -57,6 +57,7 @@ export const MemberLogsTab = ({
showLeadPreview = false, showLeadPreview = false,
onPreviewOnlineChange, onPreviewOnlineChange,
}: MemberLogsTabProps): React.JSX.Element => { }: MemberLogsTabProps): React.JSX.Element => {
const MIN_REFRESH_VISIBLE_MS = 250;
const intervalsKey = useMemo( const intervalsKey = useMemo(
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
[taskWorkIntervals] [taskWorkIntervals]
@ -68,6 +69,8 @@ export const MemberLogsTab = ({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const refreshCountRef = useRef(0); const refreshCountRef = useRef(0);
const refreshBeganAtRef = useRef<number | null>(null);
const refreshHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const expandedIdRef = useRef<string | null>(null); const expandedIdRef = useRef<string | null>(null);
@ -78,6 +81,10 @@ export const MemberLogsTab = ({
useEffect(() => { useEffect(() => {
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false;
if (refreshHideTimeoutRef.current) {
clearTimeout(refreshHideTimeoutRef.current);
refreshHideTimeoutRef.current = null;
}
}; };
}, []); }, []);
@ -86,13 +93,40 @@ export const MemberLogsTab = ({
}, [expandedId]); }, [expandedId]);
const beginRefreshing = useCallback((): void => { const beginRefreshing = useCallback((): void => {
if (refreshCountRef.current === 0) {
refreshBeganAtRef.current = Date.now();
if (refreshHideTimeoutRef.current) {
clearTimeout(refreshHideTimeoutRef.current);
refreshHideTimeoutRef.current = null;
}
}
refreshCountRef.current += 1; refreshCountRef.current += 1;
if (isMountedRef.current) setRefreshing(true); if (isMountedRef.current) setRefreshing(true);
}, []); }, []);
const endRefreshing = useCallback((): void => { const endRefreshing = useCallback((): void => {
refreshCountRef.current = Math.max(0, refreshCountRef.current - 1); refreshCountRef.current = Math.max(0, refreshCountRef.current - 1);
if (isMountedRef.current) setRefreshing(refreshCountRef.current > 0); if (refreshCountRef.current > 0) {
if (isMountedRef.current) setRefreshing(true);
return;
}
const beganAt = refreshBeganAtRef.current;
refreshBeganAtRef.current = null;
const elapsed = beganAt ? Date.now() - beganAt : Number.POSITIVE_INFINITY;
if (!isMountedRef.current) return;
if (elapsed >= MIN_REFRESH_VISIBLE_MS) {
setRefreshing(false);
return;
}
const remaining = Math.max(0, MIN_REFRESH_VISIBLE_MS - elapsed);
refreshHideTimeoutRef.current = setTimeout(() => {
refreshHideTimeoutRef.current = null;
if (!isMountedRef.current) return;
if (refreshCountRef.current === 0) setRefreshing(false);
}, remaining);
}, []); }, []);
const getRowId = useCallback((log: MemberLogSummary): string => { const getRowId = useCallback((log: MemberLogSummary): string => {
@ -258,12 +292,20 @@ export const MemberLogsTab = ({
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
const fetchDetailForLog = useCallback( const fetchDetailForLog = useCallback(
async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => { async (
log: MemberLogSummary,
options?: { bypassCache?: boolean }
): Promise<EnhancedChunk[] | null> => {
if (log.kind === 'subagent') { if (log.kind === 'subagent') {
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); const d = await api.getSubagentDetail(
log.projectId,
log.sessionId,
log.subagentId,
options
);
return d?.chunks ?? null; return d?.chunks ?? null;
} }
const d = await api.getSessionDetail(log.projectId, log.sessionId); const d = await api.getSessionDetail(log.projectId, log.sessionId, options);
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null; return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
}, },
[] []
@ -281,7 +323,7 @@ export const MemberLogsTab = ({
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return; if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return;
const next = await fetchDetailForLog(nextExpanded); const next = await fetchDetailForLog(nextExpanded, { bypassCache: true });
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
// Ensure new reference so memoized transforms update. // Ensure new reference so memoized transforms update.
setDetailChunks(next ? [...next] : null); setDetailChunks(next ? [...next] : null);
@ -327,7 +369,7 @@ export const MemberLogsTab = ({
const interval = setInterval(async () => { const interval = setInterval(async () => {
beginRefreshing(); beginRefreshing();
try { try {
const next = await fetchDetailForLog(previewLog); const next = await fetchDetailForLog(previewLog, { bypassCache: true });
if (cancelled) return; if (cancelled) return;
setPreviewChunks(next ? [...next] : null); setPreviewChunks(next ? [...next] : null);
} catch { } catch {
@ -363,7 +405,7 @@ export const MemberLogsTab = ({
const refreshDetail = async (): Promise<void> => { const refreshDetail = async (): Promise<void> => {
beginRefreshing(); beginRefreshing();
try { try {
const next = await fetchDetailForLog(expandedLogSummary); const next = await fetchDetailForLog(expandedLogSummary, { bypassCache: true });
if (cancelled) return; if (cancelled) return;
// Ensure new reference so memoized transforms update. // Ensure new reference so memoized transforms update.
setDetailChunks(next ? [...next] : null); setDetailChunks(next ? [...next] : null);
@ -395,7 +437,11 @@ export const MemberLogsTab = ({
setDetailChunks(null); setDetailChunks(null);
setDetailLoading(true); setDetailLoading(true);
try { try {
const chunks = await fetchDetailForLog(log); const shouldBypassCache = log.isOngoing || taskStatus === 'in_progress';
const chunks = await fetchDetailForLog(
log,
shouldBypassCache ? { bypassCache: true } : undefined
);
setDetailChunks(chunks ? [...chunks] : null); setDetailChunks(chunks ? [...chunks] : null);
} catch { } catch {
setDetailChunks(null); setDetailChunks(null);
@ -403,7 +449,7 @@ export const MemberLogsTab = ({
setDetailLoading(false); setDetailLoading(false);
} }
}, },
[expandedId, fetchDetailForLog, getRowId] [expandedId, fetchDetailForLog, getRowId, taskStatus]
); );
if (loading && logs.length === 0) { if (loading && logs.length === 0) {

View file

@ -614,13 +614,18 @@ export interface ElectronAPI {
maxResults?: number maxResults?: number
) => Promise<SearchSessionsResult>; ) => Promise<SearchSessionsResult>;
searchAllProjects: (query: string, maxResults?: number) => Promise<SearchSessionsResult>; searchAllProjects: (query: string, maxResults?: number) => Promise<SearchSessionsResult>;
getSessionDetail: (projectId: string, sessionId: string) => Promise<SessionDetail | null>; getSessionDetail: (
projectId: string,
sessionId: string,
options?: { bypassCache?: boolean }
) => Promise<SessionDetail | null>;
getSessionMetrics: (projectId: string, sessionId: string) => Promise<SessionMetrics | null>; getSessionMetrics: (projectId: string, sessionId: string) => Promise<SessionMetrics | null>;
getWaterfallData: (projectId: string, sessionId: string) => Promise<WaterfallData | null>; getWaterfallData: (projectId: string, sessionId: string) => Promise<WaterfallData | null>;
getSubagentDetail: ( getSubagentDetail: (
projectId: string, projectId: string,
sessionId: string, sessionId: string,
subagentId: string subagentId: string,
options?: { bypassCache?: boolean }
) => Promise<SubagentDetail | null>; ) => Promise<SubagentDetail | null>;
getSessionGroups: (projectId: string, sessionId: string) => Promise<ConversationGroup[]>; getSessionGroups: (projectId: string, sessionId: string) => Promise<ConversationGroup[]>;
getSessionsByIds: ( getSessionsByIds: (