From 684e12721306c55963b89be244c3d2df8432a4f8 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 8 May 2026 15:13:23 +0300 Subject: [PATCH] fix(chat): make provider message totals reflect what the user actually sees The session `total` value was diverging from the number of rendered chat rows, which created confusing UI states (for example: "showing 12 of 21" when only 12 messages exist visually). Why this was happening: - Providers were counting transport/normalized records, not renderable chat rows. - `tool_result` records are normalized and needed for tool wiring, but the UI does not render them as standalone bubbles; they are attached to their corresponding `tool_use`. - As a result, totals were inflated by implementation details in the history format rather than user-visible conversation content. Why this change: - `total` is a user-facing metric and should represent frontend-visible messages. - We need provider behavior to be consistent (Codex/Claude/Cursor/Gemini) so pagination labels, load-more affordances, and session stats match user expectations. - Correctness here is UX-critical: users interpret `total` as conversation message count, not internal event count. Implementation approach: - Replace post-hoc generic counting logic with explicit per-provider total trackers in each `fetchHistory` flow. - Increment totals during provider message processing so counting rules are owned by the provider pipeline itself. - Exclude `tool_result` from the total tracker since it is rendered as attached tool output, not as a standalone chat message. Behavioral impact: - `total` now aligns with rendered chat rows. - Pagination mechanics remain based on normalized history payloads, so loading behavior is unchanged while user-visible totals are corrected. - Tool result attachment behavior is preserved. Touched providers: - codex-sessions.provider.ts - claude-sessions.provider.ts - cursor-sessions.provider.ts - gemini-sessions.provider.ts --- .../list/claude/claude-sessions.provider.ts | 31 +++++++++++++---- .../list/codex/codex-sessions.provider.ts | 33 +++++++++++++++---- .../list/cursor/cursor-sessions.provider.ts | 12 +++++-- .../list/gemini/gemini-sessions.provider.ts | 8 ++++- .../chat/hooks/useChatSessionState.ts | 1 + 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index ffd358f3..5cd7f544 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -414,7 +414,9 @@ export class ClaudeSessionsProvider implements IProviderSessions { let result: ClaudeHistoryResult; try { - result = await getSessionMessages(sessionId, limit, offset); + // Load full history first so `total` reflects frontend-normalized messages, + // not raw JSONL records. + result = await getSessionMessages(sessionId, null, 0); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message); @@ -422,8 +424,6 @@ export class ClaudeSessionsProvider implements IProviderSessions { } const rawMessages = Array.isArray(result) ? result : (result.messages || []); - const total = Array.isArray(result) ? rawMessages.length : (result.total || 0); - const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore); const toolResultMap = new Map(); for (const raw of rawMessages) { @@ -464,12 +464,31 @@ export class ClaudeSessionsProvider implements IProviderSessions { } } + const totalNormalized = normalized.length; + let total = 0; + for (const msg of normalized) { + if (msg.kind !== 'tool_result') { + total += 1; + } + } + const normalizedOffset = Math.max(0, offset); + const normalizedLimit = limit === null ? null : Math.max(0, limit); + const messages = normalizedLimit === null + ? normalized + : normalized.slice( + Math.max(0, totalNormalized - normalizedOffset - normalizedLimit), + Math.max(0, totalNormalized - normalizedOffset), + ); + const hasMore = normalizedLimit === null + ? false + : Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0; + return { - messages: normalized, + messages, total, hasMore, - offset, - limit, + offset: normalizedOffset, + limit: normalizedLimit, }; } } diff --git a/server/modules/providers/list/codex/codex-sessions.provider.ts b/server/modules/providers/list/codex/codex-sessions.provider.ts index a7fe8129..0027480b 100644 --- a/server/modules/providers/list/codex/codex-sessions.provider.ts +++ b/server/modules/providers/list/codex/codex-sessions.provider.ts @@ -520,7 +520,9 @@ export class CodexSessionsProvider implements IProviderSessions { let result: CodexHistoryResult; try { - result = await getCodexSessionMessages(sessionId, limit, offset); + // Load full history first so `total` reflects frontend-normalized messages, + // not raw JSONL records. + result = await getCodexSessionMessages(sessionId, null, 0); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message); @@ -528,8 +530,6 @@ export class CodexSessionsProvider implements IProviderSessions { } const rawMessages = Array.isArray(result) ? result : (result.messages || []); - const total = Array.isArray(result) ? rawMessages.length : (result.total || 0); - const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore); const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage; const normalized: NormalizedMessage[] = []; @@ -552,12 +552,33 @@ export class CodexSessionsProvider implements IProviderSessions { } } + const totalNormalized = normalized.length; + let total = 0; + for (const msg of normalized) { + if (msg.kind !== 'tool_result') { + total += 1; + } + } + console.log(`[CodexProvider] Loaded ${total} frontend messages (${totalNormalized} normalized) for session "${sessionId}".`); + + const normalizedOffset = Math.max(0, offset); + const normalizedLimit = limit === null ? null : Math.max(0, limit); + const messages = normalizedLimit === null + ? normalized + : normalized.slice( + Math.max(0, totalNormalized - normalizedOffset - normalizedLimit), + Math.max(0, totalNormalized - normalizedOffset), + ); + const hasMore = normalizedLimit === null + ? false + : Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0; + return { - messages: normalized, + messages, total, hasMore, - offset, - limit, + offset: normalizedOffset, + limit: normalizedLimit, tokenUsage, }; } diff --git a/server/modules/providers/list/cursor/cursor-sessions.provider.ts b/server/modules/providers/list/cursor/cursor-sessions.provider.ts index e276ba8c..4773f99d 100644 --- a/server/modules/providers/list/cursor/cursor-sessions.provider.ts +++ b/server/modules/providers/list/cursor/cursor-sessions.provider.ts @@ -225,7 +225,13 @@ export class CursorSessionsProvider implements IProviderSessions { try { const blobs = await this.loadCursorBlobs(sessionId, projectPath); const allNormalized = this.normalizeCursorBlobs(blobs, sessionId); - const total = allNormalized.length; + const totalNormalized = allNormalized.length; + let total = 0; + for (const msg of allNormalized) { + if (msg.kind !== 'tool_result') { + total += 1; + } + } if (limit !== null) { const start = offset; @@ -233,8 +239,8 @@ export class CursorSessionsProvider implements IProviderSessions { ? [] : allNormalized.slice(start, start + limit); const hasMore = limit === 0 - ? start < total - : start + limit < total; + ? start < totalNormalized + : start + limit < totalNormalized; return { messages: page, total, diff --git a/server/modules/providers/list/gemini/gemini-sessions.provider.ts b/server/modules/providers/list/gemini/gemini-sessions.provider.ts index 606a1f17..98de12c7 100644 --- a/server/modules/providers/list/gemini/gemini-sessions.provider.ts +++ b/server/modules/providers/list/gemini/gemini-sessions.provider.ts @@ -528,10 +528,16 @@ export class GeminiSessionsProvider implements IProviderSessions { const messages = pageLimit === null ? normalized.slice(start) : normalized.slice(start, start + pageLimit); + let total = 0; + for (const msg of normalized) { + if (msg.kind !== 'tool_result') { + total += 1; + } + } return { messages, - total: normalized.length, + total, hasMore: pageLimit === null ? false : start + pageLimit < normalized.length, offset: start, limit: pageLimit, diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 451c3482..babc436d 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -626,6 +626,7 @@ export function useChatSessionState({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatMessages.length, isLoadingSessionMessages, searchTarget]); + console.log("[UseChatSessionState] total and chatMessages: ", totalMessages, chatMessages) // Token usage fetch for Claude useEffect(() => { if (!selectedProject || !selectedSession?.id) {