fix(chat): restore desktop editor file-open in PendingChangesBar (#981)
* fix(chat): restore desktop editor file-open in PendingChangesBar (#979) Commit d1553ba removed the runtime?.editor branch from handleOpenFile, breaking file-click in VS Code and Electron desktop runtimes. Restore the 3-branch logic: editor.openDiff(patch) → editor.openFile() → openContextDiff() web fallback. Display paths remain relative. * fix(chat): route non-git changed files to file view in desktop DiffView requires a git repository to display diffs. In desktop (Electron/Tauri), runtime.editor is unavailable, so non-git files fell through to openContextDiff which shows "Not a git repository". Route non-git files to openContextFile instead, which works without git. Git-tracked files continue to use openContextDiff. * feat(chat): per-turn changes dropdown for non-git; self-sufficient git sync in bar - PendingChangesBar seeds git store + listens to onGitRefreshHint; works without RightSidebarTabs (fixes VS Code where bar never rendered). - Bar is git-only now; non-git shows per-turn dropdown at end of completed assistant turns. - Dropdown uses base-ui Popover for collision-aware position; icon-only collapse with tooltips at narrow container widths. - Extract shared helpers (changedFiles.ts), popover list (ChangedFilesList.tsx), styles (changedFilesPopover.ts). --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
be65a9bd1f
commit
81591430a1
@@ -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<ChangedFilesListProps> = ({ files, currentDirectory, onOpenFile }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
||||||
|
<span>Changed files</span>
|
||||||
|
<span className="typography-meta tabular-nums">{files.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[260px] overflow-y-auto">
|
||||||
|
{files.map((file, index) => {
|
||||||
|
const { fileName, dirPart } = getDisplayPath(file, currentDirectory);
|
||||||
|
const stats = getFileStats(file);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${file.path}:${index}`}
|
||||||
|
type="button"
|
||||||
|
className="relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none text-left hover:bg-interactive-hover"
|
||||||
|
title={`Open ${file.path}`}
|
||||||
|
onClick={() => onOpenFile(file)}
|
||||||
|
>
|
||||||
|
<FileTypeIcon filePath={file.path} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1 flex items-baseline overflow-hidden" title={file.path}>
|
||||||
|
{dirPart ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate text-muted-foreground"
|
||||||
|
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
{dirPart}
|
||||||
|
</span>
|
||||||
|
<span className="flex-shrink-0">
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-foreground">{fileName}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-foreground">{fileName}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{(stats.additions > 0 || stats.deletions > 0) ? (
|
||||||
|
<span className="flex-shrink-0 inline-flex items-baseline gap-1 text-[0.75rem] tabular-nums">
|
||||||
|
{stats.additions > 0 ? <span style={{ color: 'var(--status-success)' }}>+{stats.additions}</span> : null}
|
||||||
|
{stats.deletions > 0 ? <span style={{ color: 'var(--status-error)' }}>-{stats.deletions}</span> : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,270 +1,66 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiFileEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/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 { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||||
// ---- Types ----
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
|
import { normalizePath } from '@/components/session/sidebar/utils';
|
||||||
/** File changed by an AI tool (non-Git mode) */
|
import {
|
||||||
interface ChangedFile {
|
type ChangedFileEntry,
|
||||||
path: string;
|
type GitChangedFile,
|
||||||
tool: string;
|
extractGitChangedFiles,
|
||||||
partId: string;
|
isGitFile,
|
||||||
messageID: string;
|
} from './changedFiles';
|
||||||
additions?: number;
|
import { ChangedFilesList } from './ChangedFilesList';
|
||||||
deletions?: number;
|
import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './changedFilesPopover';
|
||||||
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<string>();
|
|
||||||
|
|
||||||
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<string, unknown>; input?: Record<string, unknown>; 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<string, { insertions: number; deletions: number }> | 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 ----
|
|
||||||
|
|
||||||
export const PendingChangesBar: React.FC = React.memo(() => {
|
export const PendingChangesBar: React.FC = React.memo(() => {
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
|
||||||
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
|
|
||||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
|
const runtime = React.useContext(RuntimeAPIContext);
|
||||||
const isGitRepo = useIsGitRepo(currentDirectory);
|
const isGitRepo = useIsGitRepo(currentDirectory);
|
||||||
const gitStatus = useGitStore((s) =>
|
const gitStatus = useGitStore((s) =>
|
||||||
currentDirectory ? s.directories.get(currentDirectory)?.status ?? null : null,
|
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<HTMLDivElement>(null);
|
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// ---- Mode selection ----
|
// Seed git store for currentDirectory so the bar can render independently of
|
||||||
const mode: 'git' | 'non-git' = isGitRepo === true ? 'git' : 'non-git';
|
// 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 ----
|
// Mirror the onGitRefreshHint listener that lives in DiffView/GitView so the
|
||||||
const gitChangedFiles = React.useMemo(() => {
|
// bar refreshes after mutating tools (edit/write/apply_patch/bash/...) even
|
||||||
if (isGitRepo !== true || mode !== 'git' || !gitStatus || gitStatus.isClean) return [];
|
// 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<GitChangedFile[]>(() => {
|
||||||
|
if (isGitRepo !== true || !gitStatus || gitStatus.isClean) return [];
|
||||||
return extractGitChangedFiles(gitStatus.files, gitStatus.diffStats, currentDirectory);
|
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(() => {
|
const { totalAdded, totalRemoved } = React.useMemo(() => {
|
||||||
let added = 0;
|
let added = 0;
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (const file of changedFiles) {
|
for (const file of gitChangedFiles) {
|
||||||
if (isGitFile(file)) {
|
added += file.insertions;
|
||||||
added += file.insertions;
|
removed += file.deletions;
|
||||||
removed += file.deletions;
|
|
||||||
} else {
|
|
||||||
if (file.additions != null) added += file.additions;
|
|
||||||
if (file.deletions != null) removed += file.deletions;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { totalAdded: added, totalRemoved: removed };
|
return { totalAdded: added, totalRemoved: removed };
|
||||||
}, [changedFiles]);
|
}, [gitChangedFiles]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isExpanded) return;
|
if (!isExpanded) return;
|
||||||
@@ -279,50 +75,35 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
// Don't render while git status is still loading
|
if (isGitRepo !== true) return null;
|
||||||
if (isGitRepo === null) return null;
|
if (gitChangedFiles.length === 0) return null;
|
||||||
|
|
||||||
// ---- Visibility ----
|
|
||||||
if (changedFiles.length === 0) return null;
|
|
||||||
|
|
||||||
// ---- Handlers ----
|
|
||||||
const handleOpenFile = (file: ChangedFileEntry) => {
|
const handleOpenFile = (file: ChangedFileEntry) => {
|
||||||
if (!currentDirectory) return;
|
if (!currentDirectory) return;
|
||||||
|
if (!isGitFile(file)) return;
|
||||||
|
|
||||||
const targetPath = isGitFile(file)
|
const absolutePath = file.path.startsWith('/')
|
||||||
? file.relativePath
|
? file.path
|
||||||
: toRelativePath(file.path, currentDirectory);
|
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
|
||||||
|
|
||||||
|
const editor = runtime?.editor;
|
||||||
|
if (editor) {
|
||||||
|
void editor.openFile(absolutePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const store = useUIStore.getState();
|
const store = useUIStore.getState();
|
||||||
if (!store.isMobile) {
|
if (!store.isMobile) {
|
||||||
store.openContextDiff(currentDirectory, targetPath);
|
store.openContextDiff(currentDirectory, file.relativePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
store.navigateToDiff(targetPath);
|
store.navigateToDiff(file.relativePath);
|
||||||
store.setRightSidebarOpen(false);
|
store.setRightSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Label ----
|
const fileCount = gitChangedFiles.length;
|
||||||
const fileCount = changedFiles.length;
|
|
||||||
const labelHead = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
|
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 (
|
return (
|
||||||
<div className="relative flex min-w-0 items-center" ref={popoverRef}>
|
<div className="relative flex min-w-0 items-center" ref={popoverRef}>
|
||||||
<button
|
<button
|
||||||
@@ -332,7 +113,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
|||||||
>
|
>
|
||||||
<RiFileEditLine className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
|
<RiFileEditLine className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
|
||||||
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
|
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
|
||||||
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">{labelTail}</span>
|
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">changed in workspace</span>
|
||||||
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
|
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
|
||||||
{totalAdded > 0 ? <span style={{ color: 'var(--status-success)' }}>+{totalAdded}</span> : null}
|
{totalAdded > 0 ? <span style={{ color: 'var(--status-success)' }}>+{totalAdded}</span> : null}
|
||||||
{totalRemoved > 0 ? <span style={{ color: 'var(--status-error)' }}>-{totalRemoved}</span> : null}
|
{totalRemoved > 0 ? <span style={{ color: 'var(--status-error)' }}>-{totalRemoved}</span> : null}
|
||||||
@@ -346,60 +127,14 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
|||||||
|
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={changedFilesPopoverStyle}
|
||||||
maxWidth: 'calc(100cqw - 4ch)',
|
className={`${changedFilesPopoverClassName} absolute z-50 left-0 bottom-full mb-1 slide-in-from-bottom-2`}
|
||||||
backgroundColor: 'var(--surface-elevated)',
|
|
||||||
color: 'var(--surface-elevated-foreground)',
|
|
||||||
}}
|
|
||||||
className="absolute left-0 bottom-full mb-1 z-50 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 slide-in-from-bottom-2 duration-150"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
<ChangedFilesList
|
||||||
<span>Changed files</span>
|
files={gitChangedFiles}
|
||||||
<span className="typography-meta tabular-nums">{fileCount}</span>
|
currentDirectory={currentDirectory}
|
||||||
</div>
|
onOpenFile={handleOpenFile}
|
||||||
|
/>
|
||||||
<div className="max-h-[260px] overflow-y-auto">
|
|
||||||
{changedFiles.map((file, index) => {
|
|
||||||
const { fileName, dirPart } = getDisplayPath(file);
|
|
||||||
const stats = getFileStats(file);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${file.path}:${index}`}
|
|
||||||
type="button"
|
|
||||||
className="relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none text-left hover:bg-interactive-hover"
|
|
||||||
title={`Open diff for ${file.path}`}
|
|
||||||
onClick={() => handleOpenFile(file)}
|
|
||||||
>
|
|
||||||
<FileTypeIcon filePath={file.path} className="h-3.5 w-3.5 flex-shrink-0" />
|
|
||||||
<span className="min-w-0 flex-1 flex items-baseline overflow-hidden" title={file.path}>
|
|
||||||
{dirPart ? (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className="min-w-0 truncate text-muted-foreground"
|
|
||||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
|
||||||
>
|
|
||||||
{dirPart}
|
|
||||||
</span>
|
|
||||||
<span className="flex-shrink-0">
|
|
||||||
<span className="text-muted-foreground">/</span>
|
|
||||||
<span className="text-foreground">{fileName}</span>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-foreground">{fileName}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{(stats.additions > 0 || stats.deletions > 0) ? (
|
|
||||||
<span className="flex-shrink-0 inline-flex items-baseline gap-1 text-[0.75rem] tabular-nums">
|
|
||||||
{stats.additions > 0 ? <span style={{ color: 'var(--status-success)' }}>+{stats.additions}</span> : null}
|
|
||||||
{stats.deletions > 0 ? <span style={{ color: 'var(--status-error)' }}>-{stats.deletions}</span> : null}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<TurnChangedFilesDropdownProps> = 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<ChangedFile[]>(() => {
|
||||||
|
// 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 (
|
||||||
|
<Popover.Root open={isExpanded} onOpenChange={setIsExpanded}>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Popover.Trigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground/60 hover:text-muted-foreground tabular-nums"
|
||||||
|
aria-label={`${label} changed in this turn`}
|
||||||
|
>
|
||||||
|
<RiFileEditLine className="h-3.5 w-3.5" />
|
||||||
|
<span className="message-footer__label">{label}</span>
|
||||||
|
{isExpanded ? (
|
||||||
|
<RiArrowUpSLine className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label} changed in this turn</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Positioner side="top" align="start" sideOffset={4} collisionPadding={8}>
|
||||||
|
<Popover.Popup
|
||||||
|
style={changedFilesPopoverStyle}
|
||||||
|
className={changedFilesPopoverClassName}
|
||||||
|
>
|
||||||
|
<ChangedFilesList
|
||||||
|
files={changedFiles}
|
||||||
|
currentDirectory={currentDirectory}
|
||||||
|
onOpenFile={handleOpenFile}
|
||||||
|
/>
|
||||||
|
</Popover.Popup>
|
||||||
|
</Popover.Positioner>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TurnChangedFilesDropdown.displayName = 'TurnChangedFilesDropdown';
|
||||||
@@ -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<string>();
|
||||||
|
|
||||||
|
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<string, unknown>; input?: Record<string, unknown>; 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<string, { insertions: number; deletions: number }> | 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 };
|
||||||
|
};
|
||||||
@@ -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)',
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import ToolPart from './parts/ToolPart';
|
|||||||
import AssistantTextPart from './parts/AssistantTextPart';
|
import AssistantTextPart from './parts/AssistantTextPart';
|
||||||
import ReasoningPart from './parts/ReasoningPart';
|
import ReasoningPart from './parts/ReasoningPart';
|
||||||
import { MessageFilesDisplay } from '../FileAttachment';
|
import { MessageFilesDisplay } from '../FileAttachment';
|
||||||
|
import { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown';
|
||||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||||
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
|
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
|
||||||
import type { TurnGroupingContext } from '../lib/turns/types';
|
import type { TurnGroupingContext } from '../lib/turns/types';
|
||||||
@@ -1593,25 +1594,41 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
</div>
|
</div>
|
||||||
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
||||||
{shouldShowFooter && (
|
{shouldShowFooter && (
|
||||||
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
|
<div
|
||||||
|
className="mt-2 mb-1 flex items-center justify-start gap-1.5"
|
||||||
|
style={{ containerType: 'inline-size', containerName: 'message-footer' }}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{footerButtons}
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{turnDurationText ? (
|
{turnDurationText ? (
|
||||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
<Tooltip delayDuration={300}>
|
||||||
<RiHourglassLine className="h-3.5 w-3.5" />
|
<TooltipTrigger asChild>
|
||||||
{turnDurationText}
|
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||||
</span>
|
<RiHourglassLine className="h-3.5 w-3.5" />
|
||||||
|
<span className="message-footer__label">{turnDurationText}</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{turnDurationText}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
{footerTimestamp ? (
|
{footerTimestamp ? (
|
||||||
<span
|
<Tooltip delayDuration={300}>
|
||||||
className={footerTimestampClassName}
|
<TooltipTrigger asChild>
|
||||||
aria-label={`Message time: ${footerTimestamp}`}
|
<span
|
||||||
>
|
className={footerTimestampClassName}
|
||||||
<RiTimeLine className="h-3.5 w-3.5" />
|
aria-label={`Message time: ${footerTimestamp}`}
|
||||||
{footerTimestamp}
|
>
|
||||||
</span>
|
<RiTimeLine className="h-3.5 w-3.5" />
|
||||||
|
<span className="message-footer__label">{footerTimestamp}</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{footerTimestamp}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{isLastAssistantInTurn && hasStopFinish ? (
|
||||||
|
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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. */
|
/* Animated tabs: collapse labels based on local container width. */
|
||||||
@container animated-tabs (max-width: 23rem) {
|
@container animated-tabs (max-width: 23rem) {
|
||||||
.animated-tabs__label {
|
.animated-tabs__label {
|
||||||
|
|||||||
Reference in New Issue
Block a user