feat: show changed files after completed turns
Add changed-file pills with per-file diff stats Add a chat setting to disable the feature fully Avoid changed-file projection work when disabled
This commit is contained in:
@@ -9,7 +9,7 @@ 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';
|
||||
import type { TurnChangedFile, TurnGroupingContext } from '../lib/turns/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isEmptyTextPart, extractTextContent } from './partUtils';
|
||||
import { FadeInOnReveal } from './FadeInOnReveal';
|
||||
@@ -47,12 +47,50 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { extractLoopbackUrls } from '@/lib/url';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
|
||||
|
||||
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||
const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' };
|
||||
const INLINE_MESSAGE_ACTIONS_CLASS_NAME = 'mt-2 mb-1 flex items-center justify-start gap-1.5';
|
||||
|
||||
const getDisplayFileName = (file: string): string => {
|
||||
const normalized = file.replace(/\\/g, '/');
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
return segments.at(-1) ?? file;
|
||||
};
|
||||
|
||||
const TurnChangedFilePills = React.memo(({ files }: { files?: TurnChangedFile[] }) => {
|
||||
if (!files || files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{files.map((file) => {
|
||||
return (
|
||||
<Tooltip key={file.file}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex h-8 max-w-full items-center">
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs leading-[1.35] text-muted-foreground">
|
||||
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{file.file}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
type SubtaskPartLike = Part & {
|
||||
type: 'subtask';
|
||||
description?: unknown;
|
||||
@@ -1918,43 +1956,44 @@ const AssistantMessageBody = React.memo(({
|
||||
)}
|
||||
{shouldShowTurnFooter && (
|
||||
<div
|
||||
className="mt-2 mb-1 flex items-center justify-start gap-1.5"
|
||||
className="mt-2 mb-1 flex flex-wrap items-center justify-start gap-1.5"
|
||||
style={MESSAGE_FOOTER_CONTAINER_STYLE}
|
||||
>
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
{messageActionButtons}
|
||||
{finalTurnActionButtons}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{turnDurationText ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||
<Icon name="hourglass" className="h-3.5 w-3.5" />
|
||||
<span className="message-footer__label">{turnDurationText}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{turnDurationText}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{footerTimestamp ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={footerTimestampClassName}
|
||||
aria-label={`Message time: ${footerTimestamp}`}
|
||||
>
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
<span className="message-footer__label">{footerTimestamp}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{footerTimestamp}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||
) : null}
|
||||
</div>
|
||||
{turnDurationText ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||
<Icon name="hourglass" className="h-3.5 w-3.5" />
|
||||
<span className="message-footer__label">{turnDurationText}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{turnDurationText}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{footerTimestamp ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={footerTimestampClassName}
|
||||
aria-label={`Message time: ${footerTimestamp}`}
|
||||
>
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
<span className="message-footer__label">{footerTimestamp}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{footerTimestamp}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||
) : null}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilePills files={turnGroupingContext?.changedFiles} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import type { TurnActivityGroup, TurnActivityRecord, TurnDiffStats, TurnGroupingContext } from '../lib/turns/types';
|
||||
import type { TurnActivityGroup, TurnActivityRecord, TurnChangedFile, TurnDiffStats, TurnGroupingContext } from '../lib/turns/types';
|
||||
|
||||
type MessageRecord = {
|
||||
info: Message;
|
||||
@@ -146,6 +146,24 @@ const areTurnDiffStatsEqual = (left?: TurnDiffStats, right?: TurnDiffStats): boo
|
||||
&& left.files === right.files;
|
||||
};
|
||||
|
||||
const areTurnChangedFilesEqual = (left?: TurnChangedFile[], right?: TurnChangedFile[]): boolean => {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return !left && !right;
|
||||
if (left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftFile = left[index];
|
||||
const rightFile = right[index];
|
||||
if (
|
||||
leftFile.file !== rightFile.file
|
||||
|| leftFile.additions !== rightFile.additions
|
||||
|| leftFile.deletions !== rightFile.deletions
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const areTurnActivityRecordsEqual = (left: TurnActivityRecord, right: TurnActivityRecord): boolean => {
|
||||
return left.id === right.id
|
||||
&& left.messageId === right.messageId
|
||||
@@ -301,5 +319,9 @@ export const areRelevantTurnGroupingContextsEqual = (
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((ownerRelevant || segmentsRelevant || left.isLastAssistantInTurn || right.isLastAssistantInTurn) && !areTurnChangedFilesEqual(left.changedFiles, right.changedFiles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user