diff --git a/package-lock.json b/package-lock.json index 03855b3..b0094a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "claude-code-dashboard", - "version": "1.4.0", + "version": "1.6.1", "license": "AGPL-3.0-only", "devDependencies": { "@types/node": "^20.0.0", diff --git a/src/__tests__/fixtures/jsonl-samples.ts b/src/__tests__/fixtures/jsonl-samples.ts index d1be904..347191e 100644 --- a/src/__tests__/fixtures/jsonl-samples.ts +++ b/src/__tests__/fixtures/jsonl-samples.ts @@ -21,6 +21,80 @@ export const MINIMAL_SESSION = [ }), ].join('\n'); +export const SESSION_WITH_IDE_TAGS = [ + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2025-01-15T10:00:00Z', + cwd: '/home/user/project', + message: { + content: + 'The user opened the file /home/user/project/package.json in the IDE.' + + 'The user selected lines 1 to 3Fix the build script', + }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'a1', + timestamp: '2025-01-15T10:01:00Z', + message: { + model: 'claude-sonnet-4', + content: [{ type: 'text', text: 'On it.' }], + usage: { input_tokens: 10, output_tokens: 5 }, + stop_reason: 'end_turn', + }, + }), +].join('\n'); + +export const SESSION_WITH_ATTACHMENTS = [ + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2025-01-15T10:00:00Z', + cwd: '/home/user/project', + message: { content: 'Review @docs/plan.md and @src/index.ts please' }, + }), + JSON.stringify({ + type: 'attachment', + uuid: 'att1', + timestamp: '2025-01-15T10:00:00Z', + attachment: { + type: 'file', + filename: '/home/user/project/docs/plan.md', + displayPath: 'docs/plan.md', + content: { type: 'text', file: { filePath: '/home/user/project/docs/plan.md', content: '# Plan' } }, + }, + }), + JSON.stringify({ + type: 'attachment', + uuid: 'att2', + timestamp: '2025-01-15T10:00:00Z', + attachment: { + type: 'file', + filename: '/home/user/project/src/index.ts', + displayPath: 'src/index.ts', + content: { type: 'text', file: { filePath: '/home/user/project/src/index.ts', content: 'export {};' } }, + }, + }), + JSON.stringify({ + type: 'attachment', + uuid: 'att3', + timestamp: '2025-01-15T10:00:00Z', + attachment: { type: 'skill_listing', skills: [] }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'a1', + timestamp: '2025-01-15T10:01:00Z', + message: { + model: 'claude-sonnet-4', + content: [{ type: 'text', text: 'Reviewed.' }], + usage: { input_tokens: 100, output_tokens: 50 }, + stop_reason: 'end_turn', + }, + }), +].join('\n'); + export const SESSION_WITH_TOOLS = [ JSON.stringify({ type: 'user', diff --git a/src/parsers/SessionParser.ts b/src/parsers/SessionParser.ts index 6c1d6bf..d79d0b7 100644 --- a/src/parsers/SessionParser.ts +++ b/src/parsers/SessionParser.ts @@ -1,5 +1,5 @@ import * as fs from 'fs'; -import { Session, Turn, ToolCall } from '../store/DashboardStore'; +import { Session, Turn, ToolCall, TurnAttachment } from '../store/DashboardStore'; const MODEL_COSTS: Record = { 'claude-opus-4': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 }, @@ -57,6 +57,9 @@ export class SessionParser { let sessionSummary: string | null = null; let hasThinking = false; let thinkingTokens = 0; + // File attachments appear as separate entries right after the user + // message that @-tagged them; buffer any that arrive before their turn. + let pendingAttachments: TurnAttachment[] = []; for (const line of lines) { try { @@ -76,8 +79,12 @@ export class SessionParser { ? rawContent.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('') : typeof rawContent === 'string' ? rawContent : ''; - // Skip internal command messages for turn content - const displayText = text.replace(/.*?<\/command-message>/gs, '').trim(); + // Skip internal command messages and IDE-injected context for turn content + const displayText = text + .replace(/.*?<\/command-message>/gs, '') + .replace(/.*?<\/ide_opened_file>/gs, '') + .replace(/.*?<\/ide_selection>/gs, '') + .trim(); // Capture first meaningful user prompt as session summary if (!sessionSummary && displayText.length > 0) { @@ -92,7 +99,25 @@ export class SessionParser { outputTokens: 0, toolCalls: [], timestamp: ts, + ...(pendingAttachments.length > 0 ? { attachments: pendingAttachments } : {}), }); + pendingAttachments = []; + } + + if (entry.type === 'attachment' && entry.attachment?.type === 'file') { + const path = (entry.attachment.filename || entry.attachment.displayPath || '') as string; + const displayPath = (entry.attachment.displayPath || entry.attachment.filename || '') as string; + if (path) { + const lastTurn = turns[turns.length - 1]; + if (lastTurn && lastTurn.role === 'user') { + lastTurn.attachments = lastTurn.attachments ?? []; + if (!lastTurn.attachments.some(a => a.path === path)) { + lastTurn.attachments.push({ path, displayPath }); + } + } else if (!pendingAttachments.some(a => a.path === path)) { + pendingAttachments.push({ path, displayPath }); + } + } } if (entry.type === 'assistant' && entry.message) { diff --git a/src/parsers/__tests__/SessionParser.test.ts b/src/parsers/__tests__/SessionParser.test.ts index 7e6276c..2ce1b7c 100644 --- a/src/parsers/__tests__/SessionParser.test.ts +++ b/src/parsers/__tests__/SessionParser.test.ts @@ -7,6 +7,8 @@ import { MINIMAL_SESSION, MULTI_TURN_SESSION, SESSION_ARRAY_CONTENT, + SESSION_WITH_ATTACHMENTS, + SESSION_WITH_IDE_TAGS, SESSION_WITH_CACHE, SESSION_WITH_COMMAND_MESSAGE, SESSION_WITH_MCP, @@ -79,6 +81,78 @@ describe('SessionParser', () => { expect(multiResult?.promptCount).toBe(2); }); + it('strips IDE-injected context tags from user turns and summaries', () => { + vi.mocked(fs.readFileSync).mockReturnValue(asReadResult(SESSION_WITH_IDE_TAGS)); + const result = parser.parseFile('/sessions/ide.jsonl', 'proj-1'); + + expect(result?.turns[0].content).toBe('Fix the build script'); + expect(result?.sessionSummary).toBe('Fix the build script'); + }); + + it('attaches @-tagged files to the preceding user turn', () => { + vi.mocked(fs.readFileSync).mockReturnValue(asReadResult(SESSION_WITH_ATTACHMENTS)); + const result = parser.parseFile('/sessions/attach.jsonl', 'proj-1'); + + expect(result?.turns).toHaveLength(2); + expect(result?.turns[0].attachments).toEqual([ + { path: '/home/user/project/docs/plan.md', displayPath: 'docs/plan.md' }, + { path: '/home/user/project/src/index.ts', displayPath: 'src/index.ts' }, + ]); + // non-file attachments (skill listings etc.) are ignored + expect(result?.turns[1].attachments).toBeUndefined(); + }); + + it('buffers file attachments that arrive before their user turn', () => { + const session = [ + JSON.stringify({ + type: 'attachment', + uuid: 'att-early', + timestamp: '2025-01-15T09:59:59Z', + cwd: '/home/user/project', + attachment: { + type: 'file', + filename: '/home/user/project/notes.md', + displayPath: 'notes.md', + }, + }), + JSON.stringify({ + type: 'attachment', + uuid: 'att-early-dup', + timestamp: '2025-01-15T09:59:59Z', + attachment: { + type: 'file', + filename: '/home/user/project/notes.md', + displayPath: 'notes.md', + }, + }), + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2025-01-15T10:00:00Z', + message: { content: 'See @notes.md' }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'a1', + timestamp: '2025-01-15T10:00:30Z', + message: { + model: 'claude-sonnet-4', + content: [{ type: 'text', text: 'Ok' }], + usage: { input_tokens: 10, output_tokens: 5 }, + stop_reason: 'end_turn', + }, + }), + ].join('\n'); + vi.mocked(fs.readFileSync).mockReturnValue(asReadResult(session)); + + const result = parser.parseFile('/sessions/pending.jsonl', 'proj-1'); + + expect(result?.turns[0].role).toBe('user'); + expect(result?.turns[0].attachments).toEqual([ + { path: '/home/user/project/notes.md', displayPath: 'notes.md' }, + ]); + }); + it('tracks tool calls, modified files, created files, and MCP server names', () => { vi.mocked(fs.readFileSync) .mockReturnValueOnce(asReadResult(SESSION_WITH_TOOLS)) diff --git a/src/store/DashboardStore.ts b/src/store/DashboardStore.ts index 8137cc7..2db2256 100644 --- a/src/store/DashboardStore.ts +++ b/src/store/DashboardStore.ts @@ -120,6 +120,12 @@ export interface Turn { outputTokens: number; toolCalls: ToolCall[]; timestamp: number; + attachments?: TurnAttachment[]; // files the user @-tagged in this prompt +} + +export interface TurnAttachment { + path: string; // absolute file path + displayPath: string; // project-relative path as shown in the prompt } export interface ToolCall { diff --git a/webview-ui/src/components/MarkdownView.tsx b/webview-ui/src/components/MarkdownView.tsx index e16d30a..8816b6d 100644 --- a/webview-ui/src/components/MarkdownView.tsx +++ b/webview-ui/src/components/MarkdownView.tsx @@ -1,10 +1,39 @@ import React from 'react'; +// Matches @-tagged file paths in prompts (e.g. @src/index.ts); the lookbehind +// keeps emails (user@host) and mid-word @ from matching. +const MENTION_TOKEN_RE = /(? + {text} + + ); +} + +/** Plain one-line text with @file mentions highlighted (no markdown parsing). */ +export function MentionText({ text }: { text: string }) { + const parts = text.split(/((? + {parts.map((part, i) => + part.startsWith('@') && MENTION_TOKEN_RE.test(part) + ? + : part + )} + + ); +} + function renderInline( text: string, onLinkClick?: (href: string) => void, + highlightMentions = false, ): React.ReactNode[] { - const parts = text.split(/(`[^`]+`|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*)/); + const parts = text.split(highlightMentions ? INLINE_WITH_MENTIONS_RE : INLINE_RE); return parts.map((part, i) => { if (!part) { return null; @@ -12,6 +41,9 @@ function renderInline( if (part.startsWith('`') && part.endsWith('`') && part.length > 2) { return {part.slice(1, -1)}; } + if (highlightMentions && part.startsWith('@') && MENTION_TOKEN_RE.test(part)) { + return ; + } const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/); if (linkMatch) { const [, label, href] = linkMatch; @@ -51,10 +83,12 @@ export function MarkdownView({ content, compact = false, onLinkClick, + highlightMentions = false, }: { content: string; compact?: boolean; onLinkClick?: (href: string) => void; + highlightMentions?: boolean; }) { const lines = content.split('\n'); const elements: React.ReactNode[] = []; @@ -81,15 +115,15 @@ export function MarkdownView({ const h3 = line.match(/^### (.+)/); const h2 = line.match(/^## (.+)/); const h1 = line.match(/^# (.+)/); - if (h1) { elements.push(

{renderInline(h1[1], onLinkClick)}

); i++; continue; } - if (h2) { elements.push(

{renderInline(h2[1], onLinkClick)}

); i++; continue; } - if (h3) { elements.push(

{renderInline(h3[1], onLinkClick)}

); i++; continue; } + if (h1) { elements.push(

{renderInline(h1[1], onLinkClick, highlightMentions)}

); i++; continue; } + if (h2) { elements.push(

{renderInline(h2[1], onLinkClick, highlightMentions)}

); i++; continue; } + if (h3) { elements.push(

{renderInline(h3[1], onLinkClick, highlightMentions)}

); i++; continue; } // Bullet list if (line.match(/^[\-\*] /)) { const items: React.ReactNode[] = []; while (i < lines.length && lines[i].match(/^[\-\*] /)) { - items.push(
  • {renderInline(lines[i].slice(2), onLinkClick)}
  • ); + items.push(
  • {renderInline(lines[i].slice(2), onLinkClick, highlightMentions)}
  • ); i++; } elements.push(
      {items}
    ); @@ -100,7 +134,7 @@ export function MarkdownView({ if (line.match(/^\d+\. /)) { const items: React.ReactNode[] = []; while (i < lines.length && lines[i].match(/^\d+\. /)) { - items.push(
  • {renderInline(lines[i].replace(/^\d+\. /, ''), onLinkClick)}
  • ); + items.push(
  • {renderInline(lines[i].replace(/^\d+\. /, ''), onLinkClick, highlightMentions)}
  • ); i++; } elements.push(
      {items}
    ); @@ -128,7 +162,7 @@ export function MarkdownView({ !lines[i].match(/^---+$/) ) { paraLines.push(lines[i]); i++; } if (paraLines.length) { - elements.push(

    {renderInline(paraLines.join(' '), onLinkClick)}

    ); + elements.push(

    {renderInline(paraLines.join(' '), onLinkClick, highlightMentions)}

    ); } } diff --git a/webview-ui/src/components/SessionDetail.tsx b/webview-ui/src/components/SessionDetail.tsx index d62dd9d..04e01d4 100644 --- a/webview-ui/src/components/SessionDetail.tsx +++ b/webview-ui/src/components/SessionDetail.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Session, Turn, ToolCall } from '../types'; +import { Session, Turn, ToolCall, TurnAttachment } from '../types'; import { formatTokens, formatDuration } from '../utils/format'; import { toolColor } from '../utils/toolColor'; import { MarkdownView } from './MarkdownView'; @@ -180,13 +180,33 @@ function AgentCallBlock({ tc, tokenInfo }: { tc: ToolCall; tokenInfo?: { input: ); } +function AttachmentChips({ attachments }: { attachments: TurnAttachment[] }) { + return ( +
    + {attachments.map(a => ( + + + {a.displayPath || a.path} + + ))} +
    + ); +} + function TurnBlock({ turn }: { turn: Turn }) { const [collapsed, setCollapsed] = React.useState(false); const content = turn.content?.trim() ?? ''; const hasContent = content.length > 0; const hasTools = turn.toolCalls.length > 0; + const attachments = turn.attachments ?? []; - if (!hasContent && !hasTools) return null; + if (!hasContent && !hasTools && attachments.length === 0) return null; if (turn.role === 'user' && hasContent) { const sys = parseSystemContent(content); @@ -223,12 +243,26 @@ function TurnBlock({ turn }: { turn: Turn }) { const isUser = turn.role === 'user'; return ( -
    -
    +
    +
    - - {isUser ? 'You' : 'Claude'} - + {isUser ? ( + + + You + + ) : ( + + + Claude + + )}
    {hasContent && } setCollapsed(c => !c)} /> @@ -236,9 +270,10 @@ function TurnBlock({ turn }: { turn: Turn }) {
    {!collapsed && ( <> + {attachments.length > 0 && } {hasContent && (
    - +
    )} {regularCalls.length > 0 && ( diff --git a/webview-ui/src/components/__tests__/MarkdownView.test.tsx b/webview-ui/src/components/__tests__/MarkdownView.test.tsx index 65ab6a4..3ba63f2 100644 --- a/webview-ui/src/components/__tests__/MarkdownView.test.tsx +++ b/webview-ui/src/components/__tests__/MarkdownView.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '../../__tests__/helpers/render-helpers'; -import { CommandBlock, MarkdownView } from '../MarkdownView'; +import { CommandBlock, MarkdownView, MentionText } from '../MarkdownView'; describe('MarkdownView', () => { it('renders headings, emphasis, code blocks, and lists', () => { @@ -23,6 +23,21 @@ describe('MarkdownView', () => { expect(screen.getByText('now')).toBeInTheDocument(); }); + it('highlights @file mentions only when enabled, leaving emails alone', () => { + const { rerender } = render(); + expect(screen.getByText('@docs/plan.md')).toHaveClass('font-mono'); + expect(screen.queryByText('@example.com')).not.toBeInTheDocument(); + + rerender(); + expect(screen.queryByText('@docs/plan.md')).not.toBeInTheDocument(); + }); + + it('renders mention chips in plain text via MentionText', () => { + render(
    ); + expect(screen.getByText('@src/index.ts')).toHaveClass('font-mono'); + expect(screen.getByTestId('summary')).toHaveTextContent('Fix @src/index.ts now'); + }); + it('renders markdown links and forwards link clicks', () => { const onLinkClick = vi.fn(); render(); diff --git a/webview-ui/src/components/__tests__/SessionDetail.test.tsx b/webview-ui/src/components/__tests__/SessionDetail.test.tsx index fc1a3dd..dd3fc32 100644 --- a/webview-ui/src/components/__tests__/SessionDetail.test.tsx +++ b/webview-ui/src/components/__tests__/SessionDetail.test.tsx @@ -72,6 +72,24 @@ describe('SessionDetail', () => { expect(modelBadgeColor(null)).toBe(''); }); + it('renders @-tagged file chips on user turns', () => { + const session = makeSession({ + turns: [ + makeTurn({ + role: 'user', + content: 'Review @docs/plan.md please', + attachments: [{ path: '/home/user/project/docs/plan.md', displayPath: 'docs/plan.md' }], + timestamp: 1, + }), + ], + }); + + render(); + const chip = screen.getByText('docs/plan.md'); + expect(chip).toBeInTheDocument(); + expect(chip.closest('span[title]')).toHaveAttribute('title', '/home/user/project/docs/plan.md'); + }); + it('renders cache, thinking, subagent cost, collapsed previews, and tool-only assistant turns', () => { const longContent = 'A'.repeat(140); const session = makeSession({ diff --git a/webview-ui/src/types.ts b/webview-ui/src/types.ts index 1475c6c..ab1a735 100644 --- a/webview-ui/src/types.ts +++ b/webview-ui/src/types.ts @@ -49,6 +49,12 @@ export interface Turn { outputTokens: number; toolCalls: ToolCall[]; timestamp: number; + attachments?: TurnAttachment[]; // files the user @-tagged in this prompt +} + +export interface TurnAttachment { + path: string; // absolute file path + displayPath: string; // project-relative path as shown in the prompt } export interface ToolCall { diff --git a/webview-ui/src/views/ProjectDetail.tsx b/webview-ui/src/views/ProjectDetail.tsx index d86d36a..02bdc6d 100644 --- a/webview-ui/src/views/ProjectDetail.tsx +++ b/webview-ui/src/views/ProjectDetail.tsx @@ -3,7 +3,7 @@ import { Project, Session, Turn, ProjectConfig, McpServer, ProjectStats, Project import { vscode } from '../vscode'; import { formatTokens, formatDuration, timeAgo } from '../utils/format'; import { toolColor } from '../utils/toolColor'; -import { MarkdownView, CommandBlock } from '../components/MarkdownView'; +import { MarkdownView, CommandBlock, MentionText } from '../components/MarkdownView'; import SessionDetail from '../components/SessionDetail'; import WeeklyStatsTab from '../components/WeeklyStatsTab'; import ToolUsageBar from '../components/ToolUsageBar'; @@ -73,6 +73,7 @@ export default function ProjectDetail({ project, sessions, subagentSessions, con const [selectedMemoryFileName, setSelectedMemoryFileName] = useState(null); const [selectedPlanFileName, setSelectedPlanFileName] = useState(null); const memoryPreviewRef = useRef(null); + const sessionDetailRef = useRef(null); const sorted = [...(sessions ?? [])].sort((a, b) => b.startTime - a.startTime); useEffect(() => { @@ -92,6 +93,9 @@ export default function ProjectDetail({ project, sessions, subagentSessions, con setTurns([]); setTurnsLoading(true); vscode.postMessage({ type: 'getSessionTurns', sessionId: session.id }); + requestAnimationFrame(() => { + sessionDetailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); }, []); if (!project) { @@ -287,7 +291,7 @@ export default function ProjectDetail({ project, sessions, subagentSessions, con {/* ── Sessions tab ── */} {activeTab === 'sessions' && (
    -
    +

    Sessions

    {sorted.map(s => ( @@ -310,16 +314,27 @@ export default function ProjectDetail({ project, sessions, subagentSessions, con {(s.subagentCostUsd ?? 0) > 0 && +sub}
    {s.sessionSummary && ( -
    {s.sessionSummary}
    +
    )} ))}
    -
    +
    {selectedSession ? ( - + <> + + + ) : (
    Select a session to view details