diff --git a/src/components/chat/utils/__tests__/sessionContextSummary.test.ts b/src/components/chat/utils/__tests__/sessionContextSummary.test.ts index 91770edc..ceb030f4 100644 --- a/src/components/chat/utils/__tests__/sessionContextSummary.test.ts +++ b/src/components/chat/utils/__tests__/sessionContextSummary.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - deriveSessionContextSummary, - mergeDistinctChatMessages, - resolveSessionContextProjectRoot, -} from '../sessionContextSummary'; +import { deriveSessionContextSummary, mergeDistinctChatMessages } from '../sessionContextSummary'; describe('deriveSessionContextSummary', () => { const projectRoot = '/workspace/demo'; @@ -106,225 +102,6 @@ describe('deriveSessionContextSummary', () => { expect(summary.outputFiles[0].relativePath).toBe('outputs/report.md'); expect(summary.outputFiles[0].unread).toBe(true); }); - - it('recognizes Codex shell reads, plans, patch outputs, and web actions', () => { - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-30T05:18:28.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: "sed -n '1,200p' src/components/chat/utils/sessionContextSummary.ts", - workdir: projectRoot, - }), - toolResult: { - content: 'const summary = true;', - isError: false, - }, - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:19:00.000Z', - isToolUse: true, - toolName: 'UpdatePlan', - toolInput: JSON.stringify({ - plan: [ - { step: 'Normalize Codex history', status: 'in_progress' }, - { step: 'Expand session summary', status: 'pending' }, - ], - }), - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:20:00.000Z', - isToolUse: true, - toolName: 'Edit', - toolInput: JSON.stringify({ - file_path: 'src/components/chat/utils/sessionContextSummary.ts', - file_paths: [ - 'src/components/chat/utils/sessionContextSummary.ts', - 'docs/plan.md', - ], - }), - toolResult: { - content: 'Success', - isError: false, - toolUseResult: { - changes: { - 'src/components/chat/utils/sessionContextSummary.ts': { type: 'update' }, - 'docs/plan.md': { type: 'add' }, - }, - }, - }, - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:21:00.000Z', - isToolUse: true, - toolName: 'WebSearch', - toolInput: JSON.stringify({ query: 'Codex session context panel' }), - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:21:30.000Z', - isToolUse: true, - toolName: 'OpenPage', - toolInput: JSON.stringify({ url: 'https://developers.openai.com/api/docs' }), - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:22:00.000Z', - isToolUse: true, - toolName: 'FindInPage', - toolInput: JSON.stringify({ - url: 'https://developers.openai.com/api/docs', - pattern: 'session', - }), - }, - ] as any; - - const summary = deriveSessionContextSummary(messages, projectRoot); - - expect(summary.contextFiles.some((item) => item.relativePath === 'src/components/chat/utils/sessionContextSummary.ts')).toBe(true); - expect(summary.outputFiles.map((item) => item.relativePath).sort()).toEqual([ - 'docs/plan.md', - 'src/components/chat/utils/sessionContextSummary.ts', - ]); - expect(summary.tasks.some((item) => item.label === 'Normalize Codex history' && item.kind === 'todo')).toBe(true); - expect(summary.tasks.some((item) => item.label === 'Codex session context panel')).toBe(true); - expect(summary.tasks.some((item) => item.label === 'https://developers.openai.com/api/docs')).toBe(true); - expect(summary.tasks.some((item) => item.label === 'session')).toBe(true); - }); - - it('ignores dotted shell fragments that are not real file paths', () => { - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-31T12:00:00.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: "jq '.sections.survey.synthesis_summary, .sections.survey.open_gaps' pipeline/docs/research_brief.json", - workdir: projectRoot, - }), - toolResult: { - content: '', - isError: false, - }, - }, - ] as any; - - const summary = deriveSessionContextSummary(messages, projectRoot); - const contextPaths = summary.contextFiles.map((item) => item.relativePath); - - expect(contextPaths).toContain('pipeline/docs/research_brief.json'); - expect(contextPaths.some((path) => path.includes('sections.survey'))).toBe(false); - expect(contextPaths.some((path) => path.includes('open_gaps'))).toBe(false); - }); - - it('strips trailing punctuation from real paths and rejects slash-delimited prose fragments', () => { - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-31T12:05:00.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: 'cat ./pipeline/docs/research_brief.json. ./Survey/reports/model_dataset_inventory.json. 3.0/GPT-5.3.', - workdir: projectRoot, - }), - toolResult: { - content: 'Wrote ./pipeline/tasks/tasks.json.', - isError: false, - }, - }, - ] as any; - - const summary = deriveSessionContextSummary(messages, projectRoot); - const contextPaths = summary.contextFiles.map((item) => item.relativePath).sort(); - - expect(contextPaths).toContain('pipeline/docs/research_brief.json'); - expect(contextPaths).toContain('Survey/reports/model_dataset_inventory.json'); - expect(contextPaths).toContain('pipeline/tasks/tasks.json'); - expect(contextPaths.some((path) => path.endsWith('.'))).toBe(false); - expect(contextPaths).not.toContain('3.0/GPT-5.3.'); - expect(contextPaths).not.toContain('3.0/GPT-5.3'); - }); - - it('does not turn slash-delimited prose into shell directories', () => { - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-31T12:10:00.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: 'python analyze.py cost/latency GeneAgent/BioAgents/GeneGPT HealthBench/MedAgentBench ./Survey/reports', - workdir: projectRoot, - }), - toolResult: { - content: 'Compared GPT-5.3. with cost/latency tradeoffs.', - isError: false, - }, - }, - ] as any; - - const summary = deriveSessionContextSummary(messages, projectRoot); - const directoryLabels = summary.directories.map((item) => item.label); - const contextPaths = summary.contextFiles.map((item) => item.relativePath); - - expect(directoryLabels).toContain('Survey/reports'); - expect(directoryLabels).not.toContain('cost/latency'); - expect(directoryLabels).not.toContain('GeneAgent/BioAgents/GeneGPT'); - expect(directoryLabels).not.toContain('HealthBench/MedAgentBench'); - expect(contextPaths).not.toContain('cost/latency'); - expect(contextPaths).not.toContain('GeneAgent/BioAgents/GeneGPT'); - expect(contextPaths).not.toContain('HealthBench/MedAgentBench'); - expect(contextPaths).not.toContain('GPT-5.3.'); - expect(contextPaths).not.toContain('GPT-5.3'); - }); - - it('prefers session workdir over an unrelated project root when normalizing paths', () => { - const wrongProjectRoot = '/workspace/demo'; - const sessionRoot = '/home/testuser/projects/experiment-2026'; - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-31T12:15:00.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: `cat ${sessionRoot}/instance.json ./instance.json`, - workdir: sessionRoot, - }), - toolResult: { - content: '', - isError: false, - }, - }, - { - type: 'assistant', - timestamp: '2026-03-31T12:16:00.000Z', - isToolUse: true, - toolName: 'LS', - toolInput: JSON.stringify({ - path: `${sessionRoot}/analysis`, - }), - }, - ] as any; - - expect(resolveSessionContextProjectRoot(messages, wrongProjectRoot)).toBe(sessionRoot); - - const summary = deriveSessionContextSummary(messages, wrongProjectRoot); - const contextPaths = summary.contextFiles.map((item) => item.relativePath); - const directoryLabels = summary.directories.map((item) => item.label); - - expect(contextPaths).toEqual(['instance.json']); - expect(directoryLabels).toEqual(['analysis']); - expect(contextPaths.some((path) => path.startsWith('/Users/'))).toBe(false); - expect(directoryLabels.some((label) => label.startsWith('/Users/'))).toBe(false); - }); }); describe('mergeDistinctChatMessages', () => { diff --git a/src/components/chat/utils/sessionContextSummary.ts b/src/components/chat/utils/sessionContextSummary.ts index 9d76f0ac..09793a48 100644 --- a/src/components/chat/utils/sessionContextSummary.ts +++ b/src/components/chat/utils/sessionContextSummary.ts @@ -20,8 +20,8 @@ export interface SessionContextTaskItem { key: string; label: string; detail?: string; - path?: string; kind: 'task' | 'todo' | 'skill' | 'directory'; + path?: string; count: number; lastSeenAt: string; } @@ -55,139 +55,21 @@ type TaskAccumulator = { key: string; label: string; detail?: string; - path?: string; kind: 'task' | 'todo' | 'skill' | 'directory'; + path?: string; count: number; lastSeenAt: string; }; const WINDOWS_ABS_PATTERN = /^[a-z]:\//i; -const MARKDOWN_FILE_LINK_PATTERN = /\]\((\/[^)\s]+)\)/g; -const ABSOLUTE_PATH_IN_TEXT_PATTERN = /(?:^|[\s("'`])((?:\/|[A-Za-z]:\/)[^)\s"'`]+)(?=$|[\s)"'`,:])/g; -const RELATIVE_PATH_IN_TEXT_PATTERN = /(?:^|[\s("'`])((?:\.\.?\/)?(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+)(?=$|[\s)"'`,:])/g; -const SHELL_TOKEN_PATTERN = /"[^"]*"|'[^']*'|`[^`]*`|\S+/g; -const SHELL_COMMAND_BREAKS = new Set(['|', '||', '&&', ';']); -const KNOWN_FILE_BASENAMES = new Set([ - '.env', - '.env.example', - '.gitignore', - '.npmrc', - '.prettierrc', - '.prettierrc.js', - '.prettierrc.json', - '.eslintrc', - '.eslintrc.js', - '.eslintrc.json', - 'Dockerfile', - 'Makefile', - 'README', - 'README.md', - 'README.zh-CN.md', - 'CHANGELOG.md', - 'package.json', - 'package-lock.json', - 'tsconfig.json', - 'vitest.config.ts', - 'vite.config.ts', - 'vite.config.js', - 'index.js', - 'index.ts', - 'index.tsx', - 'index.jsx', - 'AGENTS.md', - 'SKILL.md', - 'CLAUDE.md', -]); -const KNOWN_FILE_EXTENSIONS = new Set([ - 'c', - 'cc', - 'cpp', - 'css', - 'csv', - 'gif', - 'go', - 'h', - 'hpp', - 'html', - 'ini', - 'ipynb', - 'java', - 'jpeg', - 'jpg', - 'js', - 'json', - 'jsonl', - 'jsx', - 'kt', - 'less', - 'lock', - 'log', - 'lua', - 'md', - 'mdx', - 'mjs', - 'pdf', - 'php', - 'png', - 'py', - 'rb', - 'rs', - 'scss', - 'sh', - 'sql', - 'svg', - 'swift', - 'toml', - 'ts', - 'tsx', - 'txt', - 'tsv', - 'xml', - 'yaml', - 'yml', -]); const normalizePath = (value: string) => value.replace(/\\/g, '/').replace(/\/+/g, '/'); -/** Matches SKILL.md paths under .claude/, .agents/, .gemini/, or plain skills/ directories. */ -const SKILL_MD_PATH_RE = /\/(?:\.(?:claude|agents|gemini)\/)?skills\/([^/]+)\/SKILL\.md$/i; - -const trimTrailingPathPunctuation = (value: string) => { - let normalized = normalizePath(value).replace(/[),:;]+$/, ''); - - while (normalized.endsWith('.')) { - const candidate = normalized.slice(0, -1); - if (!candidate || candidate === '.' || candidate === '..') { - break; - } - - const basename = candidate.replace(/\/$/, '').split('/').pop() || candidate; - const extension = basename.includes('.') ? basename.split('.').pop()?.toLowerCase() || '' : ''; - const looksLikeDirectory = candidate.includes('/') && !basename.includes('.'); - const looksLikeKnownFile = KNOWN_FILE_BASENAMES.has(basename) || (Boolean(extension) && KNOWN_FILE_EXTENSIONS.has(extension)); - - if (!looksLikeDirectory && !looksLikeKnownFile) { - break; - } - - normalized = candidate; - } - - return normalized; -}; - const isAbsolutePath = (value: string) => value.startsWith('/') || WINDOWS_ABS_PATTERN.test(value); - const toIsoTimestamp = (value: string | number | Date | undefined): string => { const date = value ? new Date(value) : new Date(); - if (Number.isNaN(date.getTime())) { - if (process.env.NODE_ENV !== 'production') { - console.warn('[sessionContextSummary] Invalid date value, falling back to now:', value); - } - return new Date().toISOString(); - } - return date.toISOString(); + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); }; const parseJsonValue = (value: unknown): any => { @@ -216,7 +98,7 @@ const parseJsonValue = (value: unknown): any => { }; const toRelativePath = (filePath: string, projectRoot: string): string | null => { - const normalizedPath = trimTrailingPathPunctuation(String(filePath || '').trim()); + const normalizedPath = normalizePath(String(filePath || '').trim()); if (!normalizedPath) { return null; } @@ -229,9 +111,8 @@ const toRelativePath = (filePath: string, projectRoot: string): string | null => return normalizedPath.replace(/^\.\//, ''); }; - const toAbsolutePath = (filePath: string, projectRoot: string): string | null => { - const normalizedPath = trimTrailingPathPunctuation(String(filePath || '').trim()); + const normalizedPath = normalizePath(String(filePath || '').trim()); if (!normalizedPath) { return null; } @@ -276,14 +157,6 @@ const extractFilePathsFromResult = (toolResult: any): string[] => { } }); } - - if (source.changes && typeof source.changes === 'object') { - Object.keys(source.changes).forEach((filePath) => { - if (typeof filePath === 'string' && filePath.trim()) { - candidates.push(filePath.trim()); - } - }); - } }); return Array.from(new Set(candidates)); @@ -309,11 +182,14 @@ const extractTodos = (toolInput: any, toolResult: any): Array<{ label: string; d return []; }; -const extractSkillName = (message: ChatMessage): string | null => { - if (message.toolName === 'activate_skill' || message.toolName === 'Skill') { +const extractSkillContext = (message: ChatMessage): { label: string; path?: string } | null => { + if (message.toolName === 'activate_skill') { const parsedInput = parseJsonValue(message.toolInput) || {}; const skillName = parsedInput?.name || parsedInput?.skill; - return typeof skillName === 'string' && skillName.trim() ? skillName.trim() : null; + if (typeof skillName === 'string' && skillName.trim()) { + return { label: skillName.trim() }; + } + return null; } if (!message.isSkillContent || typeof message.content !== 'string') { @@ -321,342 +197,30 @@ const extractSkillName = (message: ChatMessage): string | null => { } const commandMatch = message.content.match(/([^<]+)<\/command-name>/i); - if (commandMatch?.[1]?.trim()) { - return commandMatch[1].trim(); - } + const pathMatch = message.content.match(/Base directory for this skill:\s*(\S+)/i); + const normalizedPath = pathMatch?.[1] + ? normalizePath(pathMatch[1].trim()) + : undefined; - // Detect skillExpander format: "# Skill: skill-name" heading - const skillHeadingMatch = message.content.match(/^#\s+Skill:\s*(\S+)/m); - if (skillHeadingMatch?.[1]?.trim()) { - return skillHeadingMatch[1].trim(); + if (commandMatch?.[1]?.trim()) { + return { + label: commandMatch[1].trim(), + path: normalizedPath, + }; } - const pathMatch = message.content.match(/Base directory for this skill:\s*(\S+)/i); - if (pathMatch?.[1]) { - const normalized = normalizePath(pathMatch[1].trim()); + if (normalizedPath) { + const normalized = normalizedPath; const parts = normalized.split('/'); - return parts[parts.length - 1] || normalized; + return { + label: parts[parts.length - 1] || normalized, + path: normalized, + }; } return null; }; -const extractToolInputPaths = (toolInput: any): string[] => { - const parsedInput = parseJsonValue(toolInput) || toolInput || {}; - const candidates = new Set(); - - [ - parsedInput?.file_path, - parsedInput?.path, - parsedInput?.filePath, - parsedInput?.absolutePath, - parsedInput?.relativePath, - ].forEach((value) => { - if (typeof value === 'string' && value.trim()) { - candidates.add(value.trim()); - } - }); - - [parsedInput?.file_paths, parsedInput?.paths].forEach((list) => { - if (Array.isArray(list)) { - list.forEach((value) => { - if (typeof value === 'string' && value.trim()) { - candidates.add(value.trim()); - } - }); - } - }); - - return Array.from(candidates); -}; - -const extractPlanItems = (toolInput: any): Array<{ label: string; detail?: string }> => { - const parsedInput = parseJsonValue(toolInput) || toolInput || {}; - if (!Array.isArray(parsedInput?.plan)) { - return []; - } - - return parsedInput.plan - .map((item: any) => ({ - label: typeof item?.step === 'string' ? item.step.trim() : '', - detail: typeof item?.status === 'string' ? item.status.trim() : undefined, - })) - .filter((item: { label: string }) => item.label); -}; - -const stripShellToken = (token: string) => token.replace(/^['"`]|['"`]$/g, ''); - -const isLikelyDirectoryPath = (value: string) => { - const normalized = normalizePath(value).replace(/\/$/, ''); - const basename = normalized.split('/').pop() || normalized; - return !basename.includes('.') && !KNOWN_FILE_BASENAMES.has(basename); -}; - -const isExplicitPathReference = (value: string) => - value.startsWith('/') || value.startsWith('./') || value.startsWith('../'); - -const looksLikePathToken = (value: string) => { - const normalized = trimTrailingPathPunctuation(value); - if (!normalized || normalized.startsWith('-') || normalized.includes('://') || SHELL_COMMAND_BREAKS.has(normalized)) { - return false; - } - - if (!/^[A-Za-z0-9._/-]+$/.test(normalized)) { - return false; - } - - const basename = normalized.split('/').pop() || normalized; - if (KNOWN_FILE_BASENAMES.has(basename)) { - return true; - } - - const extension = basename.includes('.') ? basename.split('.').pop()?.toLowerCase() || '' : ''; - if (Boolean(extension) && KNOWN_FILE_EXTENSIONS.has(extension)) { - return true; - } - - if (normalized.startsWith('/') || normalized.startsWith('./') || normalized.startsWith('../') || normalized.includes('/')) { - return !basename.includes('.'); - } - - return false; -}; - -const shouldTrackDirectoryCandidate = (value: string) => { - const normalized = trimTrailingPathPunctuation(value); - if (!normalized || !isLikelyDirectoryPath(normalized)) { - return false; - } - - if (isExplicitPathReference(normalized)) { - return true; - } - - return false; -}; - -const shouldTrackTextPathCandidate = (value: string) => { - const normalized = trimTrailingPathPunctuation(value); - if (!normalized || normalized.includes('://')) { - return false; - } - - if (isExplicitPathReference(normalized)) { - return true; - } - - const basename = normalized.split('/').pop() || normalized; - if (KNOWN_FILE_BASENAMES.has(basename)) { - return true; - } - - const extension = basename.includes('.') ? basename.split('.').pop()?.toLowerCase() || '' : ''; - return Boolean(extension) && KNOWN_FILE_EXTENSIONS.has(extension); -}; - -const extractPathsFromText = (value: string): string[] => { - if (!value) return []; - - const candidates = new Set(); - const pushMatch = (matchValue: string) => { - const normalized = trimTrailingPathPunctuation(matchValue); - if (!shouldTrackTextPathCandidate(normalized)) return; - candidates.add(normalized); - }; - - Array.from(value.matchAll(MARKDOWN_FILE_LINK_PATTERN)).forEach((match) => { - if (match[1]) pushMatch(match[1]); - }); - - Array.from(value.matchAll(ABSOLUTE_PATH_IN_TEXT_PATTERN)).forEach((match) => { - if (match[1]) pushMatch(match[1]); - }); - - Array.from(value.matchAll(RELATIVE_PATH_IN_TEXT_PATTERN)).forEach((match) => { - if (match[1]) pushMatch(match[1]); - }); - - return Array.from(candidates); -}; - -const extractShellContext = ( - toolInput: any, - toolResult: any, -): { files: string[]; directories: string[] } => { - const parsedInput = parseJsonValue(toolInput) || toolInput || {}; - const command = String(parsedInput?.command || parsedInput?.cmd || '').trim(); - const parsedCommands = Array.isArray(parsedInput?.parsed_cmd) ? parsedInput.parsed_cmd : []; - const files = new Set(); - const directories = new Set(); - - parsedCommands.forEach((entry: any) => { - const nextPath = typeof entry?.path === 'string' ? entry.path.trim() : ''; - if (!nextPath) return; - if (entry?.type === 'read') { - files.add(nextPath); - return; - } - if (entry?.type === 'list_files') { - directories.add(nextPath); - return; - } - if (entry?.type === 'search') { - if (nextPath) directories.add(nextPath); - } - }); - - (command.match(SHELL_TOKEN_PATTERN) || []) - .map(stripShellToken) - .forEach((token) => { - if (!looksLikePathToken(token)) { - const colonPath = token.includes(':') ? token.slice(token.indexOf(':') + 1) : ''; - if (colonPath && looksLikePathToken(colonPath)) { - const normalized = trimTrailingPathPunctuation(colonPath); - if (isLikelyDirectoryPath(normalized)) { - if (shouldTrackDirectoryCandidate(normalized)) { - directories.add(normalized); - } - } else { - files.add(normalized); - } - } - return; - } - - const normalized = trimTrailingPathPunctuation(token); - if (isLikelyDirectoryPath(normalized)) { - if (shouldTrackDirectoryCandidate(normalized)) { - directories.add(normalized); - } - } else { - files.add(normalized); - } - }); - - extractPathsFromText(String(toolResult?.content || '')).forEach((nextPath) => { - if (shouldTrackDirectoryCandidate(nextPath)) { - directories.add(nextPath); - } else { - files.add(nextPath); - } - }); - - return { - files: Array.from(files), - directories: Array.from(directories), - }; -}; - -const collectProjectRootCandidate = (target: string[], candidate: unknown) => { - if (typeof candidate !== 'string' || !candidate.trim()) { - return; - } - - const normalized = trimTrailingPathPunctuation(candidate).replace(/\/$/, ''); - if (!normalized || !isAbsolutePath(normalized)) { - return; - } - - if (isLikelyDirectoryPath(normalized)) { - target.push(normalized); - return; - } - - const lastSlashIndex = normalized.lastIndexOf('/'); - if (lastSlashIndex > 0) { - target.push(normalized.slice(0, lastSlashIndex)); - } -}; - -const longestCommonPathPrefix = (paths: string[]): string => { - if (paths.length === 0) { - return ''; - } - - const parts = paths - .map((value) => normalizePath(value).replace(/\/$/, '')) - .filter(Boolean) - .map((value) => value.split('/')); - - if (parts.length === 0) { - return ''; - } - - let sharedLength = parts[0].length; - for (let index = 1; index < parts.length; index += 1) { - sharedLength = Math.min(sharedLength, parts[index].length); - for (let segmentIndex = 0; segmentIndex < sharedLength; segmentIndex += 1) { - if (parts[0][segmentIndex] !== parts[index][segmentIndex]) { - sharedLength = segmentIndex; - break; - } - } - } - - if (sharedLength === 0) { - return ''; - } - - const prefix = parts[0].slice(0, sharedLength).join('/'); - return prefix === '/' ? '' : prefix; -}; - -export const resolveSessionContextProjectRoot = (messages: ChatMessage[], preferredRoot = ''): string => { - const normalizedPreferredRoot = normalizePath(String(preferredRoot || '').trim()).replace(/\/$/, ''); - const explicitRoots: string[] = []; - const absolutePathRoots: string[] = []; - - messages.forEach((message) => { - collectProjectRootCandidate(explicitRoots, message.cwd); - collectProjectRootCandidate(explicitRoots, message.workdir); - collectProjectRootCandidate(explicitRoots, message.projectPath); - - const parsedInput = parseJsonValue(message.toolInput) || message.toolInput || {}; - collectProjectRootCandidate(explicitRoots, parsedInput?.cwd); - collectProjectRootCandidate(explicitRoots, parsedInput?.workdir); - collectProjectRootCandidate(explicitRoots, parsedInput?.projectPath); - - extractToolInputPaths(parsedInput).forEach((filePath) => { - collectProjectRootCandidate(absolutePathRoots, filePath); - }); - extractFilePathsFromResult(message.toolResult).forEach((filePath) => { - collectProjectRootCandidate(absolutePathRoots, filePath); - }); - - if (message.toolName === 'Bash' || message.toolName === 'exec_command') { - const shellContext = extractShellContext(parsedInput, message.toolResult); - shellContext.files.forEach((filePath) => { - collectProjectRootCandidate(absolutePathRoots, filePath); - }); - shellContext.directories.forEach((directoryPath) => { - collectProjectRootCandidate(absolutePathRoots, directoryPath); - }); - } - }); - - const uniqueExplicitRoots = Array.from(new Set(explicitRoots)); - if (normalizedPreferredRoot && uniqueExplicitRoots.some((root) => root === normalizedPreferredRoot || root.startsWith(`${normalizedPreferredRoot}/`))) { - return normalizedPreferredRoot; - } - - const explicitRootPrefix = longestCommonPathPrefix(uniqueExplicitRoots); - if (explicitRootPrefix) { - return explicitRootPrefix; - } - - const uniqueAbsolutePathRoots = Array.from(new Set(absolutePathRoots)); - if (normalizedPreferredRoot && uniqueAbsolutePathRoots.some((root) => root === normalizedPreferredRoot || root.startsWith(`${normalizedPreferredRoot}/`))) { - return normalizedPreferredRoot; - } - - const absolutePathRootPrefix = longestCommonPathPrefix(uniqueAbsolutePathRoots); - if (absolutePathRootPrefix) { - return absolutePathRootPrefix; - } - - return normalizedPreferredRoot; -}; - const addFile = ( target: Map, filePath: string, @@ -714,8 +278,8 @@ const addTask = ( if (timestamp > existing.lastSeenAt) { existing.lastSeenAt = timestamp; existing.detail = detail || existing.detail; + existing.path = path || existing.path; } - existing.path = path || existing.path; return; } @@ -723,8 +287,8 @@ const addTask = ( key, label: normalizedLabel, detail: detail || undefined, - path: path || undefined, kind, + path: path || undefined, count: 1, lastSeenAt: timestamp, }); @@ -801,7 +365,6 @@ export function deriveSessionContextSummary( projectRoot: string, reviews: SessionReviewState = {}, ): SessionContextSummary { - const effectiveProjectRoot = resolveSessionContextProjectRoot(messages, projectRoot); const contextFiles = new Map(); const outputFiles = new Map(); const tasks = new Map(); @@ -811,21 +374,13 @@ export function deriveSessionContextSummary( messages.forEach((message) => { const timestamp = toIsoTimestamp(message.timestamp); - const skillName = extractSkillName(message); - if (skillName) { - // Try to extract SKILL.md path from "Base directory for this skill:" in isSkillContent messages - let skillPath: string | undefined; - if (message.isSkillContent && typeof message.content === 'string') { - const baseMatch = message.content.match(/Base directory for this skill:\s*(\S+)/i); - if (baseMatch?.[1]) { - skillPath = `${baseMatch[1].trim().replace(/\/$/, '')}/SKILL.md`; - } - } - addTask(skills, 'skill', skillName, undefined, timestamp, skillPath); + const skill = extractSkillContext(message); + if (skill) { + addTask(skills, 'skill', skill.label, undefined, timestamp, skill.path); } if (message.isTaskNotification && typeof message.taskOutputFile === 'string' && message.taskOutputFile.trim()) { - addFile(outputFiles, message.taskOutputFile, effectiveProjectRoot, 'Task output', timestamp); + addFile(outputFiles, message.taskOutputFile, projectRoot, 'Task output', timestamp); if (message.taskId) { addTask(tasks, 'task', `Task ${message.taskId}`, message.content || undefined, timestamp); } @@ -840,13 +395,10 @@ export function deriveSessionContextSummary( switch (message.toolName) { case 'Read': { - extractToolInputPaths(parsedInput).forEach((filePath) => { - addFile(contextFiles, filePath, effectiveProjectRoot, 'Read', timestamp); - const skillMatch = filePath.match(SKILL_MD_PATH_RE); - if (skillMatch?.[1]) { - addTask(skills, 'skill', skillMatch[1], undefined, timestamp, filePath); - } - }); + const filePath = parsedInput?.file_path || parsedInput?.path; + if (typeof filePath === 'string') { + addFile(contextFiles, filePath, projectRoot, 'Read', timestamp); + } break; } @@ -854,23 +406,7 @@ export function deriveSessionContextSummary( case 'Glob': { const searchReason = message.toolName || 'Search'; extractFilePathsFromResult(message.toolResult).forEach((filePath) => { - addFile(contextFiles, filePath, effectiveProjectRoot, searchReason, timestamp); - }); - break; - } - - case 'Bash': - case 'exec_command': { - const shellContext = extractShellContext(parsedInput, message.toolResult); - shellContext.files.forEach((filePath) => { - addFile(contextFiles, filePath, effectiveProjectRoot, 'Shell', timestamp); - const skillMatch = filePath.match(SKILL_MD_PATH_RE); - if (skillMatch?.[1]) { - addTask(skills, 'skill', skillMatch[1], undefined, timestamp, filePath); - } - }); - shellContext.directories.forEach((directoryPath) => { - addTask(directories, 'directory', toRelativePath(directoryPath, effectiveProjectRoot) || directoryPath, 'Referenced in shell command', timestamp); + addFile(contextFiles, filePath, projectRoot, searchReason, timestamp); }); break; } @@ -878,7 +414,7 @@ export function deriveSessionContextSummary( case 'LS': { const directoryPath = parsedInput?.dir_path || parsedInput?.path || '.'; if (typeof directoryPath === 'string' && directoryPath.trim()) { - addTask(directories, 'directory', toRelativePath(directoryPath, effectiveProjectRoot) || directoryPath, 'Listed by LS', timestamp); + addTask(directories, 'directory', toRelativePath(directoryPath, projectRoot) || directoryPath, 'Listed by LS', timestamp); } break; } @@ -911,51 +447,48 @@ export function deriveSessionContextSummary( break; } - case 'UpdatePlan': - case 'update_plan': { - const planItems = extractPlanItems(parsedInput); + case 'UpdatePlan': { + const planItems = Array.isArray(parsedInput?.plan) ? parsedInput.plan : []; if (planItems.length === 0) { - addTask(tasks, 'task', 'Plan updated', undefined, timestamp); + addTask(tasks, 'task', 'Plan update', 'Plan updated', timestamp); } else { - planItems.forEach((item) => { - addTask(tasks, 'todo', item.label, item.detail, timestamp); + planItems.forEach((item: any, index: number) => { + const label = + (typeof item?.step === 'string' && item.step.trim()) + || (typeof item?.title === 'string' && item.title.trim()) + || `Plan step ${index + 1}`; + const status = typeof item?.status === 'string' ? item.status.trim() : ''; + addTask(tasks, 'task', label, status || 'Plan update', timestamp); }); } break; } case 'Write': { - extractToolInputPaths(parsedInput).forEach((filePath) => { - addFile(outputFiles, filePath, effectiveProjectRoot, 'Write', timestamp); - }); + const filePath = parsedInput?.file_path || parsedInput?.path; + if (typeof filePath === 'string') { + addFile(outputFiles, filePath, projectRoot, 'Write', timestamp); + } break; } case 'Edit': case 'ApplyPatch': { - const outputPaths = new Set([ - ...extractToolInputPaths(parsedInput), - ...extractFilePathsFromResult(message.toolResult), - ]); - outputPaths.forEach((filePath) => { - addFile(outputFiles, filePath, effectiveProjectRoot, message.toolName === 'Edit' ? 'Edit' : 'Patch', timestamp); - }); + const filePath = parsedInput?.file_path || parsedInput?.path; + if (typeof filePath === 'string') { + addFile(outputFiles, filePath, projectRoot, message.toolName === 'Edit' ? 'Edit' : 'Patch', timestamp); + } break; } case 'FileChanges': { parseFileChanges(message.toolInput).forEach((filePath) => { - addFile(outputFiles, filePath, effectiveProjectRoot, 'File change', timestamp); - const skillMatch = filePath.match(SKILL_MD_PATH_RE); - if (skillMatch?.[1]) { - addTask(skills, 'skill', skillMatch[1], undefined, timestamp, filePath); - } + addFile(outputFiles, filePath, projectRoot, 'File change', timestamp); }); break; } - case 'activate_skill': - case 'Skill': { + case 'activate_skill': { const skillLabel = parsedInput?.name || parsedInput?.skill; if (typeof skillLabel === 'string' && skillLabel.trim()) { addTask(skills, 'skill', skillLabel.trim(), 'Activated in session', timestamp); @@ -963,34 +496,48 @@ export function deriveSessionContextSummary( break; } - case 'ViewImage': { - extractToolInputPaths(parsedInput).forEach((filePath) => { - addFile(contextFiles, filePath, effectiveProjectRoot, 'Image view', timestamp); - }); - break; - } - case 'WebSearch': { - const query = parsedInput?.query || parsedInput?.command; - if (typeof query === 'string' && query.trim()) { - addTask(tasks, 'task', query.trim(), 'Web search', timestamp); + const queries: unknown[] = Array.isArray(parsedInput?.queries) + ? (parsedInput.queries as unknown[]) + : []; + const normalizedQueries: string[] = queries + .map((entry: unknown) => (typeof entry === 'string' ? entry.trim() : '')) + .filter((entry): entry is string => Boolean(entry)); + if (typeof parsedInput?.query === 'string' && parsedInput.query.trim()) { + normalizedQueries.unshift(parsedInput.query.trim()); + } + + if (normalizedQueries.length === 0) { + addTask(tasks, 'task', 'Web search', 'Search requested', timestamp); + } else { + normalizedQueries.forEach((query) => { + addTask(tasks, 'task', query, 'Web search query', timestamp); + }); } break; } case 'OpenPage': { - const url = parsedInput?.url; - if (typeof url === 'string' && url.trim()) { - addTask(tasks, 'task', url.trim(), 'Opened web page', timestamp); + const url = typeof parsedInput?.url === 'string' ? parsedInput.url.trim() : ''; + if (url) { + addTask(tasks, 'task', url, 'Opened web page', timestamp); + } else { + addTask(tasks, 'task', 'Open page', 'Web page opened', timestamp); } break; } case 'FindInPage': { - const pattern = parsedInput?.pattern; - const url = parsedInput?.url; - if (typeof pattern === 'string' && pattern.trim()) { - addTask(tasks, 'task', pattern.trim(), typeof url === 'string' && url.trim() ? `Find in ${url.trim()}` : 'Find in page', timestamp); + const url = typeof parsedInput?.url === 'string' ? parsedInput.url.trim() : ''; + const pattern = typeof parsedInput?.pattern === 'string' ? parsedInput.pattern.trim() : ''; + if (url) { + addTask(tasks, 'task', url, 'Find in page target', timestamp); + } + if (pattern) { + addTask(tasks, 'task', pattern, 'Find in page pattern', timestamp); + } + if (!url && !pattern) { + addTask(tasks, 'task', 'Find in page', 'In-page search', timestamp); } break; } diff --git a/src/components/chat/view/subcomponents/ChatContextFilePreview.tsx b/src/components/chat/view/subcomponents/ChatContextFilePreview.tsx index 539d2b4e..0de6c8f1 100644 --- a/src/components/chat/view/subcomponents/ChatContextFilePreview.tsx +++ b/src/components/chat/view/subcomponents/ChatContextFilePreview.tsx @@ -8,17 +8,26 @@ import { ExternalLink, FileText, X } from 'lucide-react'; import { Button } from '../../../ui/button'; import { api } from '../../../../utils/api'; -import { IMAGE_EXTENSIONS, AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, MARKDOWN_EXTENSIONS, HTML_EXTENSIONS } from '../../utils/fileExtensions'; +import type { SessionContextFileItem, SessionContextOutputItem } from '../../utils/sessionContextSummary'; + +export type PreviewFileTarget = + | SessionContextFileItem + | SessionContextOutputItem + | { + name?: string; + relativePath: string; + absolutePath?: string; + }; -export interface PreviewFileTarget { - name: string; - relativePath: string; - absolutePath: string | null; -} +const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']); +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']); +const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'webm', 'mkv']); +const MARKDOWN_EXTENSIONS = new Set(['md', 'mdx']); +const HTML_EXTENSIONS = new Set(['html', 'htm']); type PreviewFile = PreviewFileTarget | null; -type PreviewKind = 'empty' | 'loading' | 'text' | 'json' | 'markdown' | 'html' | 'pdf' | 'image' | 'audio' | 'video' | 'error'; +type PreviewKind = 'empty' | 'loading' | 'text' | 'markdown' | 'html' | 'pdf' | 'image' | 'audio' | 'video' | 'error'; const getPreviewKind = (file: PreviewFile): PreviewKind => { if (!file) { @@ -32,61 +41,11 @@ const getPreviewKind = (file: PreviewFile): PreviewKind => { if (IMAGE_EXTENSIONS.has(extension)) return 'image'; if (AUDIO_EXTENSIONS.has(extension)) return 'audio'; if (VIDEO_EXTENSIONS.has(extension)) return 'video'; - if (extension === 'json') return 'json'; - if (MARKDOWN_EXTENSIONS.has(extension) || extension === 'txt') return 'markdown'; + if (MARKDOWN_EXTENSIONS.has(extension)) return 'markdown'; if (HTML_EXTENSIONS.has(extension)) return 'html'; return 'text'; }; -const JSON_TOKEN_RE = /("(?:[^"\\]|\\.)*")\s*:|("(?:[^"\\]|\\.)*")|(\b(?:true|false)\b)|(\bnull\b)|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)/g; - -function highlightJsonLine(line: string): (string | JSX.Element)[] { - const parts: (string | JSX.Element)[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - - JSON_TOKEN_RE.lastIndex = 0; - while ((match = JSON_TOKEN_RE.exec(line)) !== null) { - if (match.index > lastIndex) { - parts.push(line.slice(lastIndex, match.index)); - } - - const [full, key, str, bool, nul, num] = match; - - if (key !== undefined) { - // key (without the colon) - parts.push({key}); - parts.push(':'); - } else if (str !== undefined) { - parts.push({str}); - } else if (bool !== undefined) { - parts.push({bool}); - } else if (nul !== undefined) { - parts.push({nul}); - } else if (num !== undefined) { - parts.push({num}); - } else { - parts.push(full); - } - - lastIndex = match.index + full.length; - } - - if (lastIndex < line.length) { - parts.push(line.slice(lastIndex)); - } - - return parts; -} - -function JsonHighlight({ content }: { content: string }) { - const rendered = useMemo(() => { - const lines = content.split('\n'); - return lines.map((line, i) =>
{highlightJsonLine(line)}
); - }, [content]); - return <>{rendered}; -} - interface ChatContextFilePreviewProps { projectName: string; file: PreviewFile; @@ -102,7 +61,7 @@ export default function ChatContextFilePreview({ onOpenInEditor, onClose, compact = false, - preloadedContent, + preloadedContent = null, }: ChatContextFilePreviewProps) { const { t } = useTranslation('chat'); const [content, setContent] = useState(''); @@ -112,7 +71,7 @@ export default function ChatContextFilePreview({ const previewKind = useMemo(() => getPreviewKind(file), [file]); useEffect(() => { - const abortController = new AbortController(); + let cancelled = false; let objectUrl: string | null = null; setContent(''); @@ -140,8 +99,8 @@ export default function ChatContextFilePreview({ try { if (previewKind === 'pdf' || previewKind === 'image' || previewKind === 'audio' || previewKind === 'video') { const absolutePath = file.absolutePath || file.relativePath; - const blob = await api.getFileContentBlob(projectName, absolutePath, { signal: abortController.signal }); - if (abortController.signal.aborted) { + const blob = await api.getFileContentBlob(projectName, absolutePath); + if (cancelled) { return; } @@ -150,46 +109,31 @@ export default function ChatContextFilePreview({ return; } - const response = await api.readFile(projectName, file.relativePath, { signal: abortController.signal }); + const response = await api.readFile(projectName, file.relativePath); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const rawText = await response.text(); - if (abortController.signal.aborted) { + if (cancelled) { return; } - if (previewKind === 'json') { - try { - const wrapper = JSON.parse(rawText); - const raw = typeof wrapper?.content === 'string' ? wrapper.content : rawText; - try { - const parsed = JSON.parse(raw); - setContent(JSON.stringify(parsed, null, 2)); - } catch { - setContent(raw); - } - } catch { - setContent(rawText); - } - } else { - try { - const parsed = JSON.parse(rawText); - const nextContent = typeof parsed?.content === 'string' - ? parsed.content - : JSON.stringify(parsed?.content ?? parsed, null, 2); - setContent(nextContent); - } catch { - setContent(rawText); - } + try { + const parsed = JSON.parse(rawText); + const nextContent = typeof parsed?.content === 'string' + ? parsed.content + : JSON.stringify(parsed?.content ?? parsed, null, 2); + setContent(nextContent); + } catch { + setContent(rawText); } } catch (error) { - if (!abortController.signal.aborted) { + if (!cancelled) { setLoadError(error instanceof Error ? error.message : 'Failed to load preview.'); } } finally { - if (!abortController.signal.aborted) { + if (!cancelled) { setLoading(false); } } @@ -198,7 +142,7 @@ export default function ChatContextFilePreview({ void loadPreview(); return () => { - abortController.abort(); + cancelled = true; if (objectUrl) { URL.revokeObjectURL(objectUrl); } @@ -297,7 +241,7 @@ export default function ChatContextFilePreview({