diff --git a/packages/ui/src/components/chat/ChangedFilesList.tsx b/packages/ui/src/components/chat/ChangedFilesList.tsx new file mode 100644 index 00000000..4c20be4c --- /dev/null +++ b/packages/ui/src/components/chat/ChangedFilesList.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { type ChangedFileEntry, getDisplayPath, getFileStats } from './changedFiles'; + +interface ChangedFilesListProps { + files: ChangedFileEntry[]; + currentDirectory: string; + onOpenFile: (file: ChangedFileEntry) => void; +} + +export const ChangedFilesList: React.FC = ({ files, currentDirectory, onOpenFile }) => { + return ( + <> +
+ Changed files + {files.length} +
+ +
+ {files.map((file, index) => { + const { fileName, dirPart } = getDisplayPath(file, currentDirectory); + const stats = getFileStats(file); + + return ( + + ); + })} +
+ + ); +}; diff --git a/packages/ui/src/components/chat/PendingChangesBar.tsx b/packages/ui/src/components/chat/PendingChangesBar.tsx index 085733cf..4c66bab4 100644 --- a/packages/ui/src/components/chat/PendingChangesBar.tsx +++ b/packages/ui/src/components/chat/PendingChangesBar.tsx @@ -1,270 +1,66 @@ import React from 'react'; import { RiFileEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'; -import type { ToolPart } from '@opencode-ai/sdk/v2'; -import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSessionMessageRecords } from '@/sync/sync-context'; -import { useStreamingStore, selectIsStreaming } from '@/sync/streaming'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useUIStore } from '@/stores/useUIStore'; -import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; -// ---- Types ---- - -/** File changed by an AI tool (non-Git mode) */ -interface ChangedFile { - path: string; - tool: string; - partId: string; - messageID: string; - additions?: number; - deletions?: number; - patch?: string; -} - -/** File changed in workspace (Git mode) */ -interface GitChangedFile { - path: string; - relativePath: string; - insertions: number; - deletions: number; - status: string; -} - -type ChangedFileEntry = ChangedFile | GitChangedFile; - -// ---- Helpers ---- - -const FILE_EDIT_TOOLS = new Set(['edit', 'multiedit', 'write', 'apply_patch', 'create', 'file_write']); - -const parseCount = (value: unknown): number | undefined => { - if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.trunc(value)); - return undefined; -}; - -const parsePatchStats = (patch: string): { added: number; removed: number } => { - let added = 0; - let removed = 0; - for (const line of patch.split('\n')) { - if (line.startsWith('+') && !line.startsWith('+++')) added++; - if (line.startsWith('-') && !line.startsWith('---')) removed++; - } - return { added, removed }; -}; - -/** Extract changed files from tool parts of a single assistant message */ -const extractChangedFiles = (parts: ToolPart[]): ChangedFile[] => { - const files: ChangedFile[] = []; - const seen = new Set(); - - for (const part of parts) { - if (part.type !== 'tool') continue; - if (!FILE_EDIT_TOOLS.has(part.tool)) continue; - - const state = part.state as { metadata?: Record; input?: Record; status?: string }; - if (state.status && state.status !== 'completed') continue; - - const sizeBeforeThisPart = files.length; - - const metadata = state.metadata; - - // Extract from metadata.files[] (apply_patch) - const metaFiles = Array.isArray(metadata?.files) ? metadata.files : []; - for (const file of metaFiles) { - if (!file || typeof file !== 'object') continue; - const record = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown; patch?: unknown }; - const rawPath = record.relativePath || record.filePath || ''; - if (!rawPath || seen.has(rawPath)) continue; - seen.add(rawPath); - files.push({ - path: rawPath, - tool: part.tool, - partId: part.id, - messageID: part.messageID, - additions: parseCount(record.additions) ?? undefined, - deletions: parseCount(record.deletions) ?? undefined, - patch: typeof record.patch === 'string' ? record.patch : undefined, - }); - } - - // Fallback 1: extract from metadata.filediff (edit tool) - if (metaFiles.length === 0 && metadata?.filediff && typeof metadata.filediff === 'object') { - const fd = metadata.filediff as { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown }; - const rawPath = typeof fd.file === 'string' ? fd.file : ''; - if (rawPath && !seen.has(rawPath)) { - seen.add(rawPath); - files.push({ - path: rawPath, - tool: part.tool, - partId: part.id, - messageID: part.messageID, - additions: parseCount(fd.additions) ?? undefined, - deletions: parseCount(fd.deletions) ?? undefined, - patch: typeof fd.patch === 'string' ? fd.patch : undefined, - }); - } - } - - // Fallback 2: extract from metadata.results[].filediff (multiedit tool) - if (metaFiles.length === 0 && Array.isArray(metadata?.results)) { - for (const result of metadata.results) { - if (!result || typeof result !== 'object') continue; - const fd = (result as { filediff?: { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown } }).filediff; - if (!fd || typeof fd !== 'object') continue; - const rawPath = typeof fd.file === 'string' ? fd.file : ''; - if (!rawPath || seen.has(rawPath)) continue; - seen.add(rawPath); - files.push({ - path: rawPath, - tool: part.tool, - partId: part.id, - messageID: part.messageID, - additions: parseCount(fd.additions) ?? undefined, - deletions: parseCount(fd.deletions) ?? undefined, - patch: typeof fd.patch === 'string' ? fd.patch : undefined, - }); - } - } - - // Fallback 3: extract from input.filePath for write-like tools - if (files.length === sizeBeforeThisPart) { - const input = state.input; - const filePath = typeof input?.filePath === 'string' ? input.filePath - : typeof input?.file_path === 'string' ? input.file_path - : typeof input?.path === 'string' ? input.path - : undefined; - if (filePath && !seen.has(filePath)) { - seen.add(filePath); - files.push({ - path: filePath, - tool: part.tool, - partId: part.id, - messageID: part.messageID, - }); - } - } - - // Fallback 4: parse top-level patch/diff for stats - if (files.length === sizeBeforeThisPart) { - const patchText = typeof metadata?.patch === 'string' ? metadata.patch.trim() - : typeof metadata?.diff === 'string' ? metadata.diff.trim() : ''; - if (patchText && !seen.has('Diff')) { - seen.add('Diff'); - const parsed = parsePatchStats(patchText); - files.push({ - path: 'Diff', - tool: part.tool, - partId: part.id, - messageID: part.messageID, - additions: parsed.added, - deletions: parsed.removed, - }); - } - } - } - - return files; -}; - -/** Convert absolute path to relative path based on current directory */ -const toRelativePath = (absolutePath: string, baseDirectory: string): string => { - const norm = (p: string) => p.split('\\').join('/').replace(/\/+$/, ''); - const base = norm(baseDirectory); - const absPath = norm(absolutePath); - if (absPath.startsWith(base + '/')) { - return absPath.slice(base.length + 1); - } - if (absPath.startsWith(base)) { - return absPath.slice(base.length) || absPath; - } - return absPath; -}; - -/** Extract changed files from GitStatus */ -const extractGitChangedFiles = ( - files: Array<{ path: string; index: string; working_dir: string }>, - diffStats: Record | undefined, - directory: string, -): GitChangedFile[] => { - const result: GitChangedFile[] = []; - for (const file of files) { - const code = file.working_dir !== ' ' ? file.working_dir : file.index; - if (code === '!' || code === ' ') continue; - const stats = diffStats?.[file.path]; - result.push({ - path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path, - relativePath: file.path, - insertions: stats?.insertions ?? 0, - deletions: stats?.deletions ?? 0, - status: code, - }); - } - return result; -}; - -/** Type guard for GitChangedFile */ -const isGitFile = (file: ChangedFileEntry): file is GitChangedFile => { - return 'insertions' in file; -}; - -// ---- Component ---- +import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; +import { sessionEvents } from '@/lib/sessionEvents'; +import { normalizePath } from '@/components/session/sidebar/utils'; +import { + type ChangedFileEntry, + type GitChangedFile, + extractGitChangedFiles, + isGitFile, +} from './changedFiles'; +import { ChangedFilesList } from './ChangedFilesList'; +import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './changedFilesPopover'; export const PendingChangesBar: React.FC = React.memo(() => { const [isExpanded, setIsExpanded] = React.useState(false); - const currentSessionId = useSessionUIStore((s) => s.currentSessionId); - const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const runtime = React.useContext(RuntimeAPIContext); const isGitRepo = useIsGitRepo(currentDirectory); const gitStatus = useGitStore((s) => currentDirectory ? s.directories.get(currentDirectory)?.status ?? null : null, ); - const isStreaming = useStreamingStore(selectIsStreaming(currentSessionId ?? '')); + const ensureStatus = useGitStore((s) => s.ensureStatus); + const fetchStatus = useGitStore((s) => s.fetchStatus); const popoverRef = React.useRef(null); - // ---- Mode selection ---- - const mode: 'git' | 'non-git' = isGitRepo === true ? 'git' : 'non-git'; + // Seed git store for currentDirectory so the bar can render independently of + // DiffView/GitView/right-sidebar mounting. ensureStatus has a 5s staleness + // gate and inFlightStatusFetchesByDirectory dedupes against concurrent callers. + React.useEffect(() => { + if (!currentDirectory || !runtime?.git) return; + void ensureStatus(currentDirectory, runtime.git); + }, [currentDirectory, runtime?.git, ensureStatus]); - // ---- Git mode data ---- - const gitChangedFiles = React.useMemo(() => { - if (isGitRepo !== true || mode !== 'git' || !gitStatus || gitStatus.isClean) return []; + // Mirror the onGitRefreshHint listener that lives in DiffView/GitView so the + // bar refreshes after mutating tools (edit/write/apply_patch/bash/...) even + // when neither of those views is open — e.g. VS Code runtime. + React.useEffect(() => { + if (!currentDirectory || !runtime?.git) return; + const git = runtime.git; + return sessionEvents.onGitRefreshHint((hint) => { + if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) return; + void fetchStatus(currentDirectory, git); + }); + }, [currentDirectory, runtime?.git, fetchStatus]); + + const gitChangedFiles = React.useMemo(() => { + if (isGitRepo !== true || !gitStatus || gitStatus.isClean) return []; return extractGitChangedFiles(gitStatus.files, gitStatus.diffStats, currentDirectory); - }, [isGitRepo, mode, gitStatus, currentDirectory]); + }, [isGitRepo, gitStatus, currentDirectory]); - // ---- Non-Git mode data (latest assistant turn only) ---- - const nonGitChangedFiles = React.useMemo(() => { - if (isGitRepo !== false || mode !== 'non-git' || !currentSessionId || isStreaming) return []; - - for (let i = sessionMessageRecords.length - 1; i >= 0; i--) { - const record = sessionMessageRecords[i]; - if (record.info.role !== 'assistant') continue; - - const toolParts = record.parts.filter( - (p): p is ToolPart => p.type === 'tool' && FILE_EDIT_TOOLS.has(p.tool), - ); - if (toolParts.length === 0) continue; - - return extractChangedFiles(toolParts); - } - return []; - }, [isGitRepo, mode, sessionMessageRecords, currentSessionId, isStreaming]); - - // ---- Merged view ---- - const changedFiles: ChangedFileEntry[] = mode === 'git' ? gitChangedFiles : nonGitChangedFiles; - - // ---- Aggregate stats ---- const { totalAdded, totalRemoved } = React.useMemo(() => { let added = 0; let removed = 0; - for (const file of changedFiles) { - if (isGitFile(file)) { - added += file.insertions; - removed += file.deletions; - } else { - if (file.additions != null) added += file.additions; - if (file.deletions != null) removed += file.deletions; - } + for (const file of gitChangedFiles) { + added += file.insertions; + removed += file.deletions; } return { totalAdded: added, totalRemoved: removed }; - }, [changedFiles]); + }, [gitChangedFiles]); React.useEffect(() => { if (!isExpanded) return; @@ -279,50 +75,35 @@ export const PendingChangesBar: React.FC = React.memo(() => { return () => document.removeEventListener('mousedown', handleClickOutside); }, [isExpanded]); - // Don't render while git status is still loading - if (isGitRepo === null) return null; + if (isGitRepo !== true) return null; + if (gitChangedFiles.length === 0) return null; - // ---- Visibility ---- - if (changedFiles.length === 0) return null; - - // ---- Handlers ---- const handleOpenFile = (file: ChangedFileEntry) => { if (!currentDirectory) return; + if (!isGitFile(file)) return; - const targetPath = isGitFile(file) - ? file.relativePath - : toRelativePath(file.path, currentDirectory); + const absolutePath = file.path.startsWith('/') + ? file.path + : (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path; + + const editor = runtime?.editor; + if (editor) { + void editor.openFile(absolutePath); + return; + } const store = useUIStore.getState(); if (!store.isMobile) { - store.openContextDiff(currentDirectory, targetPath); + store.openContextDiff(currentDirectory, file.relativePath); return; } - store.navigateToDiff(targetPath); + store.navigateToDiff(file.relativePath); store.setRightSidebarOpen(false); }; - // ---- Label ---- - const fileCount = changedFiles.length; + const fileCount = gitChangedFiles.length; const labelHead = `${fileCount} file${fileCount !== 1 ? 's' : ''}`; - const labelTail = mode === 'git' ? 'changed in workspace' : 'changed in the last reply'; - // ---- Display helpers ---- - const getDisplayPath = (file: ChangedFileEntry): { fileName: string; dirPart: string } => { - const relativePath = isGitFile(file) && file.relativePath - ? file.relativePath - : toRelativePath(file.path, currentDirectory); - const fileName = relativePath.split('/').pop() ?? relativePath; - const dirPart = relativePath.includes('/') ? relativePath.slice(0, relativePath.lastIndexOf('/')) : ''; - return { fileName, dirPart }; - }; - - const getFileStats = (file: ChangedFileEntry): { additions: number; deletions: number } => { - if (isGitFile(file)) return { additions: file.insertions, deletions: file.deletions }; - return { additions: file.additions ?? 0, deletions: file.deletions ?? 0 }; - }; - - // ---- Render ---- return (
- ); - })} -
+ ) : null} diff --git a/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx new file mode 100644 index 00000000..1b04166c --- /dev/null +++ b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { RiFileEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'; +import type { ToolPart } from '@opencode-ai/sdk/v2'; +import { Popover } from '@base-ui/react/popover'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useIsGitRepo } from '@/stores/useGitStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; +import { + type ChangedFile, + type ChangedFileEntry, + FILE_EDIT_TOOLS, + extractChangedFiles, + isGitFile, + toRelativePath, +} from './changedFiles'; +import { ChangedFilesList } from './ChangedFilesList'; +import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './changedFilesPopover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import type { TurnActivityRecord } from './lib/turns/types'; + +interface TurnChangedFilesDropdownProps { + activityParts: TurnActivityRecord[] | undefined; +} + +export const TurnChangedFilesDropdown: React.FC = React.memo(({ activityParts }) => { + const [isExpanded, setIsExpanded] = React.useState(false); + const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const runtime = React.useContext(RuntimeAPIContext); + const isGitRepo = useIsGitRepo(currentDirectory); + + const changedFiles = React.useMemo(() => { + // Skip work entirely in git repos — the global PendingChangesBar handles those. + if (isGitRepo !== false) return []; + if (!activityParts || activityParts.length === 0) return []; + const toolParts: ToolPart[] = []; + for (const activity of activityParts) { + const part = activity.part; + if (part.type !== 'tool') continue; + if (!FILE_EDIT_TOOLS.has(part.tool)) continue; + toolParts.push(part); + } + if (toolParts.length === 0) return []; + return extractChangedFiles(toolParts); + }, [activityParts, isGitRepo]); + + if (changedFiles.length === 0) return null; + + const handleOpenFile = (file: ChangedFileEntry) => { + if (!currentDirectory) return; + if (isGitFile(file)) return; + + const absolutePath = file.path.startsWith('/') + ? file.path + : (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path; + + const editor = runtime?.editor; + if (editor) { + void editor.openFile(absolutePath); + setIsExpanded(false); + return; + } + + const store = useUIStore.getState(); + if (!store.isMobile) { + store.openContextFile(currentDirectory, absolutePath); + setIsExpanded(false); + return; + } + store.navigateToDiff(toRelativePath(file.path, currentDirectory)); + store.setRightSidebarOpen(false); + setIsExpanded(false); + }; + + const fileCount = changedFiles.length; + const label = `${fileCount} file${fileCount !== 1 ? 's' : ''}`; + + return ( + + + + + + {label} + {isExpanded ? ( + + ) : ( + + )} + + } + /> + + {label} changed in this turn + + + + + + + + + + ); +}); + +TurnChangedFilesDropdown.displayName = 'TurnChangedFilesDropdown'; diff --git a/packages/ui/src/components/chat/changedFiles.ts b/packages/ui/src/components/chat/changedFiles.ts new file mode 100644 index 00000000..79545c3c --- /dev/null +++ b/packages/ui/src/components/chat/changedFiles.ts @@ -0,0 +1,195 @@ +import type { ToolPart } from '@opencode-ai/sdk/v2'; + +export interface ChangedFile { + path: string; + tool: string; + partId: string; + messageID: string; + additions?: number; + deletions?: number; + patch?: string; +} + +export interface GitChangedFile { + path: string; + relativePath: string; + insertions: number; + deletions: number; + status: string; +} + +export type ChangedFileEntry = ChangedFile | GitChangedFile; + +export const FILE_EDIT_TOOLS = new Set(['edit', 'multiedit', 'write', 'apply_patch', 'create', 'file_write']); + +export const isGitFile = (file: ChangedFileEntry): file is GitChangedFile => 'insertions' in file; + +const parseCount = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.trunc(value)); + return undefined; +}; + +const parsePatchStats = (patch: string): { added: number; removed: number } => { + let added = 0; + let removed = 0; + for (const line of patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) added++; + if (line.startsWith('-') && !line.startsWith('---')) removed++; + } + return { added, removed }; +}; + +export const extractChangedFiles = (parts: ToolPart[]): ChangedFile[] => { + const files: ChangedFile[] = []; + const seen = new Set(); + + for (const part of parts) { + if (part.type !== 'tool') continue; + if (!FILE_EDIT_TOOLS.has(part.tool)) continue; + + const state = part.state as { metadata?: Record; input?: Record; status?: string }; + if (state.status && state.status !== 'completed') continue; + + const sizeBeforeThisPart = files.length; + const metadata = state.metadata; + + const metaFiles = Array.isArray(metadata?.files) ? metadata.files : []; + for (const file of metaFiles) { + if (!file || typeof file !== 'object') continue; + const record = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown; patch?: unknown }; + const rawPath = record.relativePath || record.filePath || ''; + if (!rawPath || seen.has(rawPath)) continue; + seen.add(rawPath); + files.push({ + path: rawPath, + tool: part.tool, + partId: part.id, + messageID: part.messageID, + additions: parseCount(record.additions) ?? undefined, + deletions: parseCount(record.deletions) ?? undefined, + patch: typeof record.patch === 'string' ? record.patch : undefined, + }); + } + + if (metaFiles.length === 0 && metadata?.filediff && typeof metadata.filediff === 'object') { + const fd = metadata.filediff as { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown }; + const rawPath = typeof fd.file === 'string' ? fd.file : ''; + if (rawPath && !seen.has(rawPath)) { + seen.add(rawPath); + files.push({ + path: rawPath, + tool: part.tool, + partId: part.id, + messageID: part.messageID, + additions: parseCount(fd.additions) ?? undefined, + deletions: parseCount(fd.deletions) ?? undefined, + patch: typeof fd.patch === 'string' ? fd.patch : undefined, + }); + } + } + + if (metaFiles.length === 0 && Array.isArray(metadata?.results)) { + for (const result of metadata.results) { + if (!result || typeof result !== 'object') continue; + const fd = (result as { filediff?: { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown } }).filediff; + if (!fd || typeof fd !== 'object') continue; + const rawPath = typeof fd.file === 'string' ? fd.file : ''; + if (!rawPath || seen.has(rawPath)) continue; + seen.add(rawPath); + files.push({ + path: rawPath, + tool: part.tool, + partId: part.id, + messageID: part.messageID, + additions: parseCount(fd.additions) ?? undefined, + deletions: parseCount(fd.deletions) ?? undefined, + patch: typeof fd.patch === 'string' ? fd.patch : undefined, + }); + } + } + + if (files.length === sizeBeforeThisPart) { + const input = state.input; + const filePath = typeof input?.filePath === 'string' ? input.filePath + : typeof input?.file_path === 'string' ? input.file_path + : typeof input?.path === 'string' ? input.path + : undefined; + if (filePath && !seen.has(filePath)) { + seen.add(filePath); + files.push({ + path: filePath, + tool: part.tool, + partId: part.id, + messageID: part.messageID, + }); + } + } + + if (files.length === sizeBeforeThisPart) { + const patchText = typeof metadata?.patch === 'string' ? metadata.patch.trim() + : typeof metadata?.diff === 'string' ? metadata.diff.trim() : ''; + if (patchText && !seen.has('Diff')) { + seen.add('Diff'); + const parsed = parsePatchStats(patchText); + files.push({ + path: 'Diff', + tool: part.tool, + partId: part.id, + messageID: part.messageID, + additions: parsed.added, + deletions: parsed.removed, + }); + } + } + } + + return files; +}; + +export const extractGitChangedFiles = ( + files: Array<{ path: string; index: string; working_dir: string }>, + diffStats: Record | undefined, + directory: string, +): GitChangedFile[] => { + const result: GitChangedFile[] = []; + for (const file of files) { + const code = file.working_dir !== ' ' ? file.working_dir : file.index; + if (code === '!' || code === ' ') continue; + const stats = diffStats?.[file.path]; + result.push({ + path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path, + relativePath: file.path, + insertions: stats?.insertions ?? 0, + deletions: stats?.deletions ?? 0, + status: code, + }); + } + return result; +}; + +export const toRelativePath = (absolutePath: string, baseDirectory: string): string => { + const norm = (p: string) => p.split('\\').join('/').replace(/\/+$/, ''); + const base = norm(baseDirectory); + const absPath = norm(absolutePath); + if (absPath.startsWith(base + '/')) { + return absPath.slice(base.length + 1); + } + if (absPath.startsWith(base)) { + return absPath.slice(base.length) || absPath; + } + return absPath; +}; + +export const getDisplayPath = (file: ChangedFileEntry, currentDirectory: string): { fileName: string; dirPart: string } => { + const relativePath = isGitFile(file) && file.relativePath + ? file.relativePath + : toRelativePath(file.path, currentDirectory); + const fileName = relativePath.split('/').pop() ?? relativePath; + const dirPart = relativePath.includes('/') ? relativePath.slice(0, relativePath.lastIndexOf('/')) : ''; + return { fileName, dirPart }; +}; + +export const getFileStats = (file: ChangedFileEntry): { additions: number; deletions: number } => { + if (isGitFile(file)) return { additions: file.insertions, deletions: file.deletions }; + return { additions: file.additions ?? 0, deletions: file.deletions ?? 0 }; +}; diff --git a/packages/ui/src/components/chat/changedFilesPopover.ts b/packages/ui/src/components/chat/changedFilesPopover.ts new file mode 100644 index 00000000..95d5fe2d --- /dev/null +++ b/packages/ui/src/components/chat/changedFilesPopover.ts @@ -0,0 +1,10 @@ +import type { CSSProperties } from 'react'; + +export const changedFilesPopoverClassName = + "w-max min-w-[280px] max-w-full rounded-xl p-1 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)] animate-in fade-in-0 zoom-in-95 duration-150"; + +export const changedFilesPopoverStyle: CSSProperties = { + maxWidth: 'calc(100cqw - 4ch)', + backgroundColor: 'var(--surface-elevated)', + color: 'var(--surface-elevated-foreground)', +}; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index dfbcc693..4c6b7852 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -6,6 +6,7 @@ import ToolPart from './parts/ToolPart'; import AssistantTextPart from './parts/AssistantTextPart'; import ReasoningPart from './parts/ReasoningPart'; import { MessageFilesDisplay } from '../FileAttachment'; +import { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types'; import type { TurnGroupingContext } from '../lib/turns/types'; @@ -1593,25 +1594,41 @@ const AssistantMessageBody: React.FC> = ({ {shouldShowFooter && ( -
+
{footerButtons}
{turnDurationText ? ( - - - {turnDurationText} - + + + + + {turnDurationText} + + + {turnDurationText} + ) : null} {footerTimestamp ? ( - - - {footerTimestamp} - + + + + + {footerTimestamp} + + + {footerTimestamp} + + ) : null} + {isLastAssistantInTurn && hasStopFinish ? ( + ) : null}
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 835c21a5..e1155348 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -776,6 +776,13 @@ html:not(.dark) .chat-scroll { } } +/* Assistant message footer: collapse text labels to icons when narrow. */ +@container message-footer (max-width: 24rem) { + .message-footer__label { + display: none; + } +} + /* Animated tabs: collapse labels based on local container width. */ @container animated-tabs (max-width: 23rem) { .animated-tabs__label {