feat: show file change summary bar (#950)

* feat: add FileChangeSummary component for multi-file diff preview in ToolPart

Provides an aggregated diff card for apply_patch and multi-edit tools,
showing per-file stats with click-to-expand diff view.

* feat: add PendingChangesBar above chat input with collapse/expand and file opening

- Collapsed/expanded toggle with aggregate +N -N stats (green/red)
- Relative path display, chat-column alignment with ChatInput
- Click file to open in diff viewer (web/desktop) or editor (VS Code)
- Support edit/multiedit/apply_patch/write tool metadata extraction
- Add PendingChangesBar to main chat view (ChatContainer)

* feat: dual-mode ChangedFilesBar — Git diff state vs latest AI turn

Git mode: reads git status from useGitStore (status.files + diffStats),
auto-clears on commit/restore. Shows 'N files changed in workspace'.

Non-Git mode: latest assistant turn only (no accumulation), clears on new
user message or manual dismiss. Shows 'AI updated N files in the last reply'.

Both modes: dismiss button with signature-based tracking.
Add pendingChangesBarDismissed state to session-ui-store, cleared on sendMessage.

* fix: address code review issues in ChangedFilesBar

- Gate non-git mode on streaming state to prevent flicker during AI turns
- Return null when isGitRepo is unknown (loading state)
- Add group/row class for reject button visibility in FileChangeSummary
- Use per-session Map for dismiss tracking to prevent cross-session leaks
- Include additions/deletions in dismiss signature for re-edit detection
- Extract shared parsePatchStats/parseCount to fileChangeHelpers

* chore: revert local .opencode/package-lock.json changes from PR

* fix: per-part fallback guard and git-only reject button

- Fix extractChangedFiles to use per-part files.length snapshot instead
  of global guard, preventing file entries from being skipped when
  earlier parts already contributed files
- Hide reject button in FileChangeSummary when not in a git repo,
  preventing silent revert failures

* refactor: remove FileChangeSummary — dead code redundant with ToolPart

FileChangeSummary duplicated diff rendering that ToolPart already
provides (PatchDiff, per-file stats, DiffViewToggle). The only unique
feature was a git revert button, which conflicts with the design
principle of not having accept/reject on file change previews.

Moved parsePatchStats/parseCount back into PendingChangesBar (sole
consumer) and deleted the shared helper module.

* chore: remove stray Tester.txt

* fix: deduplicate Fallback 4 'Diff' placeholder via seen set

* chore: update non-Git mode copy to neutral 'changed in the last reply'

* fix(chat): unify changes row with tasks

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-04-21 22:34:06 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d73edc672e
commit d4a4f43a83
7 changed files with 555 additions and 21 deletions
@@ -0,0 +1,64 @@
import { create } from 'zustand';
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
import type { Todo } from '@opencode-ai/sdk/v2/client';
import { getSafeStorage } from './utils/safeStorage';
const MAX_SESSIONS = 50;
interface SessionTodosRecord {
todos: Todo[];
touchedAt: number;
}
interface TodosPersistState {
sessions: Record<string, SessionTodosRecord>;
setSessionTodos: (sessionId: string, todos: Todo[] | undefined) => void;
getSessionTodos: (sessionId: string) => Todo[] | undefined;
}
const evictOldest = (sessions: Record<string, SessionTodosRecord>): Record<string, SessionTodosRecord> => {
const ids = Object.keys(sessions);
if (ids.length <= MAX_SESSIONS) return sessions;
const sorted = ids
.map((id) => [id, sessions[id].touchedAt] as const)
.sort((a, b) => a[1] - b[1]);
const drop = sorted.slice(0, ids.length - MAX_SESSIONS).map(([id]) => id);
const next = { ...sessions };
for (const id of drop) delete next[id];
return next;
};
export const useTodosPersistStore = create<TodosPersistState>()(
devtools(
persist(
(set, get) => ({
sessions: {},
setSessionTodos: (sessionId, todos) => {
if (!sessionId) return;
set((state) => {
const next = { ...state.sessions };
if (!todos || todos.length === 0) {
if (!(sessionId in next)) return state;
delete next[sessionId];
return { sessions: next };
}
next[sessionId] = { todos, touchedAt: Date.now() };
return { sessions: evictOldest(next) };
});
},
getSessionTodos: (sessionId) => {
if (!sessionId) return undefined;
return get().sessions[sessionId]?.todos;
},
}),
{
name: 'openchamber-session-todos',
version: 1,
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({ sessions: state.sessions }),
},
),
{ name: 'TodosPersistStore' },
),
);