From 41539b0645947496aaec49240588bcbdd631f13d Mon Sep 17 00:00:00 2001 From: axisrow Date: Tue, 6 Oct 2026 23:18:57 +0800 Subject: [PATCH 1/6] fix(renderer): number chat turns by transcript user messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn N must equal the Nth user message of the transcript, 1:1. The old numbering counted AI chunks (aiCount), so user messages that produced no response silently shifted every later label: a 24-user-message session displayed "22 turns" and Turn 21 pointed at transcript turn 24. - groupTransformer: both transform paths now derive an AI group's turnIndex from the user-chunk counter (userCount - 1, 0-based display convention unchanged); compact/system chunks consume no number; AI groups before the first user message fall back to AI-sequence numbering. - jsonl analyzeSessionFileMetadata: turnCount now counts user turns (isUserChunkLine semantics, sidechain excluded) instead of closed AI runs. Red→green: new test/renderer/utils/groupTransformer.test.ts failed 5/7 on the old numbering ([0,2] expected, [0,1] received for an empty turn), now 7/7; jsonl turn-count and parity tests flipped to user semantics, subsystem green (171 passed). Known effect: two AI groups of the same turn (post- compaction continuation) now share a turn number — transcript-true; turn navigation lands on the first group of that turn. Co-Authored-By: Claude Code --- src/main/utils/jsonl.ts | 27 +-- src/renderer/utils/groupTransformer.ts | 11 +- test/main/utils/jsonl.test.ts | 27 +-- test/renderer/utils/groupTransformer.test.ts | 193 +++++++++++++++++++ 4 files changed, 220 insertions(+), 38 deletions(-) create mode 100644 test/renderer/utils/groupTransformer.test.ts diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 79a7852b..a6dc9c34 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,7 @@ 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) — same count as the "Turn N" chips in the chat */ turnCount: number; hasDisplayableContent: boolean; } @@ -469,9 +468,9 @@ export async function analyzeSessionFileMetadata( let hasDisplayableContent = false; // After a UserGroup, await the first main-thread assistant message to count the AIGroup 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, sidechain excluded) == one turn == one "Turn N" chip, whether + // or not it produced a response. let turnCount = 0; let gitBranch: string | null = null; @@ -530,6 +529,7 @@ export async function analyzeSessionFileMetadata( if (isParsedUserChunkMessage(parsed)) { messageCount++; awaitingAIGroup = true; + if (!parsed.isSidechain) turnCount++; } else if ( awaitingAIGroup && parsed.type === 'assistant' && @@ -540,19 +540,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 +749,6 @@ export async function analyzeSessionFileMetadata( } } - if (aiRunOpen) { - turnCount++; - } - return { firstUserMessage: firstUserMessage ?? firstCommandMessage, name: lastName ?? lastAiTitle, diff --git a/src/renderer/utils/groupTransformer.ts b/src/renderer/utils/groupTransformer.ts index d07c2fdd..b9f64e2d 100644 --- a/src/renderer/utils/groupTransformer.ts +++ b/src/renderer/utils/groupTransformer.ts @@ -118,7 +118,10 @@ export function transformChunksToConversation( } else if (isEnhancedAIChunk(chunk)) { items.push({ type: 'ai', - group: createAIGroupFromChunk(chunk, aiCount), + // Turn N = ordinal of the user message this group answers (0-based); + // empty turns consume a number. Fallback to AI-sequence before the + // first user chunk (session not starting with a user message). + group: createAIGroupFromChunk(chunk, userCount > 0 ? userCount - 1 : aiCount), }); aiCount++; } else if (isEnhancedCompactChunk(chunk)) { @@ -279,7 +282,7 @@ export function incrementalUpdateConversation( } else if (isEnhancedAIChunk(chunk)) { items.push({ type: 'ai', - group: createAIGroupFromChunk(chunk, aiCount), + group: createAIGroupFromChunk(chunk, userCount > 0 ? userCount - 1 : aiCount), }); aiCount++; } else if (isEnhancedCompactChunk(chunk)) { @@ -619,7 +622,9 @@ function createCompactGroup(chunk: EnhancedCompactChunk): CompactGroup { * Creates an AIGroup from an EnhancedAIChunk. * * @param chunk - The AI chunk to transform - * @param turnIndex - 0-based index of this AI group within the session + * @param turnIndex - 0-based transcript turn = ordinal of the user message this + * group answers (empty turns consume a number); falls back to + * AI-sequence numbering for groups preceding the first user chunk * @returns AIGroup with semantic steps and metrics */ function createAIGroupFromChunk(chunk: EnhancedAIChunk, turnIndex: number): AIGroup { diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index 235c08a4..1aca4b21 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -11,7 +11,7 @@ import { readSessionName, } from '../../../src/main/utils/jsonl'; import { ChunkBuilder } from '../../../src/main/services/analysis/ChunkBuilder'; -import { isAIChunk } from '../../../src/main/types'; +import { isAIChunk, isUserChunk } from '../../../src/main/types'; import type { ParsedMessage } from '../../../src/main/types'; // Helper to create a minimal ParsedMessage @@ -531,7 +531,7 @@ describe('jsonl', () => { } } }); - it('counts turns — AI response groups, same rule as the chunk pipeline', async () => { + it('counts turns — transcript user messages, same rule as the Turn N chips', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-turns-')); try { const filePath = path.join(tempDir, 'session.jsonl'); @@ -563,9 +563,8 @@ describe('jsonl', () => { usage: { input_tokens: 10, output_tokens: 2 }, }, }); - // root (parentUuid null) is hard noise everywhere; an assistant run - // closes on user/system/compact and counts exactly one turn — - // continuations, synthetic replies and sidechains never break a group + // root (parentUuid null) is hard noise everywhere; a turn is one real + // user message — empty turns count too, system output/sidechains don't const lines = [ user('u1', null, 'go'), assistant('a1', 'u1', 'claude-fable-5-1'), @@ -573,7 +572,7 @@ describe('jsonl', () => { user('u2', 'a1b', 'again'), assistant('a2-synthetic', 'u2', ''), // hard noise — no break assistant('a2', 'a2-synthetic', 'claude-fable-5-1'), - user('sys', 'a2', 'ok'), // system break + user('sys', 'a2', 'ok'), // not a turn assistant('a3', 'sys', 'claude-fable-5-1'), user('side-u', 'a3', 'sidechat', true), // sidechain — skipped assistant('side-a', 'side-u', 'claude-fable-5-1'), @@ -584,8 +583,8 @@ describe('jsonl', () => { const result = await analyzeSessionFileMetadata(filePath); - // groups: [a1,a1b] [a2] [a3] [a4] = 4 - expect(result.turnCount).toBe(4); + // turns: u1, u2, u3 = 3 (system output and sidechain don't count) + expect(result.turnCount).toBe(3); } finally { try { fs.rmSync(tempDir, { @@ -600,7 +599,7 @@ describe('jsonl', () => { } }); - it('parity — scan turnCount equals the chunk pipeline AIChunk count', async () => { + it('parity — scan turnCount equals the chunk pipeline UserChunk count', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-parity-')); try { const msg = (over: Partial): ParsedMessage => ({ @@ -637,6 +636,7 @@ describe('jsonl', () => { ai('a3', 'sys'), msg({ uuid: 'c1', parentUuid: 'a3', type: 'user', isCompactSummary: true }), // break ai('a4', 'c1'), // closed at EOF + msg({ uuid: 'u4', parentUuid: 'a4', type: 'user', content: 'one more' }), // empty turn ]; // Serialize the same objects to JSONL the way the scanner reads them const toEntry = (m: ParsedMessage): string => @@ -663,11 +663,12 @@ describe('jsonl', () => { const scan = await analyzeSessionFileMetadata(filePath); const chunks = new ChunkBuilder().buildChunks(messages); - const aiChunks = chunks.filter(isAIChunk).length; + const userChunks = chunks.filter(isUserChunk).length; - // groups: [a1,a2] [a3] [a4] = 3 on both paths - expect(aiChunks).toBe(3); - expect(scan.turnCount).toBe(aiChunks); + // user turns u1, u2, u4 = 3 (stdout/compact/sidechain are not turns); + // AI groups [a1,a2] [a3] [a4] = 3 — equal here, diverges on empty turns + expect(userChunks).toBe(3); + expect(scan.turnCount).toBe(userChunks); } finally { try { fs.rmSync(tempDir, { diff --git a/test/renderer/utils/groupTransformer.test.ts b/test/renderer/utils/groupTransformer.test.ts new file mode 100644 index 00000000..fe65a4e8 --- /dev/null +++ b/test/renderer/utils/groupTransformer.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; + +import { + incrementalUpdateConversation, + transformChunksToConversation, +} from '@renderer/utils/groupTransformer'; + +// ============================================================================= +// Test Fixtures (chunk shapes per src/main/types/chunks.ts type guards) +// ============================================================================= + +function makeMetrics(overrides = {}) { + return { + durationMs: 60000, + totalTokens: 5000, + inputTokens: 3000, + outputTokens: 2000, + cacheReadTokens: 500, + cacheCreationTokens: 100, + messageCount: 10, + costUsd: 0.05, + ...overrides, + }; +} + +let seq = 0; + +function makeUserChunk(overrides: Record = {}) { + seq += 1; + return { + id: `chunk-user-${seq}`, + chunkType: 'user' as const, + startTime: new Date('2025-01-15T10:00:00Z'), + endTime: new Date('2025-01-15T10:00:01Z'), + durationMs: 1000, + metrics: makeMetrics({ messageCount: 1 }), + userMessage: { + uuid: `user-${seq}`, + parentUuid: null, + type: 'user' as const, + timestamp: new Date('2025-01-15T10:00:00Z'), + content: `user message ${seq}`, + isMeta: false, + isSidechain: false, + }, + rawMessages: [], + ...overrides, + }; +} + +function makeAIChunk(overrides: Record = {}) { + seq += 1; + return { + id: `chunk-ai-${seq}`, + chunkType: 'ai' as const, + startTime: new Date('2025-01-15T10:00:01Z'), + endTime: new Date('2025-01-15T10:00:05Z'), + durationMs: 4000, + metrics: makeMetrics({ messageCount: 2 }), + responses: [ + { + uuid: `assistant-${seq}`, + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2025-01-15T10:00:01Z'), + content: [{ type: 'text', text: 'answer' }], + isMeta: false, + isSidechain: false, + }, + ], + processes: [], + sidechainMessages: [], + toolExecutions: [], + semanticSteps: [], + rawMessages: [], + ...overrides, + }; +} + +function makeSystemChunk(overrides: Record = {}) { + seq += 1; + return { + id: `chunk-system-${seq}`, + chunkType: 'system' as const, + startTime: new Date('2025-01-15T10:00:06Z'), + endTime: new Date('2025-01-15T10:00:07Z'), + durationMs: 1000, + metrics: makeMetrics({ messageCount: 1 }), + message: { + uuid: `system-${seq}`, + type: 'user' as const, + timestamp: new Date('2025-01-15T10:00:06Z'), + content: 'command output', + isMeta: true, + isSidechain: false, + }, + commandOutput: 'Set model to sonnet', + rawMessages: [], + ...overrides, + }; +} + +function makeCompactChunk(overrides: Record = {}) { + seq += 1; + return { + id: `chunk-compact-${seq}`, + chunkType: 'compact' as const, + startTime: new Date('2025-01-15T10:01:00Z'), + endTime: new Date('2025-01-15T10:01:00Z'), + durationMs: 0, + metrics: makeMetrics({ messageCount: 0 }), + message: { + uuid: `compact-${seq}`, + type: 'summary' as const, + timestamp: new Date('2025-01-15T10:01:00Z'), + content: 'Summary', + isMeta: false, + isSidechain: false, + }, + rawMessages: [], + ...overrides, + }; +} + +/** AI group turnIndexes in item order. */ +function aiTurnIndexes(chunks: Parameters[0]) { + const conv = transformChunksToConversation(chunks, [], false); + return conv.items + .filter((item) => item.type === 'ai') + .map((item) => (item.group as { turnIndex: number }).turnIndex); +} + +// ============================================================================= +// Turn numbering — must match transcript user-message ordinals 1:1 +// ============================================================================= + +describe('groupTransformer turn numbering', () => { + it('numbers AI groups by the user message they answer (empty turns consume a number)', () => { + // [U1, A1, U2 (no response), U3, A3] → Turn 1, Turn 3 + expect( + aiTurnIndexes([ + makeUserChunk(), + makeAIChunk(), + makeUserChunk(), + makeUserChunk(), + makeAIChunk(), + ]) + ).toEqual([0, 2]); + }); + + it('numbers an AI group by its own user message even when the previous turn was empty', () => { + // [U1, A1, U2 (no response), A2] → A2 answers U2 → Turn 2 + expect(aiTurnIndexes([makeUserChunk(), makeAIChunk(), makeUserChunk(), makeAIChunk()])).toEqual( + [0, 1] + ); + }); + + it('does not consume a turn number for compact boundaries', () => { + const chunks = [makeUserChunk(), makeAIChunk(), makeCompactChunk(), makeAIChunk()]; + expect(aiTurnIndexes(chunks)).toEqual([0, 0]); + }); + + it('does not consume a turn number for system chunks', () => { + const chunks = [makeUserChunk(), makeAIChunk(), makeSystemChunk(), makeAIChunk()]; + expect(aiTurnIndexes(chunks)).toEqual([0, 0]); + }); + + it('falls back to AI-sequence numbering before the first user chunk', () => { + const chunks = [makeAIChunk(), makeAIChunk(), makeUserChunk(), makeAIChunk()]; + expect(aiTurnIndexes(chunks)).toEqual([0, 1, 0]); + }); + + it('incremental path agrees with the full transform', () => { + const base = [makeUserChunk(), makeAIChunk()]; + const prev = transformChunksToConversation(base, [], false); + const grown = incrementalUpdateConversation( + prev, + [...base, makeUserChunk(), makeAIChunk()], + [], + false + ); + const aiItems = grown.items.filter((item) => item.type === 'ai'); + expect(aiItems.map((item) => (item.group as { turnIndex: number }).turnIndex)).toEqual([0, 1]); + }); + + it('incremental path accounts for empty turns added in bulk', () => { + const base = [makeUserChunk(), makeAIChunk(), makeUserChunk(), makeUserChunk()]; + const prev = transformChunksToConversation(base, [], false); + const grown = incrementalUpdateConversation(prev, [...base, makeAIChunk()], [], false); + const aiItems = grown.items.filter((item) => item.type === 'ai'); + expect(aiItems.map((item) => (item.group as { turnIndex: number }).turnIndex)).toEqual([0, 2]); + }); +}); From cc650951d0be1b12ba3eccba9f7c165ef5c14eea Mon Sep 17 00:00:00 2001 From: axisrow Date: Wed, 7 Oct 2026 00:06:50 +0800 Subject: [PATCH 2/6] fix(renderer): surface session origin in middle panel and sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat column showed no indication of which project/directory/worktree a session belongs to, and the sidebar only showed a truncated worktree name ("agent-orc…"). - New formatSessionOrigin util: "repo · worktree · branch · ~/short/path" strip line and a "worktree · branch" sidebar tag; reuses shortenDisplayPath. - New SessionOriginStrip component between SearchBar and ChatHistory; renders nothing when the origin is unknown. - MiddlePanel reads the session from per-tab data (tabSessionData[tabId] ?? sessionDetail) and repoName from repositoryGroups. - SessionItem renders the origin tag with the full path as tooltip. Co-Authored-By: Claude Code --- .../components/chat/SessionOriginStrip.tsx | 28 ++++++++++ .../components/layout/MiddlePanel.tsx | 35 ++++++++++++ .../components/sidebar/SessionItem.tsx | 13 +++-- src/renderer/utils/formatSessionOrigin.ts | 55 +++++++++++++++++++ .../components/sessionOriginStrip.test.ts | 55 +++++++++++++++++++ .../utils/formatSessionOrigin.test.ts | 52 ++++++++++++++++++ 6 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 src/renderer/components/chat/SessionOriginStrip.tsx create mode 100644 src/renderer/utils/formatSessionOrigin.ts create mode 100644 test/renderer/components/sessionOriginStrip.test.ts create mode 100644 test/renderer/utils/formatSessionOrigin.test.ts 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..48cff484 100644 --- a/src/renderer/components/layout/MiddlePanel.tsx +++ b/src/renderer/components/layout/MiddlePanel.tsx @@ -1,6 +1,15 @@ +/** + * MiddlePanel - Chat column: SearchBar, origin strip, ChatHistory. + */ + import React from 'react'; +import { useStore } from '@renderer/store'; +import { formatSessionOrigin } 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 +18,35 @@ interface MiddlePanelProps { } export const MiddlePanel: React.FC = ({ tabId }) => { + const { session, repoName } = useStore( + useShallow((s) => { + const detail = + (tabId ? s.tabSessionData[tabId]?.sessionDetail : undefined) ?? s.sessionDetail; + const detailSession = detail?.session; + const detailRepoName = detailSession + ? s.repositoryGroups.find((g) => g.worktrees.some((w) => w.id === detailSession.projectId)) + ?.name + : undefined; + return { session: detailSession, repoName: detailRepoName }; + }) + ); + + const origin = formatSessionOrigin({ + projectPath: session?.projectPath, + gitBranch: session?.gitBranch, + worktreeName: session?.worktreeName, + repoName, + }); + return (
+
); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 1cda2f8e..18950236 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -8,6 +8,7 @@ import React, { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useStore } from '@renderer/store'; +import { formatSessionOriginTag } from '@renderer/utils/formatSessionOrigin'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatDistanceToNowStrict } from 'date-fns'; import { EyeOff, Pin } from 'lucide-react'; @@ -316,7 +317,7 @@ export const SessionItem = React.memo(function SessionItem({ · {formatTokensCompact(session.turnCount)} turns @@ -345,15 +346,19 @@ export const SessionItem = React.memo(function SessionItem({ /> )} - {session.worktreeName && ( + {formatSessionOriginTag(session) && ( <> · - {session.worktreeName} + {formatSessionOriginTag(session)} )} diff --git a/src/renderer/utils/formatSessionOrigin.ts b/src/renderer/utils/formatSessionOrigin.ts new file mode 100644 index 00000000..c11d5f1d --- /dev/null +++ b/src/renderer/utils/formatSessionOrigin.ts @@ -0,0 +1,55 @@ +/** + * Builds compact "where does this session live" labels from data already on + * the Session object (projectPath / gitBranch / worktreeName) plus the + * repository name when the sidebar runs in grouped view. + * + * Full values stay available for tooltips; these helpers only format. + */ + +import { shortenDisplayPath } from './pathDisplay'; + +export interface SessionOriginInput { + /** Full project directory (~/.claude encoded id decoded) */ + projectPath?: string; + /** Git branch of the session's project directory */ + gitBranch?: string; + /** Worktree display name — absent for the main worktree */ + worktreeName?: string; + /** Repository display name (grouped sidebar view) */ + repoName?: string; +} + +/** Last non-empty path segment, e.g. `/Users/x/proj` → `proj`. */ +function basename(p: string): string { + return p.split(/[\\/]/).filter(Boolean).pop() ?? p; +} + +const SEPARATOR = ' · '; + +/** + * One-line origin for the strip above the chat: + * `repo · worktree · branch · ~/short/path`. Worktree defaults to `main`. + * Returns '' when nothing is known — callers should render nothing. + */ +export function formatSessionOrigin(o: SessionOriginInput): string { + if (!o.projectPath && !o.repoName) return ''; + + const repo = o.repoName ?? basename(o.projectPath ?? ''); + const parts = [ + repo, + o.worktreeName ?? 'main', + o.gitBranch, + o.projectPath ? shortenDisplayPath(o.projectPath) : undefined, + ].filter(Boolean); + return parts.join(SEPARATOR); +} + +/** + * Compact tag for sidebar session rows: `worktree · branch`. + * Main-worktree sessions show just the branch. Returns null when neither + * worktree nor branch is known — callers should render nothing. + */ +export function formatSessionOriginTag(o: SessionOriginInput): string | null { + const tag = [o.worktreeName, o.gitBranch].filter(Boolean).join(SEPARATOR); + return tag.length > 0 ? tag : null; +} diff --git a/test/renderer/components/sessionOriginStrip.test.ts b/test/renderer/components/sessionOriginStrip.test.ts new file mode 100644 index 00000000..01d196a8 --- /dev/null +++ b/test/renderer/components/sessionOriginStrip.test.ts @@ -0,0 +1,55 @@ +/** + * SessionOriginStrip renders the session origin line above the chat and + * hides itself entirely when the origin is unknown. + */ + +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import { SessionOriginStrip } from '../../../src/renderer/components/chat/SessionOriginStrip'; + +async function mount(ui: React.ReactElement): Promise<{ host: HTMLElement; unmount: () => void }> { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + await act(async () => { + root.render(ui); + await Promise.resolve(); + }); + return { + host, + unmount: () => { + act(() => { + root.unmount(); + }); + }, + }; +} + +describe('SessionOriginStrip', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders nothing for an empty origin', async () => { + const { host, unmount } = await mount(React.createElement(SessionOriginStrip, { origin: '' })); + expect(host.textContent).toBe(''); + unmount(); + }); + + it('renders the origin text with a tooltip', async () => { + const { host, unmount } = await mount( + React.createElement(SessionOriginStrip, { + origin: 'repo · wt · main · ~/p', + title: '/full/path', + }) + ); + const el = host.querySelector('div'); + expect(el?.textContent).toBe('repo · wt · main · ~/p'); + expect(el?.getAttribute('title')).toBe('/full/path'); + unmount(); + }); +}); diff --git a/test/renderer/utils/formatSessionOrigin.test.ts b/test/renderer/utils/formatSessionOrigin.test.ts new file mode 100644 index 00000000..0761f543 --- /dev/null +++ b/test/renderer/utils/formatSessionOrigin.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { formatSessionOrigin, formatSessionOriginTag } from '@renderer/utils/formatSessionOrigin'; + +describe('formatSessionOrigin', () => { + it('renders repo · worktree · branch · shortened path for a main-worktree session', () => { + expect(formatSessionOrigin({ projectPath: '/Users/test/project', gitBranch: 'main' })).toBe( + 'project · main · main · ~/project' + ); + }); + + it('uses repoName when provided and keeps the full-length path as-is when short enough', () => { + expect( + formatSessionOrigin({ + repoName: 'agent-orchestrator', + projectPath: '/Users/axisrow/.conductor/workspaces/agent-orchestrator-76', + worktreeName: 'agent-orchestrator-76', + gitBranch: 'feature/x', + }) + ).toBe( + 'agent-orchestrator · agent-orchestrator-76 · feature/x · ~/.conductor/workspaces/agent-orchestrator-76' + ); + }); + + it('middle-truncates long paths', () => { + const out = formatSessionOrigin({ + projectPath: '/Users/test/w/very-long-repo/deep/nested/directory/x', + }); + expect(out).toContain('…'); + expect(out.startsWith('x · main · ~/w/…')).toBe(true); + }); + + it('returns an empty string when nothing is known', () => { + expect(formatSessionOrigin({})).toBe(''); + expect(formatSessionOrigin({ gitBranch: 'main' })).toBe(''); + }); +}); + +describe('formatSessionOriginTag', () => { + it('joins worktree and branch', () => { + expect(formatSessionOriginTag({ worktreeName: 'wt-a', gitBranch: 'feat' })).toBe('wt-a · feat'); + }); + + it('falls back to whichever part exists', () => { + expect(formatSessionOriginTag({ gitBranch: 'main' })).toBe('main'); + expect(formatSessionOriginTag({ worktreeName: 'wt-a' })).toBe('wt-a'); + }); + + it('returns null when there is nothing to show', () => { + expect(formatSessionOriginTag({})).toBeNull(); + }); +}); From befa49e9b059106a5d1db3135a44ec40acd79590 Mon Sep 17 00:00:00 2001 From: axisrow Date: Wed, 7 Oct 2026 00:07:03 +0800 Subject: [PATCH 3/6] fix(renderer): keep Context pill across in-place session refresh The "Context (N)" pill above the chat kept disappearing: - During a live refresh the conversation gains new AI groups whose stats are not computed until the next full fetch. The pill's memo picked the last AI group overall, found no stats for it and rendered nothing. Now it targets the last AI group that actually has stats (new findLastTrackedAiGroupId helper), and a phase's lastAIGroupId is only used when it is tracked. - Phase 1 of fetchSessionDetail reset sessionContextStats / sessionPhaseInfo / sessionClaudeMdStats to null on every fetch, briefly unmounting the pill until Phase 2 finished. Phase 1 now leaves existing stats in place (per-tab entries spread the previous data; a replaced tab's data is already cleaned up by openTab). selectSession still clears the stats when switching sessions, now including sessionPhaseInfo and sessionClaudeMdStats. Behavioral note: between a live refresh and the next full fetch the pill shows the stats of the last tracked turn instead of nothing. Co-Authored-By: Claude Code --- src/renderer/components/chat/ChatHistory.tsx | 13 +++++--- .../store/slices/sessionDetailSlice.ts | 13 +++----- src/renderer/store/slices/sessionSlice.ts | 2 ++ src/renderer/utils/contextTracker.ts | 17 ++++++++++ test/renderer/utils/contextTracker.test.ts | 31 ++++++++++++++++++- 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index dd5f31b2..83702443 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 { findLastTrackedAiGroupId } 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'; @@ -140,21 +141,23 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { let targetAiGroupId: string | undefined; if (effectivePhase !== null && sessionPhaseInfo) { const phase = sessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); - if (phase) { + // The phase's last AI group must have stats; a live refresh can leave + // it untracked (stats recomputed on the next full fetch) — fall back + // to the last tracked group instead of rendering an empty pill + if (phase && sessionContextStats.has(phase.lastAIGroupId)) { targetAiGroupId = phase.lastAIGroupId; } } - // Default: use the last AI group overall + // Default: the last AI group that actually has stats if (!targetAiGroupId) { - const lastAiItem = [...conversation.items].reverse().find((item) => item.type === 'ai'); - if (lastAiItem?.type !== 'ai') { + targetAiGroupId = findLastTrackedAiGroupId(conversation.items, sessionContextStats); + if (!targetAiGroupId) { return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined, }; } - targetAiGroupId = lastAiItem.group.id; } const stats = sessionContextStats.get(targetAiGroupId); diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index e7b33a18..ffed0a17 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -272,7 +272,9 @@ export const createSessionDetailSlice: StateCreator = selectedSessionId: id, sessionDetail: null, sessionContextStats: null, + sessionPhaseInfo: null, + sessionClaudeMdStats: null, sessionDetailError: null, }); diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts index 31ab421b..c1ce8d9a 100644 --- a/src/renderer/utils/contextTracker.ts +++ b/src/renderer/utils/contextTracker.ts @@ -1308,6 +1308,23 @@ 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 | 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; +} + /** * Process all chat items in a session and compute context stats with phase information. * Returns both the stats map and session-wide phase info. diff --git a/test/renderer/utils/contextTracker.test.ts b/test/renderer/utils/contextTracker.test.ts index 5c828018..b9b6b5d2 100644 --- a/test/renderer/utils/contextTracker.test.ts +++ b/test/renderer/utils/contextTracker.test.ts @@ -15,7 +15,11 @@ import { describe, expect, it } from 'vitest'; -import { processSessionContextWithPhases, classifyRounds } from '@renderer/utils/contextTracker'; +import { + processSessionContextWithPhases, + classifyRounds, + findLastTrackedAiGroupId, +} from '@renderer/utils/contextTracker'; import type { AIGroup, UserGroup } from '@renderer/types/groups'; import type { ChatItem } from '@renderer/types/groups'; @@ -405,3 +409,28 @@ describe('contextTracker compaction reset', () => { expect(stats!.tokensByCategory.loop).toBe(60_200); }); }); + +describe('findLastTrackedAiGroupId', () => { + it('returns the last AI group that has stats (not just the last AI group)', () => { + const items = [userGroup(), aiGroup('a', 0, [], []), userGroup(), aiGroup('b', 1, [], [])]; + const stats = new Map([['a', { accumulatedInjections: [] } as unknown as ContextStats]]); + expect(findLastTrackedAiGroupId(items, stats)).toBe('a'); + }); + + it('returns undefined when stats are empty', () => { + const items = [userGroup(), aiGroup('a', 0, [], [])]; + expect(findLastTrackedAiGroupId(items, new Map())).toBeUndefined(); + }); + + it('returns the last AI group when all of them are tracked', () => { + const items = [aiGroup('a', 0, [], []), aiGroup('b', 1, [], [])]; + const stats = new Map(['a', 'b'].map((id) => [id, {} as unknown as ContextStats])); + expect(findLastTrackedAiGroupId(items, stats)).toBe('b'); + }); + + it('skips non-AI items', () => { + const items = [aiGroup('a', 0, [], []), userGroup(), userGroup()]; + const stats = new Map([['a', {} as unknown as ContextStats]]); + expect(findLastTrackedAiGroupId(items, stats)).toBe('a'); + }); +}); From 3ea71cb20927514d3a49b5e3188045b61b9e689a Mon Sep 17 00:00:00 2001 From: axisrow Date: Wed, 7 Oct 2026 00:59:14 +0800 Subject: [PATCH 4/6] fix(main): don't count compact summaries as turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found during live verification (session 61e54b3a): the sidebar showed 36 turns while the chat showed 34 user turns — the two "This session is being continued..." compact-summary user lines were counted as turns, but the chat renders them as CompactBoundary, not user turns. Red: turn-count test with a compact-summary line in the fixture (5 != 4). Green: skip isCompactSummary lines, same rule as the chat pipeline. Co-Authored-By: Claude Code --- src/main/utils/jsonl.ts | 3 ++- test/main/utils/jsonl.test.ts | 23 +++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index a6dc9c34..042a6a6e 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -529,7 +529,8 @@ export async function analyzeSessionFileMetadata( if (isParsedUserChunkMessage(parsed)) { messageCount++; awaitingAIGroup = true; - if (!parsed.isSidechain) turnCount++; + // Compact summaries render as CompactBoundary, not a user turn + if (!parsed.isSidechain && !parsed.isCompactSummary) turnCount++; } else if ( awaitingAIGroup && parsed.type === 'assistant' && diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index 1aca4b21..62b9dfba 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -539,7 +539,8 @@ describe('jsonl', () => { uuid: string, parentUuid: string | null, content: string, - isSidechain = false + isSidechain = false, + isCompactSummary = false ): string => JSON.stringify({ type: 'user', @@ -548,6 +549,7 @@ describe('jsonl', () => { timestamp: '2026-01-01T00:00:00.000Z', isMeta: false, isSidechain, + ...(isCompactSummary ? { isCompactSummary: true } : {}), message: { role: 'user', content }, }); const assistant = (uuid: string, parentUuid: string, model: string): string => @@ -577,14 +579,27 @@ describe('jsonl', () => { user('side-u', 'a3', 'sidechat', true), // sidechain — skipped assistant('side-a', 'side-u', 'claude-fable-5-1'), user('u3', 'a3', 'more'), - assistant('a4', 'u3', 'claude-fable-5-1'), // still open — closed at EOF + assistant('a4', 'u3', 'claude-fable-5-1'), + // compact boundary: the summary user line is metadata, not a turn + user( + 'u3-cs', + 'a4', + 'This session is being continued from a previous conversation', + false, + true + ), + assistant('a5', 'u3-cs', 'claude-fable-5-1'), + user('u4', 'a5', 'after compact'), + assistant('a6', 'u4', 'claude-fable-5-1'), // still open — closed at EOF ]; fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); const result = await analyzeSessionFileMetadata(filePath); - // turns: u1, u2, u3 = 3 (system output and sidechain don't count) - expect(result.turnCount).toBe(3); + // turns: u1, u2, u3, u4 = 4 (system output, sidechain and the compact + // summary line don't count — the chat renders the summary as a + // CompactBoundary, not a user turn) + expect(result.turnCount).toBe(4); } finally { try { fs.rmSync(tempDir, { From 956f9f4175e605347de62d120ea4411fbec22f40 Mon Sep 17 00:00:00 2001 From: axisrow Date: Wed, 7 Oct 2026 02:55:29 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(renderer):=20review=20round=20=E2=80=94?= =?UTF-8?q?=20resolve=20worktree=20for=20the=20origin=20strip,=20harden=20?= =?UTF-8?q?stats=20hand-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressed verified findings from the PR #52 code review: - The origin strip above the chat read session.worktreeName from SessionDetail, but that field is only ever tagged onto sidebar LIST rows — the detail path never populates it, so non-main worktree sessions rendered "repo · main · branch" (wrong worktree) and the tooltip lost the worktree part. MiddlePanel now resolves repo/worktree from repositoryGroups with the same rule the sidebar tags use (resolveSessionOriginGroups; main worktree stays unnamed). - Both hand-rolled tooltip joins replaced with formatSessionOriginTooltip; local basename() replaced with the canonical getBaseName from pathUtils. - Phase 1 of fetchSessionDetail spreads the previous per-tab data only for the same session: selectSession reuses the active tab id for a NEW session, so the previous session's context stats could render next to the new conversation until Phase 2 replaced them. - With a context phase explicitly selected but its last AI group untracked (live refresh, stats not yet recomputed) the pill now renders empty instead of falling back to the last tracked group of another phase. Not addressed (documented trade-offs / follow-ups): duplicate "Turn N" numbers for two AI groups of the same user turn (affects the turnIndex navigation lookup and the injection id scheme "ai-N" — needs a group-id-based navigation/id redesign), live-refresh pill staleness until the next full fetch (root-cause fix: schedule Phase-2 recompute in refreshSessionInPlace), zero turnCount for agent-team sessions whose only inputs are teammate relays. Co-Authored-By: Claude Code --- src/renderer/components/chat/ChatHistory.tsx | 18 +++--- .../components/layout/MiddlePanel.tsx | 35 +++++++----- .../components/sidebar/SessionItem.tsx | 11 ++-- .../store/slices/sessionDetailSlice.ts | 7 ++- src/renderer/utils/formatSessionOrigin.ts | 47 ++++++++++++++-- .../utils/formatSessionOrigin.test.ts | 55 ++++++++++++++++++- 6 files changed, 138 insertions(+), 35 deletions(-) diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 83702443..6a5258ca 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -141,16 +141,20 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { let targetAiGroupId: string | undefined; if (effectivePhase !== null && sessionPhaseInfo) { const phase = sessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); - // The phase's last AI group must have stats; a live refresh can leave - // it untracked (stats recomputed on the next full fetch) — fall back - // to the last tracked group instead of rendering an empty pill + // The selected phase must resolve within itself: falling back to the + // last tracked group of ANOTHER phase would render foreign injections + // under the phase the user picked. Untracked → render empty (the stats + // recompute lands with the next full fetch). if (phase && sessionContextStats.has(phase.lastAIGroupId)) { targetAiGroupId = phase.lastAIGroupId; + } else { + return { + allContextInjections: [] as ContextInjection[], + lastAiGroupTotalTokens: undefined, + }; } - } - - // Default: the last AI group that actually has stats - if (!targetAiGroupId) { + } else if (!targetAiGroupId) { + // Default: the last AI group that actually has stats targetAiGroupId = findLastTrackedAiGroupId(conversation.items, sessionContextStats); if (!targetAiGroupId) { return { diff --git a/src/renderer/components/layout/MiddlePanel.tsx b/src/renderer/components/layout/MiddlePanel.tsx index 48cff484..cecaf9a6 100644 --- a/src/renderer/components/layout/MiddlePanel.tsx +++ b/src/renderer/components/layout/MiddlePanel.tsx @@ -5,7 +5,11 @@ import React from 'react'; import { useStore } from '@renderer/store'; -import { formatSessionOrigin } from '@renderer/utils/formatSessionOrigin'; +import { + formatSessionOrigin, + formatSessionOriginTooltip, + resolveSessionOriginGroups, +} from '@renderer/utils/formatSessionOrigin'; import { useShallow } from 'zustand/react/shallow'; import { ChatHistory } from '../chat/ChatHistory'; @@ -18,34 +22,37 @@ interface MiddlePanelProps { } export const MiddlePanel: React.FC = ({ tabId }) => { - const { session, repoName } = useStore( + const { session, repoName, worktreeName } = useStore( useShallow((s) => { const detail = (tabId ? s.tabSessionData[tabId]?.sessionDetail : undefined) ?? s.sessionDetail; const detailSession = detail?.session; - const detailRepoName = detailSession - ? s.repositoryGroups.find((g) => g.worktrees.some((w) => w.id === detailSession.projectId)) - ?.name - : undefined; - return { session: detailSession, repoName: detailRepoName }; + // 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 origin = formatSessionOrigin({ + const originInput = { projectPath: session?.projectPath, gitBranch: session?.gitBranch, - worktreeName: session?.worktreeName, + worktreeName, repoName, - }); + }; return (
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 18950236..6263b9a1 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -8,7 +8,10 @@ import React, { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useStore } from '@renderer/store'; -import { formatSessionOriginTag } from '@renderer/utils/formatSessionOrigin'; +import { + formatSessionOriginTag, + formatSessionOriginTooltip, +} from '@renderer/utils/formatSessionOrigin'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatDistanceToNowStrict } from 'date-fns'; import { EyeOff, Pin } from 'lucide-react'; @@ -351,11 +354,7 @@ export const SessionItem = React.memo(function SessionItem({ · {formatSessionOriginTag(session)} diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index ffed0a17..df6b1727 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -296,11 +296,16 @@ export const createSessionDetailSlice: StateCreator 0 ? tag : null; } + +/** + * Full origin parts for tooltips — same parts the tags show, unshortened. + * Empty/unknown parts are skipped, so callers can pass everything they have. + */ +export function formatSessionOriginTooltip(o: SessionOriginInput): string | undefined { + const tooltip = [o.worktreeName, o.gitBranch, o.projectPath] + .filter((part): part is string => Boolean(part)) + .join(SEPARATOR); + return tooltip.length > 0 ? tooltip : undefined; +} + +/** Worktree row of the grouped sidebar view, narrowed to what origin needs. */ +export interface WorktreeGroupRef { + name: string; + worktrees: { id: string; name: string; isMainWorktree?: boolean }[]; +} + +/** + * Derives repoName and worktreeName from the repository groups by the + * session's projectId. SessionDetail.session carries no worktreeName (it is + * only tagged onto sidebar list rows), so the strip must resolve it from the + * same groups the sidebar uses — main worktree stays unnamed, like the tags. + */ +export function resolveSessionOriginGroups( + groups: WorktreeGroupRef[], + projectId: string +): { repoName?: string; worktreeName?: string } { + for (const group of groups) { + const worktree = group.worktrees.find((w) => w.id === projectId); + if (worktree) { + return { + repoName: group.name, + worktreeName: worktree.isMainWorktree ? undefined : worktree.name, + }; + } + } + return {}; +} diff --git a/test/renderer/utils/formatSessionOrigin.test.ts b/test/renderer/utils/formatSessionOrigin.test.ts index 0761f543..362125a8 100644 --- a/test/renderer/utils/formatSessionOrigin.test.ts +++ b/test/renderer/utils/formatSessionOrigin.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { formatSessionOrigin, formatSessionOriginTag } from '@renderer/utils/formatSessionOrigin'; +import { + formatSessionOrigin, + formatSessionOriginTag, + formatSessionOriginTooltip, + resolveSessionOriginGroups, +} from '@renderer/utils/formatSessionOrigin'; describe('formatSessionOrigin', () => { it('renders repo · worktree · branch · shortened path for a main-worktree session', () => { @@ -50,3 +55,51 @@ describe('formatSessionOriginTag', () => { expect(formatSessionOriginTag({})).toBeNull(); }); }); + +describe('formatSessionOriginTooltip', () => { + it('joins all known parts unshortened', () => { + expect( + formatSessionOriginTooltip({ + worktreeName: 'wt-a', + gitBranch: 'feat', + projectPath: '/Users/x/repo', + }) + ).toBe('wt-a · feat · /Users/x/repo'); + }); + + it('skips unknown parts and returns undefined when empty', () => { + expect(formatSessionOriginTooltip({ gitBranch: 'main' })).toBe('main'); + expect(formatSessionOriginTooltip({})).toBeUndefined(); + }); +}); + +describe('resolveSessionOriginGroups', () => { + const groups = [ + { + name: 'agent-orchestrator', + worktrees: [ + { id: 'wt-main', name: 'main', isMainWorktree: true }, + { id: 'wt-76', name: 'agent-orchestrator-76' }, + ], + }, + { name: 'other-repo', worktrees: [{ id: 'wt-other', name: 'main', isMainWorktree: true }] }, + ]; + + it('leaves the main worktree unnamed — same rule as the sidebar tags', () => { + expect(resolveSessionOriginGroups(groups, 'wt-main')).toEqual({ + repoName: 'agent-orchestrator', + worktreeName: undefined, + }); + }); + + it('resolves a non-main worktree name for the detail path', () => { + expect(resolveSessionOriginGroups(groups, 'wt-76')).toEqual({ + repoName: 'agent-orchestrator', + worktreeName: 'agent-orchestrator-76', + }); + }); + + it('returns empty for an unknown project', () => { + expect(resolveSessionOriginGroups(groups, 'nope')).toEqual({}); + }); +}); From a612e6b92deb9e4bdccc00d7ae6f4cdb1dd99f57 Mon Sep 17 00:00:00 2001 From: axisrow Date: Wed, 7 Oct 2026 10:58:06 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(renderer):=20second=20review=20round=20?= =?UTF-8?q?=E2=80=94=20origin=20de-duplication,=20resolver=20extraction,?= =?UTF-8?q?=20reset=20completeness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressed verified findings from the second /code-review pass: - The origin strip no longer synthesizes a "main" worktree segment: it duplicated the branch for main-worktree sessions on branch "main" ("repo · main · main · ~/path" — the most common session shape) and asserted "main" for unresolvable origins in flat sidebar mode. The worktree segment renders only when explicitly known. - Turn-index policy (userCount-1 with AI-sequence fallback) extracted into aiTurnIndex — full and incremental transforms must number the same transcript identically; the policy now lives in one place with one comment. - The context pill's target-group resolution moved into contextTracker as resolveContextTargetAiGroupId (unit-tested without React: selected phase resolves within itself, untracked phase renders empty, no selection picks the last tracked group); the memo's dead `else if` condition removed and its empty result deduplicated into EMPTY_CONTEXT_RESULT. - clearSelection and the shared session reset helper now also clear sessionPhaseInfo / sessionClaudeMdStats — same incomplete-reset class the selectSession hardening fixed. - jsonl turnCount doc comment, the awaitingAIGroup comment and the SessionItem turns tooltip no longer promise "same count as Turn chips" for a session whose last turn has no response; tooltip states the rule. - Parity test hardened: an assistant line after the trailing empty turn makes AI-run counting give 4 while user-message counting gives 3 — a regression to AI-run semantics now fails the test instead of passing it. Refuted during verification: switching the scanner's turn predicate to categorizeMessage would CHANGE semantics — isParsedUserChunkMessage wraps the canonical isUserChunkLine shared with the turn-budget hook (system-tag prefixed lines are excluded by both), so scanner and hook stay aligned by construction. Chunk-factory hoisting declined: the two test factory sets look alike but differ (seq ids, rawMessages, inline userMessage); the shape drift it guards against is caught by typecheck. Known follow-ups unchanged: #53 (turnIndex uniqueness for navigation and injection ids), #54 (stats recompute in the refresh path; this pass also showed the pill's token cost freezes with it — noted there). Co-Authored-By: Claude Code --- src/main/utils/jsonl.ts | 12 +++-- src/renderer/components/chat/ChatHistory.tsx | 48 +++++++------------ .../components/sidebar/SessionItem.tsx | 7 +-- src/renderer/store/slices/sessionSlice.ts | 2 + src/renderer/store/utils/stateResetHelpers.ts | 2 + src/renderer/utils/contextTracker.ts | 20 ++++++++ src/renderer/utils/formatSessionOrigin.ts | 7 ++- src/renderer/utils/groupTransformer.ts | 15 ++++-- test/main/utils/jsonl.test.ts | 9 +++- test/renderer/utils/contextTracker.test.ts | 32 ++++++++++++- .../utils/formatSessionOrigin.test.ts | 6 +-- 11 files changed, 112 insertions(+), 48 deletions(-) diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 042a6a6e..f500da32 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -428,7 +428,9 @@ export interface SessionFileMetadata { phaseBreakdown?: PhaseTokenBreakdown[]; /** Total spend: sum of all assistant usage in this transcript (in+cache+out) */ totalTokens: number; - /** User turns (transcript user messages) — 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; } @@ -466,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 the transcript: one user message (isUserChunkLine - // semantics, sidechain excluded) == one turn == one "Turn N" chip, whether - // or not it produced a response. + // 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; diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 6a5258ca..670e8358 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -6,7 +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 { findLastTrackedAiGroupId } from '@renderer/utils/contextTracker'; +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'; @@ -27,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. */ @@ -131,37 +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); - // The selected phase must resolve within itself: falling back to the - // last tracked group of ANOTHER phase would render foreign injections - // under the phase the user picked. Untracked → render empty (the stats - // recompute lands with the next full fetch). - if (phase && sessionContextStats.has(phase.lastAIGroupId)) { - targetAiGroupId = phase.lastAIGroupId; - } else { - return { - allContextInjections: [] as ContextInjection[], - lastAiGroupTotalTokens: undefined, - }; - } - } else if (!targetAiGroupId) { - // Default: the last AI group that actually has stats - targetAiGroupId = findLastTrackedAiGroupId(conversation.items, sessionContextStats); - if (!targetAiGroupId) { - return { - allContextInjections: [] as ContextInjection[], - lastAiGroupTotalTokens: undefined, - }; - } + // 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) { + return EMPTY_CONTEXT_RESULT; } const stats = sessionContextStats.get(targetAiGroupId); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 6263b9a1..0e2073e0 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -267,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 ( <>