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:
Bohdan Triapitsyn
2026-06-03 22:11:37 +03:00
parent 9087950f1a
commit 04b1425c2e
22 changed files with 218 additions and 41 deletions
@@ -685,10 +685,11 @@ const TurnBlock = React.memo(({
hasTools: turn.hasTools,
hasReasoning: turn.hasReasoning,
diffStats: turn.diffStats,
changedFiles: turn.changedFiles,
userMessageCreatedAt: typeof userCreatedAt === 'number' ? userCreatedAt : undefined,
userMessageVariant,
};
}, [turn.diffStats, turn.hasReasoning, turn.hasTools, turn.headerMessageId, turn.summaryText, turn.turnId, turn.userMessage.info, visibleActivityParts, visibleActivitySegments]);
}, [turn.changedFiles, turn.diffStats, turn.hasReasoning, turn.hasTools, turn.headerMessageId, turn.summaryText, turn.turnId, turn.userMessage.info, visibleActivityParts, visibleActivitySegments]);
const renderMessage = React.useCallback(
(message: ChatMessageEntry) => {
@@ -736,6 +737,7 @@ const TurnBlock = React.memo(({
activityGroupSegments: turnGroupingContextBase.activityGroupSegments,
headerMessageId: turnGroupingContextBase.headerMessageId,
diffStats: turnGroupingContextBase.diffStats,
changedFiles: turnGroupingContextBase.changedFiles,
userMessageCreatedAt: turnGroupingContextBase.userMessageCreatedAt,
userMessageVariant: turnGroupingContextBase.userMessageVariant,
isGroupExpanded: turnUiState.isExpanded,
@@ -1114,6 +1116,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
const showTurnChangedFiles = useUIStore((state) => state.showTurnChangedFiles);
const defaultActivityExpanded = activityRenderMode === 'summary';
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(() => new Map());
const userAnimationRef = React.useRef<{
@@ -1212,6 +1215,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
sessionKey,
showTextJustificationActivity: chatRenderMode === 'sorted',
showTurnChangedFiles,
});
const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0;
const staticEntryMessages = hasUngroupedStaticEntries ? displayMessages : EMPTY_STATIC_ENTRY_MESSAGES;
@@ -6,6 +6,7 @@ import { streamPerfMeasure } from '@/stores/utils/streamDebug';
interface UseTurnRecordsOptions {
sessionKey?: string;
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
}
export interface TurnRecordsResult {
@@ -23,13 +24,16 @@ export const useTurnRecords = (
const streamingTurnRef = React.useRef<TurnRecord | undefined>(undefined);
const previousSessionKeyRef = React.useRef<string | undefined>(options.sessionKey);
const previousShowTextJustificationActivityRef = React.useRef(options.showTextJustificationActivity);
const previousShowTurnChangedFilesRef = React.useRef(options.showTurnChangedFiles);
if (
previousSessionKeyRef.current !== options.sessionKey
|| previousShowTextJustificationActivityRef.current !== options.showTextJustificationActivity
|| previousShowTurnChangedFilesRef.current !== options.showTurnChangedFiles
) {
previousSessionKeyRef.current = options.sessionKey;
previousShowTextJustificationActivityRef.current = options.showTextJustificationActivity;
previousShowTurnChangedFilesRef.current = options.showTurnChangedFiles;
previousProjectionRef.current = null;
staticTurnsRef.current = [];
streamingTurnRef.current = undefined;
@@ -39,18 +43,19 @@ export const useTurnRecords = (
previousProjectionRef.current = null;
staticTurnsRef.current = [];
streamingTurnRef.current = undefined;
}, [options.sessionKey, options.showTextJustificationActivity]);
}, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles]);
const projection = React.useMemo(() => {
return streamPerfMeasure('ui.turns.projection_ms', () => {
const nextProjection = projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current,
showTextJustificationActivity: options.showTextJustificationActivity,
showTurnChangedFiles: options.showTurnChangedFiles,
});
previousProjectionRef.current = nextProjection;
return nextProjection;
});
}, [messages, options.showTextJustificationActivity]);
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles]);
const staticTurns = React.useMemo(() => {
const nextStatic = projection.turns.length <= 1
@@ -1,6 +1,6 @@
import { projectTurnActivity } from './projectTurnActivity';
import { projectTurnIndexes } from './projectTurnIndexes';
import { projectTurnDiffStats, projectTurnSummary } from './projectTurnSummary';
import { projectTurnChangedFiles, projectTurnDiffStats, projectTurnSummary } from './projectTurnSummary';
import type {
ChatMessageEntry,
TurnMessageRecord,
@@ -83,11 +83,13 @@ const buildTurnStreamState = (userMessage: ChatMessageEntry, assistantMessages:
interface ProjectTurnRecordsOptions {
previousProjection?: TurnProjectionResult | null;
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
}
const DEFAULT_OPTIONS: ProjectTurnRecordsOptions = {
previousProjection: null,
showTextJustificationActivity: false,
showTurnChangedFiles: false,
};
const areSameMessageRefs = (left: ChatMessageEntry[], right: ChatMessageEntry[]): boolean => {
@@ -180,6 +182,7 @@ export const projectTurnRecords = (
hasTools: false,
hasReasoning: false,
diffStats: undefined,
changedFiles: undefined,
stream: {
isStreaming: false,
isRetrying: false,
@@ -215,6 +218,9 @@ export const projectTurnRecords = (
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
turn.changedFiles = effectiveOptions.showTurnChangedFiles
? projectTurnChangedFiles(turn.userMessage)
: undefined;
const activity = projectTurnActivity({
turnId: turn.turnId,
@@ -1,6 +1,7 @@
import type { ChatMessageEntry, TurnDiffStats, TurnSummaryRecord } from './types';
import type { ChatMessageEntry, TurnChangedFile, TurnDiffStats, TurnSummaryRecord } from './types';
interface SummaryDiff {
file?: string | null;
additions?: number | null;
deletions?: number | null;
}
@@ -102,3 +103,31 @@ export const projectTurnDiffStats = (userMessage: ChatMessageEntry): TurnDiffSta
files,
};
};
export const projectTurnChangedFiles = (userMessage: ChatMessageEntry): TurnChangedFile[] | undefined => {
const summary = (userMessage.info as { summary?: UserSummaryPayload | null }).summary;
const diffs = summary?.diffs;
if (!Array.isArray(diffs) || diffs.length === 0) {
return undefined;
}
const files = diffs
.map((diff) => {
if (!diff || typeof diff.file !== 'string' || diff.file.trim().length === 0) {
return null;
}
const additions = typeof diff.additions === 'number' ? diff.additions : 0;
const deletions = typeof diff.deletions === 'number' ? diff.deletions : 0;
if (additions === 0 && deletions === 0) {
return null;
}
return {
file: diff.file,
additions,
deletions,
};
})
.filter((file): file is TurnChangedFile => file !== null);
return files.length > 0 ? files : undefined;
};
@@ -34,6 +34,12 @@ export interface TurnDiffStats {
files: number;
}
export interface TurnChangedFile {
file: string;
additions: number;
deletions: number;
}
export interface TurnActivityGroup {
id: string;
anchorMessageId: string;
@@ -70,6 +76,7 @@ export interface TurnRecord {
hasTools: boolean;
hasReasoning: boolean;
diffStats?: TurnDiffStats;
changedFiles?: TurnChangedFile[];
stream: TurnStreamState;
startedAt?: number;
completedAt?: number;
@@ -115,6 +122,7 @@ export interface TurnGroupingContext {
hasTools: boolean;
hasReasoning: boolean;
diffStats?: TurnDiffStats;
changedFiles?: TurnChangedFile[];
userMessageCreatedAt?: number;
userMessageVariant?: string;
isWorking: boolean;
@@ -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;
};
@@ -142,7 +142,7 @@ const VisualSectionContent: React.FC = () => {
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'expandedTools', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'fileViewerPreview', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'fileViewerPreview', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
};
// Sessions section: Default model & agent, Session retention
@@ -247,7 +247,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -305,6 +305,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled);
const showToolFileIcons = useUIStore(state => state.showToolFileIcons);
const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons);
const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles);
const setShowTurnChangedFiles = useUIStore(state => state.setShowTurnChangedFiles);
const showExpandedBashTools = useUIStore(state => state.showExpandedBashTools);
const setShowExpandedBashTools = useUIStore(state => state.setShowExpandedBashTools);
const showExpandedEditTools = useUIStore(state => state.showExpandedEditTools);
@@ -441,6 +443,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ showToolFileIcons: enabled });
}, [setShowToolFileIcons]);
const handleShowTurnChangedFilesChange = React.useCallback((enabled: boolean) => {
setShowTurnChangedFiles(enabled);
void updateDesktopSettings({ showTurnChangedFiles: enabled });
}, [setShowTurnChangedFiles]);
const handleFileViewerPreviewChange = React.useCallback((enabled: boolean) => {
setSettingsDefaultFileViewerPreview(enabled);
void updateDesktopSettings({ defaultFileViewerPreview: enabled });
@@ -1587,7 +1594,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
)}
{(shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{(shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
<section className="p-2 space-y-0.5">
{shouldShow('reasoning') && (
<div
@@ -1737,6 +1744,29 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
)}
{shouldShow('showTurnChangedFiles') && (
<div
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={showTurnChangedFiles}
onClick={() => handleShowTurnChangedFilesChange(!showTurnChangedFiles)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleShowTurnChangedFilesChange(!showTurnChangedFiles);
}
}}
>
<Checkbox
checked={showTurnChangedFiles}
onChange={handleShowTurnChangedFilesChange}
ariaLabel={t('settings.openchamber.visual.field.showTurnChangedFilesAria')}
/>
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showTurnChangedFiles')}</span>
</div>
)}
{shouldShow('mobileStatusBar') && isMobile && (
<div
className="group flex cursor-pointer items-center gap-2 py-0.5"