diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 79a7852b..f500da32 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -17,7 +17,6 @@ import * as readline from 'readline'; import { SessionContentFilter } from '../services/discovery/SessionContentFilter'; import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; -import { categorizeMessage } from '../services/parsing/MessageClassifier'; import { type ChatHistoryEntry, type ContentBlock, @@ -429,7 +428,9 @@ export interface SessionFileMetadata { phaseBreakdown?: PhaseTokenBreakdown[]; /** Total spend: sum of all assistant usage in this transcript (in+cache+out) */ totalTokens: number; - /** AI response groups — same count as the "Turn N" chips in the chat */ + /** User turns (transcript user messages, sidechains/compact summaries excluded) — + * the chat numbers chips the same way, but a session's last turn without a + * response has no chip yet */ turnCount: number; hasDisplayableContent: boolean; } @@ -467,11 +468,13 @@ export async function analyzeSessionFileMetadata( const lastAiTitle: string | null = null; let messageCount = 0; let hasDisplayableContent = false; - // After a UserGroup, await the first main-thread assistant message to count the AIGroup + // After a UserGroup, await the first main-thread assistant message (used by + // the ongoing-detection heuristics; no longer drives turn counting) let awaitingAIGroup = false; - // Turn counting mirrors ChunkBuilder.buildChunks exactly: an AI run closed by - // a user/system/compact boundary (or EOF) == one AI group == one "Turn N". - let aiRunOpen = false; + // Turn counting mirrors the transcript: one user message (isUserChunkLine + // semantics — the canonical predicate shared with the turn-budget hook — + // plus sidechain/compact-summary exclusions) == one turn, whether or not + // it produced a response. let turnCount = 0; let gitBranch: string | null = null; @@ -530,6 +533,8 @@ export async function analyzeSessionFileMetadata( if (isParsedUserChunkMessage(parsed)) { messageCount++; awaitingAIGroup = true; + // Compact summaries render as CompactBoundary, not a user turn + if (!parsed.isSidechain && !parsed.isCompactSummary) turnCount++; } else if ( awaitingAIGroup && parsed.type === 'assistant' && @@ -540,19 +545,6 @@ export async function analyzeSessionFileMetadata( awaitingAIGroup = false; } - // Same rules as the chunk pipeline: sidechain never reaches the main - // thread (SessionParser splits it out), hardNoise is skipped entirely, - // a user/system/compact boundary closes the current AI run. - if (!parsed.isSidechain) { - const category = categorizeMessage(parsed); - if (category === 'ai') { - aiRunOpen = true; - } else if (category !== 'hardNoise' && aiRunOpen) { - turnCount++; - aiRunOpen = false; - } - } - if (!gitBranch && 'gitBranch' in entry && entry.gitBranch) { gitBranch = entry.gitBranch; } @@ -762,10 +754,6 @@ export async function analyzeSessionFileMetadata( } } - if (aiRunOpen) { - turnCount++; - } - return { firstUserMessage: firstUserMessage ?? firstCommandMessage, name: lastName ?? lastAiTitle, diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index dd5f31b2..670e8358 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -6,6 +6,7 @@ import { useTabNavigationController } from '@renderer/hooks/useTabNavigationCont import { useTabUI } from '@renderer/hooks/useTabUI'; import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup'; import { useStore } from '@renderer/store'; +import { resolveContextTargetAiGroupId } from '@renderer/utils/contextTracker'; import { applyEventFilters, EMPTY_EVENT_FILTER_COUNTS } from '@renderer/utils/eventFilters'; import { lastAssistantTotalTokens } from '@shared/turnAccounting'; import { useVirtualizer } from '@tanstack/react-virtual'; @@ -26,6 +27,12 @@ import { EventFilterBar } from './EventFilterBar'; import type { ContextInjection } from '@renderer/types/contextInjection'; +/** Shared empty shape of the context-pill memo result (EMPTY_EVENT_FILTER_COUNTS precedent). */ +const EMPTY_CONTEXT_RESULT: { + allContextInjections: ContextInjection[]; + lastAiGroupTotalTokens: number | undefined; +} = { allContextInjections: [], lastAiGroupTotalTokens: undefined }; + /** * Waits for two requestAnimationFrame cycles, allowing the virtualizer to render. */ @@ -130,31 +137,19 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { // Compute all accumulated context injections (phase-aware) const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { if (!sessionContextStats || !conversation?.items.length) { - return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; + return EMPTY_CONTEXT_RESULT; } - // Determine which phase to show - const effectivePhase = selectedContextPhase; - - // If a specific phase is selected, find the last AI group in that phase - let targetAiGroupId: string | undefined; - if (effectivePhase !== null && sessionPhaseInfo) { - const phase = sessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); - if (phase) { - targetAiGroupId = phase.lastAIGroupId; - } - } - - // Default: use the last AI group overall + // Which AI group's stats the pill shows: selected phase resolves within + // itself; otherwise the last tracked group + const targetAiGroupId = resolveContextTargetAiGroupId( + conversation.items, + sessionContextStats, + sessionPhaseInfo, + selectedContextPhase + ); if (!targetAiGroupId) { - const lastAiItem = [...conversation.items].reverse().find((item) => item.type === 'ai'); - if (lastAiItem?.type !== 'ai') { - return { - allContextInjections: [] as ContextInjection[], - lastAiGroupTotalTokens: undefined, - }; - } - targetAiGroupId = lastAiItem.group.id; + return EMPTY_CONTEXT_RESULT; } const stats = sessionContextStats.get(targetAiGroupId); diff --git a/src/renderer/components/chat/SessionOriginStrip.tsx b/src/renderer/components/chat/SessionOriginStrip.tsx new file mode 100644 index 00000000..671e7fa7 --- /dev/null +++ b/src/renderer/components/chat/SessionOriginStrip.tsx @@ -0,0 +1,28 @@ +/** + * SessionOriginStrip - One-line "where does this session live" label shown + * above the chat: `repo · worktree · branch · ~/path`. Full path in tooltip. + * Renders nothing when the origin is unknown. + */ + +interface SessionOriginStripProps { + /** Formatted origin string; '' renders nothing */ + origin: string; + /** Tooltip (full unshortened origin parts) */ + title?: string; +} + +export const SessionOriginStrip = ({ + origin, + title, +}: Readonly): React.JSX.Element | null => { + if (!origin) return null; + return ( +
+ {origin} +
+ ); +}; diff --git a/src/renderer/components/layout/MiddlePanel.tsx b/src/renderer/components/layout/MiddlePanel.tsx index 8579bed3..cecaf9a6 100644 --- a/src/renderer/components/layout/MiddlePanel.tsx +++ b/src/renderer/components/layout/MiddlePanel.tsx @@ -1,6 +1,19 @@ +/** + * MiddlePanel - Chat column: SearchBar, origin strip, ChatHistory. + */ + import React from 'react'; +import { useStore } from '@renderer/store'; +import { + formatSessionOrigin, + formatSessionOriginTooltip, + resolveSessionOriginGroups, +} from '@renderer/utils/formatSessionOrigin'; +import { useShallow } from 'zustand/react/shallow'; + import { ChatHistory } from '../chat/ChatHistory'; +import { SessionOriginStrip } from '../chat/SessionOriginStrip'; import { SearchBar } from '../search/SearchBar'; interface MiddlePanelProps { @@ -9,9 +22,38 @@ interface MiddlePanelProps { } export const MiddlePanel: React.FC = ({ tabId }) => { + const { session, repoName, worktreeName } = useStore( + useShallow((s) => { + const detail = + (tabId ? s.tabSessionData[tabId]?.sessionDetail : undefined) ?? s.sessionDetail; + const detailSession = detail?.session; + // detail.session carries no worktreeName (only sidebar list rows are + // tagged) — resolve repo/worktree from the same groups the sidebar uses + const resolved = detailSession + ? resolveSessionOriginGroups(s.repositoryGroups, detailSession.projectId) + : {}; + return { + session: detailSession, + repoName: resolved.repoName, + worktreeName: resolved.worktreeName, + }; + }) + ); + + const originInput = { + projectPath: session?.projectPath, + gitBranch: session?.gitBranch, + worktreeName, + repoName, + }; + return (
+
); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 1cda2f8e..0e2073e0 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -8,6 +8,10 @@ import React, { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useStore } from '@renderer/store'; +import { + formatSessionOriginTag, + formatSessionOriginTooltip, +} from '@renderer/utils/formatSessionOrigin'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatDistanceToNowStrict } from 'date-fns'; import { EyeOff, Pin } from 'lucide-react'; @@ -263,6 +267,7 @@ export const SessionItem = React.memo(function SessionItem({ ]); // Height must match SESSION_HEIGHT (48px) in DateGroupedSessions.tsx for virtual scroll + const originTag = formatSessionOriginTag(session); return ( <>