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
34 changes: 11 additions & 23 deletions src/main/utils/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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' &&
Expand All @@ -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;
}
Expand Down Expand Up @@ -762,10 +754,6 @@ export async function analyzeSessionFileMetadata(
}
}

if (aiRunOpen) {
turnCount++;
}

return {
firstUserMessage: firstUserMessage ?? firstCommandMessage,
name: lastName ?? lastAiTitle,
Expand Down
39 changes: 17 additions & 22 deletions src/renderer/components/chat/ChatHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions src/renderer/components/chat/SessionOriginStrip.tsx
Original file line number Diff line number Diff line change
@@ -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<SessionOriginStripProps>): React.JSX.Element | null => {
if (!origin) return null;
return (
<div
className="shrink-0 truncate px-4 pt-1 text-[11px]"
title={title}
style={{ color: 'var(--color-text-muted)' }}
>
{origin}
</div>
);
};
42 changes: 42 additions & 0 deletions src/renderer/components/layout/MiddlePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,9 +22,38 @@ interface MiddlePanelProps {
}

export const MiddlePanel: React.FC<MiddlePanelProps> = ({ 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 (
<div className="relative flex h-full flex-col">
<SearchBar tabId={tabId} />
<SessionOriginStrip
origin={formatSessionOrigin(originInput)}
title={formatSessionOriginTooltip(originInput)}
/>
<ChatHistory tabId={tabId} />
</div>
);
Expand Down
13 changes: 9 additions & 4 deletions src/renderer/components/sidebar/SessionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<>
<button
Expand Down Expand Up @@ -316,7 +321,7 @@ export const SessionItem = React.memo(function SessionItem({
<span style={{ opacity: 0.5 }}>·</span>
<span
className="tabular-nums"
title="Turns — AI response groups (same count as Turn chips in the transcript)"
title="Turns — transcript user messages; empty turns count too, the last unanswered turn has no chip yet"
>
{formatTokensCompact(session.turnCount)} turns
</span>
Expand Down Expand Up @@ -345,15 +350,15 @@ export const SessionItem = React.memo(function SessionItem({
/>
</>
)}
{session.worktreeName && (
{originTag && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<span
className="min-w-0 truncate"
title={`Worktree: ${session.worktreeName}`}
title={formatSessionOriginTooltip(session)}
style={{ color: 'var(--color-text-muted)' }}
>
{session.worktreeName}
{originTag}
</span>
</>
)}
Expand Down
18 changes: 10 additions & 8 deletions src/renderer/store/slices/sessionDetailSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,17 +272,16 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
currentState.updateTabLabel(existingTab.id, newLabel);
}

// Phase 1 set: conversation renders immediately, stats are null (filled in Phase 2)
// Phase 1 set: conversation renders immediately. Context stats are NOT
// cleared here — Phase 2 replaces them in place; nulling would unmount
// the "Context (N)" pill for a moment on every refetch.
set({
sessionDetail: slimDetail,
sessionDetailLoading: false,
conversation,
conversationLoading: false,
visibleAIGroupId: firstAIGroupId,
selectedAIGroup: firstAIGroup,
sessionClaudeMdStats: null,
sessionContextStats: null,
sessionPhaseInfo: null,
});

// Auto-expand all AI groups if the setting is enabled
Expand All @@ -294,21 +293,24 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
}
}

// Store per-tab session data (Phase 1 — stats null)
// Store per-tab session data (Phase 1 — stats preserved, filled in Phase 2)
if (tabId) {
const prev = get().tabSessionData;
// Preserve stats only for the same session: selectSession reuses the
// active tab id for the NEW session, spreading the previous session's
// stats next to the new conversation would render wrong pill data
const prevTab = prev[tabId];
const sameSession = prevTab?.sessionDetail?.session?.id === sessionId;
set({
tabSessionData: {
...prev,
[tabId]: {
...(sameSession ? prevTab : createEmptyTabSessionData()),
sessionDetail: slimDetail,
conversation,
conversationLoading: false,
sessionDetailLoading: false,
sessionDetailError: null,
sessionClaudeMdStats: null,
sessionContextStats: null,
sessionPhaseInfo: null,
visibleAIGroupId: firstAIGroupId,
selectedAIGroup: firstAIGroup,
},
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/store/slices/sessionSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
selectedSessionId: id,
sessionDetail: null,
sessionContextStats: null,
sessionPhaseInfo: null,
sessionClaudeMdStats: null,
sessionDetailError: null,
});

Expand All @@ -346,6 +348,8 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
sessions: [],
sessionDetail: null,
sessionContextStats: null,
sessionPhaseInfo: null,
sessionClaudeMdStats: null,
});
},

Expand Down
2 changes: 2 additions & 0 deletions src/renderer/store/utils/stateResetHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export function getSessionResetState(): Partial<AppState> {
selectedSessionId: null,
sessionDetail: null,
sessionContextStats: null,
sessionPhaseInfo: null,
sessionClaudeMdStats: null,
sessions: [],
sessionsError: null,
sessionsCursor: null,
Expand Down
37 changes: 37 additions & 0 deletions src/renderer/utils/contextTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,43 @@ function getFirstAssistantTotalTokens(aiGroup: AIGroup): number | undefined {
return total === 0 ? undefined : total;
}

/**
* Last AI group that has context stats, walking backwards. During a live
* refresh the conversation can gain AI groups with no stats yet — the
* context-pill target must be the last *tracked* group, otherwise the
* pill renders nothing and "Context (N)" disappears.
*/
export function findLastTrackedAiGroupId(
items: ChatItem[],
stats: Map<string, ContextStats>
): string | undefined {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
if (item.type === 'ai' && stats.has(item.group.id)) return item.group.id;
}
return undefined;
}

/**
* Resolves which AI group's stats the context pill should show. An explicitly
* selected phase resolves within itself only — an untracked phase target
* returns undefined (render empty) instead of falling back to the last
* tracked group of ANOTHER phase. Without a selection the last tracked
* group wins.
*/
export function resolveContextTargetAiGroupId(
items: ChatItem[],
stats: Map<string, ContextStats>,
phaseInfo: ContextPhaseInfo | null,
selectedPhase: number | null
): string | undefined {
if (selectedPhase !== null && phaseInfo) {
const phase = phaseInfo.phases.find((p) => p.phaseNumber === selectedPhase);
return phase && stats.has(phase.lastAIGroupId) ? phase.lastAIGroupId : undefined;
}
return findLastTrackedAiGroupId(items, stats);
}

/**
* Process all chat items in a session and compute context stats with phase information.
* Returns both the stats map and session-wide phase info.
Expand Down
Loading
Loading