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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ webview-ui/coverage/

# Claude Code — local-only settings (personal overrides, not for team)
.claude/settings.local.json
.claude/.headroom_wrap_marker.json
.tokensave
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ Data flows one way: `JSONL files → FileWatcher → DashboardStore → Panel.bu

| Component | Used in |
|---|---|
| `SessionDetail.tsx` (+ `modelLabel`, `modelBadgeColor`) | ProjectDetail → Sessions tab; includes the copy-resume-command button |
| `SessionDetail.tsx` (+ `modelLabel`, `modelBadgeColor`) | ProjectDetail → Sessions tab; session meta header + copy-resume-command button; renders turns via `conversation/` |
| `conversation/` (`ConversationTurn`, `ResponseGroup`, `UserMessageCard`, `ThinkingRow`, `ToolCallRow`, `AgentCallBlock`, `SkillContextRow`, `SystemEventRow`, `shared.tsx`, `systemEvents.ts`) | Claude-Code-extension-style conversation timeline: user prompt cards ("You" header + accent border), tool-colored dots joined by connector lines (everything between two user prompts merges into one `ResponseGroup`), tool "OUT" output boxes, project-relative file hints, show-less-by-default messages |
| `SessionsBrowser.tsx` | Dashboard → Sessions tab (cross-project rows + backend-served prompt search) |
| `WeeklyStatsTab.tsx` | ProjectDetail → Activity (usage trend) |
| `MarkdownView.tsx` (+ `CommandBlock`) | ProjectDetail → Setup (CLAUDE.md, memory, commands) |
Expand Down Expand Up @@ -156,7 +157,7 @@ Session turns are **lazy-loaded**: initial state ships sessions with `turns: []`

- **Project** — `{ id, name, path, lastActive, isActive, sessionCount, totalTokens, totalCostUsd, techStack[] }`
- **Session** — `{ id, projectId, parentSessionId, startTime, endTime, durationMs, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, costUsd, promptCount, toolCallCount, filesModified[], filesCreated[], turns[], sessionSummary, hasThinking, thinkingTokens, cacheHitRate, subagentCostUsd, idleTimeMs, activeTimeMs, activityRatio, model }`
- **Turn** — `{ id, role, content, inputTokens, outputTokens, toolCalls[], timestamp }`
- **Turn** — `{ id, role, content, inputTokens, outputTokens, toolCalls[], timestamp, thinking? }`
- **ToolCall** — `{ id, name, input, output?, mcpServer? }`
- **ProjectConfig** — `{ claudeMd, mcpServers, projectSettings, commands[], memory, hooks[] }`
- **ProjectStats** — `{ usageOverTime[], toolUsage[], promptPatterns[], efficiency, recentToolCalls[], weeklyStats }`
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/fixtures/jsonl-samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,52 @@ export const SESSION_ARRAY_CONTENT = [
},
}),
].join('\n');

export const SESSION_WITH_TOOL_RESULTS = [
JSON.stringify({
type: 'user',
uuid: 'u1',
timestamp: '2025-01-15T10:00:00Z',
cwd: '/home/user/project',
message: { content: 'Search the web' },
}),
JSON.stringify({
type: 'assistant',
uuid: 'a1',
timestamp: '2025-01-15T10:00:30Z',
message: {
model: 'claude-sonnet-4',
content: [
{ type: 'thinking', thinking: 'Let me plan the search first.', thinking_tokens: 400 },
{ type: 'text', text: 'Searching now.' },
{ type: 'tool_use', id: 'ws1', name: 'WebSearch', input: { query: 'persistent memory' } },
{ type: 'tool_use', id: 'ws2', name: 'Bash', input: { command: 'ls' } },
],
usage: { input_tokens: 200, output_tokens: 100 },
stop_reason: 'tool_use',
},
}),
JSON.stringify({
type: 'user',
uuid: 'tr1',
timestamp: '2025-01-15T10:00:40Z',
message: {
content: [
{ type: 'tool_result', tool_use_id: 'ws1', content: [{ type: 'text', text: ' Result A\nResult B ' }] },
{ type: 'tool_result', tool_use_id: 'ws2', content: 'file1.ts\nfile2.ts' },
{ type: 'tool_result', tool_use_id: 'unknown-id', content: 'orphan' },
],
},
}),
JSON.stringify({
type: 'assistant',
uuid: 'a2',
timestamp: '2025-01-15T10:01:00Z',
message: {
model: 'claude-sonnet-4',
content: [{ type: 'text', text: 'Found it.' }],
usage: { input_tokens: 300, output_tokens: 80 },
stop_reason: 'end_turn',
},
}),
].join('\n');
57 changes: 45 additions & 12 deletions src/parsers/SessionParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export interface ParsedSession extends Session {
isActiveSession: boolean;
}

// Turns ship to the webview over postMessage — cap the bulky per-turn extras
// so a session with huge tool outputs or long thinking stays lightweight.
const TOOL_OUTPUT_CAP = 2500;
const THINKING_TEXT_CAP = 10000;

export class SessionParser {
/** Read just the cwd from the first entry in a JSONL file */
readCwd(filePath: string): string | null {
Expand Down Expand Up @@ -75,6 +80,8 @@ export class SessionParser {
// File attachments appear as separate entries right after the user
// message that @-tagged them; buffer any that arrive before their turn.
let pendingAttachments: TurnAttachment[] = [];
// Tool results echo back as later user entries referencing tool_use_id.
const toolCallById = new Map<string, ToolCall>();

for (const line of lines) {
try {
Expand All @@ -90,6 +97,24 @@ export class SessionParser {
if (!startTime && ts) { startTime = ts; }

const rawContent = entry.message.content;

// Attach tool results to the originating tool call — they are
// echoes of assistant activity, not user prompts.
const toolResults = Array.isArray(rawContent)
? rawContent.filter((c: any) => c.type === 'tool_result')
: [];
for (const tr of toolResults) {
const target = toolCallById.get(tr.tool_use_id);
if (!target || target.output !== undefined) { continue; }
const raw = typeof tr.content === 'string'
? tr.content
: Array.isArray(tr.content)
? tr.content.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('\n')
: '';
const trimmed = raw.trim();
if (trimmed) { target.output = trimmed.slice(0, TOOL_OUTPUT_CAP); }
}

const text = Array.isArray(rawContent)
? rawContent.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('')
: typeof rawContent === 'string' ? rawContent : '';
Expand All @@ -107,17 +132,20 @@ export class SessionParser {
sessionSummary = displayText.slice(0, 120) + (displayText.length > 120 ? '…' : '');
}

turns.push({
id: entry.uuid || String(ts),
role: 'user',
content: displayText,
inputTokens: 0,
outputTokens: 0,
toolCalls: [],
timestamp: ts,
...(pendingAttachments.length > 0 ? { attachments: pendingAttachments } : {}),
});
pendingAttachments = [];
// Pure tool-result entries are not prompts — skip the turn entirely.
if (toolResults.length === 0 || displayText.length > 0) {
turns.push({
id: entry.uuid || String(ts),
role: 'user',
content: displayText,
inputTokens: 0,
outputTokens: 0,
toolCalls: [],
timestamp: ts,
...(pendingAttachments.length > 0 ? { attachments: pendingAttachments } : {}),
});
pendingAttachments = [];
}
}

if (entry.type === 'attachment' && entry.attachment?.type === 'file') {
Expand Down Expand Up @@ -154,11 +182,13 @@ export class SessionParser {
const toolCalls: ToolCall[] = [];
const contentBlocks = Array.isArray(entry.message.content) ? entry.message.content : [];
let textContent = '';
let turnThinking = '';

for (const block of contentBlocks) {
if (block.type === 'text') { textContent += block.text; }
if (block.type === 'thinking') {
hasThinking = true;
if (typeof block.thinking === 'string') { turnThinking += block.thinking; }
// thinking blocks may report their own token count
if (typeof block.thinking_tokens === 'number') {
thinkingTokens += block.thinking_tokens as number;
Expand All @@ -169,7 +199,9 @@ export class SessionParser {
const mcpServer = typeof block.name === 'string' && block.name.startsWith('mcp__')
? (block.name.split('__')[1] ?? undefined)
: undefined;
toolCalls.push({ id: block.id, name: block.name, input: block.input ?? {}, mcpServer });
const call: ToolCall = { id: block.id, name: block.name, input: block.input ?? {}, mcpServer };
toolCalls.push(call);
if (block.id) { toolCallById.set(block.id, call); }

if (block.name === 'Write') {
const fp = block.input?.file_path as string | undefined;
Expand All @@ -189,6 +221,7 @@ export class SessionParser {
outputTokens: outTok,
toolCalls,
timestamp: ts,
...(turnThinking.trim() ? { thinking: turnThinking.trim().slice(0, THINKING_TEXT_CAP) } : {}),
});
}
} catch { /* skip malformed lines */ }
Expand Down
19 changes: 19 additions & 0 deletions src/parsers/__tests__/SessionParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SESSION_WITH_MCP,
SESSION_WITH_THINKING,
SESSION_WITH_TOOLS,
SESSION_WITH_TOOL_RESULTS,
} from '../../__tests__/fixtures/jsonl-samples';

vi.mock('fs');
Expand Down Expand Up @@ -186,6 +187,24 @@ describe('SessionParser', () => {
expect(thinkingResult?.model).toBe('claude-opus-4');
});

it('attaches tool outputs, captures thinking text, and skips tool-result turns', () => {
vi.mocked(fs.readFileSync).mockReturnValue(asReadResult(SESSION_WITH_TOOL_RESULTS));
const result = parser.parseFile('/sessions/results.jsonl', 'proj-1');

// The tool-result user entry must not become a turn or count as a prompt
expect(result?.turns.map(t => t.role)).toEqual(['user', 'assistant', 'assistant']);
expect(result?.promptCount).toBe(1);

const [webSearch, bash] = result!.turns[1].toolCalls;
expect(webSearch.output).toBe('Result A\nResult B');
expect(bash.output).toBe('file1.ts\nfile2.ts');

expect(result?.turns[1].thinking).toBe('Let me plan the search first.');
expect(result?.turns[2].thinking).toBeUndefined();
expect(result?.hasThinking).toBe(true);
expect(result?.thinkingTokens).toBe(400);
});

it('computes idle, active, and duration metrics', () => {
const idleSession = [
JSON.stringify({ type: 'user', uuid: 'u1', timestamp: '2025-01-15T10:00:00Z', cwd: '/test', message: { content: 'Q1' } }),
Expand Down
1 change: 1 addition & 0 deletions src/store/DashboardStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export interface Turn {
toolCalls: ToolCall[];
timestamp: number;
attachments?: TurnAttachment[]; // files the user @-tagged in this prompt
thinking?: string; // extended-thinking text emitted before this turn's content
}

export interface TurnAttachment {
Expand Down
Loading
Loading