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
+38 -20
View File
@@ -18,6 +18,7 @@ type TodoItem = Todo & { id?: string };
type TodoStatus = string;
type TodoPriority = string;
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -134,6 +135,7 @@ interface StatusRowProps {
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string;
leftAccessory?: React.ReactNode;
}
export const StatusRow: React.FC<StatusRowProps> = ({
@@ -150,14 +152,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
showAssistantStatus = true,
showTodos = true,
agentName,
leftAccessory,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const todosRecord = useDirectorySync((state) => state.todo);
const todos: TodoItem[] = React.useMemo(
() => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS),
[todosRecord, currentSessionId],
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (currentSessionId ? state.sessions[currentSessionId]?.todos : undefined),
[currentSessionId],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
const live = todosRecord[currentSessionId];
if (live && live.length > 0) return live;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [todosRecord, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
@@ -189,17 +200,17 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return { active, left };
}, [visibleTodos]);
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
const hasTodoContent = showTodos && hasActiveTodos;
const hasTodoContent = showTodos && visibleTodos.length > 0;
const hasAssistantContent = showAssistantStatus && (
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus)
);
const hasLeftAccessory = Boolean(leftAccessory);
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = hasAssistantContent || hasTodoContent;
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
@@ -239,7 +250,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="typography-ui-label text-foreground truncate max-w-[200px]">
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
@@ -262,10 +273,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
}
return (
<div className="chat-column mb-1" style={{ containerType: "inline-size" }}>
<div className="flex items-center justify-between py-0.5 gap-2 h-[1.2rem]">
{/* Left: Abort status or Working placeholder */}
<div className="flex-1 flex items-center overflow-hidden min-w-0">
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={{ containerType: "inline-size", containerName: "status-row" }}>
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
{/* Left: Abort status or Working placeholder or leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0", hasLeftAccessory ? "pl-1.5" : "overflow-hidden")}>
{showAssistantStatus && showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
@@ -283,36 +294,43 @@ export const StatusRow: React.FC<StatusRowProps> = ({
retryInfo={retryInfo}
agentName={agentName}
/>
) : leftAccessory ? (
leftAccessory
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className="relative -mr-3 flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasActiveTodos && (
{isExpanded && hasTodoContent && (
<div
style={{ maxWidth: "calc(100cqw - 4ch)" }}
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px]",
"rounded-xl border border-border bg-background shadow-none",
"w-max min-w-[200px] 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"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="typography-ui-label text-muted-foreground">Tasks</span>
<span className="typography-meta text-muted-foreground">
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>Tasks</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-3 py-2 max-h-[200px] overflow-y-auto divide-y divide-border">
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}