Skip to content

Commit 8eed286

Browse files
luoxuanzaoQoder-AI
andauthored
feat(chat): summarize file changes by turn behind a collapsible card
After a reply finishes, the files it edited collapse into a single card under the response — file count, +/- stats and the first few paths — instead of scattering across the tool call log. Clicking the card opens a read-only modal with the per-file diffs; diff text and paths are selectable for copying, and the modal dismisses natively via outside click or Escape. Saved conversations show the same card when reopened. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 9ad01b1 commit 8eed286

20 files changed

Lines changed: 787 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ version with its date and start a fresh empty `[Unreleased]` above it.
2525
while a turn is running, turns that end on a tool call stay expanded, and
2626
saved conversations reopen with the same collapsed state.
2727

28+
- Turn change summaries: after a reply finishes, the files it edited collapse
29+
into a single card under the response — file count, +/- stats and the first
30+
few paths — instead of scattering across the tool call log. Clicking the
31+
card opens a read-only modal with the per-file diffs, and saved
32+
conversations show the same card when reopened.
33+
2834
### Changed
2935

3036
- The composer permission picker now mirrors New Qoder's three tiers —
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { App } from 'obsidian';
2+
import { Modal } from 'obsidian';
3+
4+
import { t } from '../../../i18n/i18n';
5+
import { renderDiffContent, renderDiffStats } from '../rendering/diff-renderer';
6+
import type { TurnChangesSummary, TurnFileChange, TurnFileDiff } from './turn-file-changes';
7+
import { filePathName, turnFileDisplayPath, turnFileName } from './turn-file-path';
8+
9+
export class TurnChangesModal extends Modal {
10+
private selectedFile: TurnFileChange;
11+
12+
constructor(app: App, private readonly changes: TurnChangesSummary) {
13+
super(app);
14+
this.selectedFile = changes.files[0];
15+
}
16+
17+
onOpen(): void {
18+
this.modalEl.addClass('qoderian-turn-changes-modal');
19+
this.render();
20+
}
21+
22+
onClose(): void {
23+
this.contentEl.empty();
24+
}
25+
26+
private render(): void {
27+
this.contentEl.empty();
28+
29+
const header = this.contentEl.createDiv({ cls: 'qoderian-turn-changes-header' });
30+
header.createEl('h2', { text: t('chat.changes.title') });
31+
const summary = header.createDiv({ cls: 'qoderian-turn-changes-summary' });
32+
summary.createSpan({ text: t('chat.changes.files', { count: this.changes.files.length }) });
33+
renderDiffStats(summary, this.changes.stats);
34+
35+
const body = this.contentEl.createDiv({ cls: 'qoderian-turn-changes-body' });
36+
const fileList = body.createDiv({ cls: 'qoderian-turn-changes-files' });
37+
for (const file of this.changes.files) {
38+
const button = fileList.createEl('button', {
39+
cls: `qoderian-turn-changes-file${file === this.selectedFile ? ' is-selected' : ''}`,
40+
attr: {
41+
'aria-current': file === this.selectedFile ? 'true' : 'false',
42+
type: 'button',
43+
},
44+
});
45+
button.createSpan({ cls: 'qoderian-turn-changes-file-name', text: turnFileName(file) });
46+
const stats = button.createSpan({ cls: 'qoderian-turn-changes-file-stats' });
47+
renderDiffStats(stats, file.stats);
48+
button.addEventListener('click', () => {
49+
this.selectedFile = file;
50+
this.render();
51+
});
52+
}
53+
54+
const detail = body.createDiv({ cls: 'qoderian-turn-changes-detail' });
55+
const detailHeader = detail.createDiv({ cls: 'qoderian-turn-changes-detail-header' });
56+
detailHeader.createSpan({
57+
cls: 'qoderian-turn-changes-path',
58+
text: turnFileDisplayPath(this.app, this.selectedFile),
59+
});
60+
61+
const diffList = detail.createDiv({ cls: 'qoderian-turn-changes-diffs' });
62+
this.selectedFile.diffs.forEach((diff, index) => {
63+
if (this.selectedFile.diffs.length > 1) {
64+
diffList.createDiv({
65+
cls: 'qoderian-turn-changes-edit-label',
66+
text: t('chat.changes.edit', { count: index + 1 }),
67+
});
68+
}
69+
const diffEl = diffList.createDiv({ cls: 'qoderian-diff-content' });
70+
this.renderFileDiff(diffEl, diff);
71+
});
72+
}
73+
74+
private renderFileDiff(diffEl: HTMLElement, diff: TurnFileDiff): void {
75+
if (diff.operation === 'delete' && diff.diffLines.length === 0) {
76+
diffEl.createDiv({ cls: 'qoderian-diff-no-changes', text: t('chat.changes.deleted') });
77+
return;
78+
}
79+
if (diff.movedTo && diff.diffLines.length === 0) {
80+
diffEl.createDiv({
81+
cls: 'qoderian-diff-no-changes',
82+
text: t('chat.changes.renamed', { path: filePathName(diff.movedTo) }),
83+
});
84+
return;
85+
}
86+
renderDiffContent(diffEl, diff.diffLines, 3);
87+
}
88+
89+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { DiffStats } from '../../../core/types/diff';
2+
import type { ToolCallInfo, ToolDiffData } from '../../../core/types/tools';
3+
import {
4+
diffFromToolInput,
5+
parseApplyPatchDiffs,
6+
parseFileUpdateChangeDiffs,
7+
} from '../../../qoder/tools/diff';
8+
import { TOOL_APPLY_PATCH } from '../../../qoder/tools/tool-names';
9+
10+
export interface TurnFileDiff extends ToolDiffData {
11+
operation?: 'add' | 'update' | 'delete';
12+
movedTo?: string;
13+
}
14+
15+
export interface TurnFileChange {
16+
filePath: string;
17+
diffs: TurnFileDiff[];
18+
stats: DiffStats;
19+
}
20+
21+
export interface TurnChangesSummary {
22+
files: TurnFileChange[];
23+
stats: DiffStats;
24+
}
25+
26+
/** Collect completed file edits from one assistant turn, including nested subagents. */
27+
export function collectTurnChanges(toolCalls?: ToolCallInfo[]): TurnChangesSummary {
28+
const byFile = new Map<string, TurnFileChange>();
29+
const visitedCalls = new Set<ToolCallInfo>();
30+
31+
const addDiff = (diff: TurnFileDiff): void => {
32+
const hasFileOperation = diff.operation === 'delete' || Boolean(diff.movedTo);
33+
if (!diff.filePath || (!hasFileOperation && diff.stats.added === 0 && diff.stats.removed === 0)) {
34+
return;
35+
}
36+
37+
const existing = byFile.get(diff.filePath) ?? {
38+
filePath: diff.filePath,
39+
diffs: [],
40+
stats: { added: 0, removed: 0 },
41+
};
42+
existing.diffs.push(diff);
43+
existing.stats.added += diff.stats.added;
44+
existing.stats.removed += diff.stats.removed;
45+
byFile.set(diff.filePath, existing);
46+
};
47+
48+
const visit = (calls?: ToolCallInfo[]): void => {
49+
for (const call of calls ?? []) {
50+
if (visitedCalls.has(call)) continue;
51+
visitedCalls.add(call);
52+
53+
if (call.status === 'completed') {
54+
if (call.name === TOOL_APPLY_PATCH) {
55+
const patch = typeof call.input.patch === 'string' ? call.input.patch : '';
56+
const patchDiffs = parseApplyPatchDiffs(patch);
57+
const diffs = patchDiffs.length > 0
58+
? patchDiffs
59+
: parseFileUpdateChangeDiffs(call.input.changes);
60+
if (diffs.length > 0) diffs.forEach(addDiff);
61+
else if (call.diffData) addDiff(call.diffData);
62+
} else {
63+
const filePath = getFilePath(call);
64+
const diff = call.diffData ?? (filePath ? diffFromToolInput(call, filePath) : undefined);
65+
if (diff) addDiff(diff);
66+
}
67+
}
68+
69+
visit(call.subagent?.toolCalls);
70+
}
71+
};
72+
73+
visit(toolCalls);
74+
const files = [...byFile.values()];
75+
return {
76+
files,
77+
stats: files.reduce(
78+
(total, file) => ({
79+
added: total.added + file.stats.added,
80+
removed: total.removed + file.stats.removed,
81+
}),
82+
{ added: 0, removed: 0 },
83+
),
84+
};
85+
}
86+
87+
function getFilePath(call: ToolCallInfo): string | undefined {
88+
const value = call.input.file_path ?? call.input.path;
89+
return typeof value === 'string' && value.trim() ? value : call.diffData?.filePath;
90+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { App } from 'obsidian';
2+
import { FileSystemAdapter, normalizePath } from 'obsidian';
3+
4+
import type { TurnFileChange } from './turn-file-changes';
5+
6+
/** Format a changed file without exposing the host's absolute vault path. */
7+
export function turnFileDisplayPath(app: App, file: TurnFileChange): string {
8+
const movedTo = latestMoveTarget(file);
9+
return movedTo
10+
? `${vaultDisplayPath(app, file.filePath)} → ${vaultDisplayPath(app, movedTo)}`
11+
: vaultDisplayPath(app, file.filePath);
12+
}
13+
14+
export function turnFileName(file: TurnFileChange): string {
15+
const movedTo = latestMoveTarget(file);
16+
return movedTo
17+
? `${filePathName(file.filePath)} → ${filePathName(movedTo)}`
18+
: filePathName(file.filePath);
19+
}
20+
21+
export function filePathName(filePath: string): string {
22+
return filePath.split(/[/\\]/).pop() || filePath;
23+
}
24+
25+
function vaultDisplayPath(app: App, filePath: string): string {
26+
const normalized = normalizePath(filePath);
27+
const adapter = app.vault.adapter;
28+
if (!(adapter instanceof FileSystemAdapter)) {
29+
return isAbsolutePath(normalized) ? filePathName(normalized) : normalized;
30+
}
31+
32+
const basePath = normalizePath(adapter.getBasePath());
33+
const relativePath = normalized.startsWith(`${basePath}/`)
34+
? normalized.slice(basePath.length + 1)
35+
: normalized;
36+
return isAbsolutePath(relativePath) ? filePathName(relativePath) : relativePath;
37+
}
38+
39+
function latestMoveTarget(file: TurnFileChange): string | undefined {
40+
return [...file.diffs].reverse().find(diff => diff.movedTo)?.movedTo;
41+
}
42+
43+
function isAbsolutePath(filePath: string): boolean {
44+
return filePath.startsWith('/') || /^[A-Za-z]:\//.test(filePath);
45+
}

‎src/features/chat/controllers/input-controller.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ export class InputController {
459459
const completedContentEl = state.currentContentEl;
460460
if (completedContentEl) {
461461
renderer.appendResponseFooter(completedContentEl, finalAssistantMsg);
462+
renderer.updateTurnChangesButton(finalAssistantMsg, completedContentEl);
462463
}
463464

464465
await streamController.finalizeCurrentThinkingBlock(finalAssistantMsg);

‎src/features/chat/rendering/message-renderer.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,13 @@ import {
2525
import { replaceMentionTokensWithHtml } from '../../../shared/markdown/mention-chip';
2626
import type { ReferenceChipKind } from '../../../shared/mention/types';
2727
import { openReferenceChip } from '../../../shared/obsidian/compat';
28+
import { TurnChangesModal } from '../changes/turn-changes-modal';
29+
import { collectTurnChanges } from '../changes/turn-file-changes';
30+
import { turnFileDisplayPath } from '../changes/turn-file-path';
2831
import { formatConversationDirectoryTitle } from '../utils/conversation-directory-title';
2932
import { findRewindContext } from '../utils/rewind';
3033
import { setupCollapsible } from './collapsible';
34+
import { renderDiffStats } from './diff-renderer';
3135
import {
3236
renderStoredAsyncSubagent,
3337
renderStoredSubagent,
@@ -425,6 +429,7 @@ export class MessageRenderer {
425429
this.appendResponseFooter(contentEl, msg);
426430
}
427431

432+
this.updateTurnChangesButton(msg, contentEl);
428433
this.collapseCompletedTurn(msg, contentEl);
429434
}
430435

@@ -542,6 +547,60 @@ export class MessageRenderer {
542547
return false;
543548
}
544549

550+
/** Add or refresh the aggregate file-change entry for a stored or live turn. */
551+
updateTurnChangesButton(msg: ChatMessage, contentEl: HTMLElement): void {
552+
contentEl.querySelector('.qoderian-turn-changes-trigger')?.remove();
553+
if (msg.role !== 'assistant') return;
554+
555+
const changes = collectTurnChanges(msg.toolCalls);
556+
if (changes.files.length === 0) return;
557+
558+
const button = contentEl.createEl('button', {
559+
cls: 'qoderian-turn-changes-trigger',
560+
attr: {
561+
'aria-label': t('chat.changes.button', { count: changes.files.length }),
562+
type: 'button',
563+
},
564+
});
565+
566+
const header = button.createSpan({ cls: 'qoderian-turn-changes-trigger-header' });
567+
const icon = header.createSpan({ cls: 'qoderian-turn-changes-trigger-icon' });
568+
setIcon(icon, 'files');
569+
const summary = header.createSpan({ cls: 'qoderian-turn-changes-trigger-summary' });
570+
summary.createSpan({
571+
cls: 'qoderian-turn-changes-trigger-title',
572+
text: t('chat.changes.edited', { count: changes.files.length }),
573+
});
574+
const stats = summary.createSpan({ cls: 'qoderian-turn-changes-trigger-stats' });
575+
renderDiffStats(stats, changes.stats);
576+
577+
const visibleFiles = changes.files.slice(0, 3);
578+
const fileList = button.createSpan({ cls: 'qoderian-turn-changes-trigger-files' });
579+
for (const file of visibleFiles) {
580+
const row = fileList.createSpan({ cls: 'qoderian-turn-changes-trigger-file' });
581+
row.createSpan({
582+
cls: 'qoderian-turn-changes-trigger-path',
583+
text: turnFileDisplayPath(this.app, file),
584+
});
585+
const fileStats = row.createSpan({ cls: 'qoderian-turn-changes-trigger-file-stats' });
586+
renderDiffStats(fileStats, file.stats);
587+
if (file.stats.added === 0 && file.stats.removed === 0) {
588+
fileStats.setText(file.diffs.some(diff => diff.operation === 'delete')
589+
? t('chat.changes.deleted')
590+
: t('chat.changes.changed'));
591+
}
592+
}
593+
594+
const remaining = changes.files.length - visibleFiles.length;
595+
if (remaining > 0) {
596+
const more = button.createSpan({ cls: 'qoderian-turn-changes-trigger-more' });
597+
more.createSpan({ text: t('chat.changes.moreFiles', { count: remaining }) });
598+
const chevron = more.createSpan({ cls: 'qoderian-turn-changes-trigger-more-icon' });
599+
setIcon(chevron, 'chevron-down');
600+
}
601+
button.addEventListener('click', () => new TurnChangesModal(this.app, changes).open());
602+
}
603+
545604
/** Rebuilds the active assistant content after a streamed block is upgraded. */
546605
rerenderAssistantContent(msg: ChatMessage, contentEl: HTMLElement): void {
547606
contentEl.empty();

‎src/i18n/locales/de.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@
115115
"activity": {
116116
"steps": "Ausführungsschritte ({count})"
117117
},
118+
"changes": {
119+
"title": "Änderungen dieser Runde",
120+
"button": "Änderungen anzeigen · {count} Dateien",
121+
"edited": "{count} Dateien bearbeitet",
122+
"files": "{count} Dateien geändert",
123+
"edit": "Bearbeitung {count}",
124+
"deleted": "Datei gelöscht",
125+
"renamed": "Umbenannt in {path}",
126+
"changed": "Geändert",
127+
"moreFiles": "{count} weitere Dateien anzeigen"
128+
},
118129
"drop": {
119130
"context": "Notizen, Ordner oder Bilder hier ablegen, um sie als Kontext hinzuzufügen",
120131
"ignored": "{count} nicht unterstützte Dateien ignoriert; es können nur Notizen, Ordner und Bilder abgelegt werden"

‎src/i18n/locales/en.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@
115115
"activity": {
116116
"steps": "Execution steps ({count})"
117117
},
118+
"changes": {
119+
"title": "Turn changes",
120+
"button": "View changes · {count} files",
121+
"edited": "Edited {count} files",
122+
"files": "{count} files changed",
123+
"edit": "Edit {count}",
124+
"deleted": "File deleted",
125+
"renamed": "Renamed to {path}",
126+
"changed": "Changed",
127+
"moreFiles": "Show {count} more files"
128+
},
118129
"drop": {
119130
"context": "Drop notes, folders, or images here to add as context",
120131
"ignored": "Ignored {count} unsupported file(s); only notes, folders, and images can be dropped"

‎src/i18n/locales/es.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@
115115
"activity": {
116116
"steps": "Pasos de ejecución ({count})"
117117
},
118+
"changes": {
119+
"title": "Cambios de este turno",
120+
"button": "Ver cambios · {count} archivos",
121+
"edited": "{count} archivos editados",
122+
"files": "{count} archivos modificados",
123+
"edit": "Edición {count}",
124+
"deleted": "Archivo eliminado",
125+
"renamed": "Renombrado a {path}",
126+
"changed": "Modificado",
127+
"moreFiles": "Mostrar {count} archivos más"
128+
},
118129
"drop": {
119130
"context": "Arrastra notas, carpetas o imágenes aquí para añadirlas como contexto",
120131
"ignored": "Se ignoraron {count} archivos no compatibles; solo se pueden soltar notas, carpetas e imágenes"

0 commit comments

Comments
 (0)