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,6 +38,7 @@ import { ModelControls } from './ModelControls';
import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { StatusRow } from './StatusRow';
import { PendingChangesBar } from './PendingChangesBar';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
@@ -3397,6 +3398,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
showAbortStatus={showAbortStatus}
showAssistantStatus={false}
showTodos
leftAccessory={newSessionDraftOpen ? null : <PendingChangesBar />}
/>
{showDraftTargetSelectors && selectedDraftProject ? (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
@@ -0,0 +1,409 @@
import React from 'react';
import { RiFileEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
import type { ToolPart } from '@opencode-ai/sdk/v2';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessageRecords } from '@/sync/sync-context';
import { useStreamingStore, selectIsStreaming } from '@/sync/streaming';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useGitStore, useIsGitRepo } from '@/stores/useGitStore';
import { useUIStore } from '@/stores/useUIStore';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
// ---- Types ----
/** File changed by an AI tool (non-Git mode) */
interface ChangedFile {
path: string;
tool: string;
partId: string;
messageID: string;
additions?: number;
deletions?: number;
patch?: string;
}
/** File changed in workspace (Git mode) */
interface GitChangedFile {
path: string;
relativePath: string;
insertions: number;
deletions: number;
status: string;
}
type ChangedFileEntry = ChangedFile | GitChangedFile;
// ---- Helpers ----
const FILE_EDIT_TOOLS = new Set(['edit', 'multiedit', 'write', 'apply_patch', 'create', 'file_write']);
const parseCount = (value: unknown): number | undefined => {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.trunc(value));
return undefined;
};
const parsePatchStats = (patch: string): { added: number; removed: number } => {
let added = 0;
let removed = 0;
for (const line of patch.split('\n')) {
if (line.startsWith('+') && !line.startsWith('+++')) added++;
if (line.startsWith('-') && !line.startsWith('---')) removed++;
}
return { added, removed };
};
/** Extract changed files from tool parts of a single assistant message */
const extractChangedFiles = (parts: ToolPart[]): ChangedFile[] => {
const files: ChangedFile[] = [];
const seen = new Set<string>();
for (const part of parts) {
if (part.type !== 'tool') continue;
if (!FILE_EDIT_TOOLS.has(part.tool)) continue;
const state = part.state as { metadata?: Record<string, unknown>; input?: Record<string, unknown>; status?: string };
if (state.status && state.status !== 'completed') continue;
const sizeBeforeThisPart = files.length;
const metadata = state.metadata;
// Extract from metadata.files[] (apply_patch)
const metaFiles = Array.isArray(metadata?.files) ? metadata.files : [];
for (const file of metaFiles) {
if (!file || typeof file !== 'object') continue;
const record = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown; patch?: unknown };
const rawPath = record.relativePath || record.filePath || '';
if (!rawPath || seen.has(rawPath)) continue;
seen.add(rawPath);
files.push({
path: rawPath,
tool: part.tool,
partId: part.id,
messageID: part.messageID,
additions: parseCount(record.additions) ?? undefined,
deletions: parseCount(record.deletions) ?? undefined,
patch: typeof record.patch === 'string' ? record.patch : undefined,
});
}
// Fallback 1: extract from metadata.filediff (edit tool)
if (metaFiles.length === 0 && metadata?.filediff && typeof metadata.filediff === 'object') {
const fd = metadata.filediff as { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown };
const rawPath = typeof fd.file === 'string' ? fd.file : '';
if (rawPath && !seen.has(rawPath)) {
seen.add(rawPath);
files.push({
path: rawPath,
tool: part.tool,
partId: part.id,
messageID: part.messageID,
additions: parseCount(fd.additions) ?? undefined,
deletions: parseCount(fd.deletions) ?? undefined,
patch: typeof fd.patch === 'string' ? fd.patch : undefined,
});
}
}
// Fallback 2: extract from metadata.results[].filediff (multiedit tool)
if (metaFiles.length === 0 && Array.isArray(metadata?.results)) {
for (const result of metadata.results) {
if (!result || typeof result !== 'object') continue;
const fd = (result as { filediff?: { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown } }).filediff;
if (!fd || typeof fd !== 'object') continue;
const rawPath = typeof fd.file === 'string' ? fd.file : '';
if (!rawPath || seen.has(rawPath)) continue;
seen.add(rawPath);
files.push({
path: rawPath,
tool: part.tool,
partId: part.id,
messageID: part.messageID,
additions: parseCount(fd.additions) ?? undefined,
deletions: parseCount(fd.deletions) ?? undefined,
patch: typeof fd.patch === 'string' ? fd.patch : undefined,
});
}
}
// Fallback 3: extract from input.filePath for write-like tools
if (files.length === sizeBeforeThisPart) {
const input = state.input;
const filePath = typeof input?.filePath === 'string' ? input.filePath
: typeof input?.file_path === 'string' ? input.file_path
: typeof input?.path === 'string' ? input.path
: undefined;
if (filePath && !seen.has(filePath)) {
seen.add(filePath);
files.push({
path: filePath,
tool: part.tool,
partId: part.id,
messageID: part.messageID,
});
}
}
// Fallback 4: parse top-level patch/diff for stats
if (files.length === sizeBeforeThisPart) {
const patchText = typeof metadata?.patch === 'string' ? metadata.patch.trim()
: typeof metadata?.diff === 'string' ? metadata.diff.trim() : '';
if (patchText && !seen.has('Diff')) {
seen.add('Diff');
const parsed = parsePatchStats(patchText);
files.push({
path: 'Diff',
tool: part.tool,
partId: part.id,
messageID: part.messageID,
additions: parsed.added,
deletions: parsed.removed,
});
}
}
}
return files;
};
/** Convert absolute path to relative path based on current directory */
const toRelativePath = (absolutePath: string, baseDirectory: string): string => {
const norm = (p: string) => p.split('\\').join('/').replace(/\/+$/, '');
const base = norm(baseDirectory);
const absPath = norm(absolutePath);
if (absPath.startsWith(base + '/')) {
return absPath.slice(base.length + 1);
}
if (absPath.startsWith(base)) {
return absPath.slice(base.length) || absPath;
}
return absPath;
};
/** Extract changed files from GitStatus */
const extractGitChangedFiles = (
files: Array<{ path: string; index: string; working_dir: string }>,
diffStats: Record<string, { insertions: number; deletions: number }> | undefined,
directory: string,
): GitChangedFile[] => {
const result: GitChangedFile[] = [];
for (const file of files) {
const code = file.working_dir !== ' ' ? file.working_dir : file.index;
if (code === '!' || code === ' ') continue;
const stats = diffStats?.[file.path];
result.push({
path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path,
relativePath: file.path,
insertions: stats?.insertions ?? 0,
deletions: stats?.deletions ?? 0,
status: code,
});
}
return result;
};
/** Type guard for GitChangedFile */
const isGitFile = (file: ChangedFileEntry): file is GitChangedFile => {
return 'insertions' in file;
};
// ---- Component ----
export const PendingChangesBar: React.FC = React.memo(() => {
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const isGitRepo = useIsGitRepo(currentDirectory);
const gitStatus = useGitStore((s) =>
currentDirectory ? s.directories.get(currentDirectory)?.status ?? null : null,
);
const isStreaming = useStreamingStore(selectIsStreaming(currentSessionId ?? ''));
const popoverRef = React.useRef<HTMLDivElement>(null);
// ---- Mode selection ----
const mode: 'git' | 'non-git' = isGitRepo === true ? 'git' : 'non-git';
// ---- Git mode data ----
const gitChangedFiles = React.useMemo(() => {
if (isGitRepo !== true || mode !== 'git' || !gitStatus || gitStatus.isClean) return [];
return extractGitChangedFiles(gitStatus.files, gitStatus.diffStats, currentDirectory);
}, [isGitRepo, mode, gitStatus, currentDirectory]);
// ---- Non-Git mode data (latest assistant turn only) ----
const nonGitChangedFiles = React.useMemo(() => {
if (isGitRepo !== false || mode !== 'non-git' || !currentSessionId || isStreaming) return [];
for (let i = sessionMessageRecords.length - 1; i >= 0; i--) {
const record = sessionMessageRecords[i];
if (record.info.role !== 'assistant') continue;
const toolParts = record.parts.filter(
(p): p is ToolPart => p.type === 'tool' && FILE_EDIT_TOOLS.has(p.tool),
);
if (toolParts.length === 0) continue;
return extractChangedFiles(toolParts);
}
return [];
}, [isGitRepo, mode, sessionMessageRecords, currentSessionId, isStreaming]);
// ---- Merged view ----
const changedFiles: ChangedFileEntry[] = mode === 'git' ? gitChangedFiles : nonGitChangedFiles;
// ---- Aggregate stats ----
const { totalAdded, totalRemoved } = React.useMemo(() => {
let added = 0;
let removed = 0;
for (const file of changedFiles) {
if (isGitFile(file)) {
added += file.insertions;
removed += file.deletions;
} else {
if (file.additions != null) added += file.additions;
if (file.deletions != null) removed += file.deletions;
}
}
return { totalAdded: added, totalRemoved: removed };
}, [changedFiles]);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isExpanded]);
// Don't render while git status is still loading
if (isGitRepo === null) return null;
// ---- Visibility ----
if (changedFiles.length === 0) return null;
// ---- Handlers ----
const handleOpenFile = (file: ChangedFileEntry) => {
if (!currentDirectory) return;
const targetPath = isGitFile(file)
? file.relativePath
: toRelativePath(file.path, currentDirectory);
const store = useUIStore.getState();
if (!store.isMobile) {
store.openContextDiff(currentDirectory, targetPath);
return;
}
store.navigateToDiff(targetPath);
store.setRightSidebarOpen(false);
};
// ---- Label ----
const fileCount = changedFiles.length;
const labelHead = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
const labelTail = mode === 'git' ? 'changed in workspace' : 'changed in the last reply';
// ---- Display helpers ----
const getDisplayPath = (file: ChangedFileEntry): { fileName: string; dirPart: string } => {
const relativePath = isGitFile(file) && file.relativePath
? file.relativePath
: toRelativePath(file.path, currentDirectory);
const fileName = relativePath.split('/').pop() ?? relativePath;
const dirPart = relativePath.includes('/') ? relativePath.slice(0, relativePath.lastIndexOf('/')) : '';
return { fileName, dirPart };
};
const getFileStats = (file: ChangedFileEntry): { additions: number; deletions: number } => {
if (isGitFile(file)) return { additions: file.insertions, deletions: file.deletions };
return { additions: file.additions ?? 0, deletions: file.deletions ?? 0 };
};
// ---- Render ----
return (
<div className="relative flex min-w-0 items-center" ref={popoverRef}>
<button
type="button"
onClick={() => setIsExpanded((prev) => !prev)}
className="flex min-w-0 max-w-full items-center gap-1 text-left text-muted-foreground"
>
<RiFileEditLine className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">{labelTail}</span>
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
{totalAdded > 0 ? <span style={{ color: 'var(--status-success)' }}>+{totalAdded}</span> : null}
{totalRemoved > 0 ? <span style={{ color: 'var(--status-error)' }}>-{totalRemoved}</span> : null}
</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5 flex-shrink-0" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0" />
)}
</button>
{isExpanded ? (
<div
style={{
maxWidth: 'calc(100cqw - 4ch)',
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
}}
className="absolute left-0 bottom-full mb-1 z-50 w-max min-w-[280px] max-w-full 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"
>
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>Changed files</span>
<span className="typography-meta tabular-nums">{fileCount}</span>
</div>
<div className="max-h-[260px] overflow-y-auto">
{changedFiles.map((file, index) => {
const { fileName, dirPart } = getDisplayPath(file);
const stats = getFileStats(file);
return (
<button
key={`${file.path}:${index}`}
type="button"
className="relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none text-left hover:bg-interactive-hover"
title={`Open diff for ${file.path}`}
onClick={() => handleOpenFile(file)}
>
<FileTypeIcon filePath={file.path} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="min-w-0 flex-1 flex items-baseline overflow-hidden" title={file.path}>
{dirPart ? (
<>
<span
className="min-w-0 truncate text-muted-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
>
{dirPart}
</span>
<span className="flex-shrink-0">
<span className="text-muted-foreground">/</span>
<span className="text-foreground">{fileName}</span>
</span>
</>
) : (
<span className="truncate text-foreground">{fileName}</span>
)}
</span>
{(stats.additions > 0 || stats.deletions > 0) ? (
<span className="flex-shrink-0 inline-flex items-baseline gap-1 text-[0.75rem] tabular-nums">
{stats.additions > 0 ? <span style={{ color: 'var(--status-success)' }}>+{stats.additions}</span> : null}
{stats.deletions > 0 ? <span style={{ color: 'var(--status-error)' }}>-{stats.deletions}</span> : null}
</span>
) : null}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
});
PendingChangesBar.displayName = 'PendingChangesBar';
+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} />
))}
+13
View File
@@ -763,6 +763,19 @@ html:not(.dark) .chat-scroll {
}
}
/* Status row: collapse optional text when narrow to keep both sides in one line. */
@container status-row (max-width: 30rem) {
.status-row__active-todo {
display: none;
}
}
@container status-row (max-width: 24rem) {
.status-row__changed-label {
display: none;
}
}
/* Animated tabs: collapse labels based on local container width. */
@container animated-tabs (max-width: 23rem) {
.animated-tabs__label {
@@ -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' },
),
);
+23
View File
@@ -187,6 +187,10 @@ export type SessionUIState = {
markSessionPlanAvailable: (sessionId: string) => void
isSessionPlanAvailable: (sessionId: string) => boolean
// Non-Git mode: dismissed signature hash per session, hides bar until new turn arrives
pendingChangesBarDismissed: Map<string, string>
dismissPendingChangesBar: (sessionId: string, signature: string | null) => void
// Actions — UI state management
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState>) => void
@@ -343,6 +347,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
isLoading: false,
lastLoadedDirectory: null,
sessionPlanAvailable: new Map(),
pendingChangesBarDismissed: new Map(),
// ---------------------------------------------------------------------------
// setCurrentSession
@@ -650,6 +655,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
getWorktreeMetadata: (sessionId) => get().worktreeMetadata.get(sessionId),
dismissPendingChangesBar: (sessionId, signature) => {
const map = new Map(get().pendingChangesBarDismissed);
if (signature === null) {
map.delete(sessionId);
} else {
map.set(sessionId, signature);
}
set({ pendingChangesBarDismissed: map });
},
// ---------------------------------------------------------------------------
// sendMessage — calls SDK, reads domain data from sync
// ---------------------------------------------------------------------------
@@ -664,6 +679,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
variant?: string,
inputMode?: "normal" | "shell",
) => {
// Clear non-Git changed-files bar on new user message for current session
const sid = get().currentSessionId;
if (sid) {
const map = new Map(get().pendingChangesBarDismissed);
map.delete(sid);
set({ pendingChangesBarDismissed: map });
}
const draft = get().newSessionDraft
const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined
+6 -1
View File
@@ -27,6 +27,7 @@ import { syncDebug } from "./debug"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import { useConfigStore } from "@/stores/useConfigStore"
import { useTodosPersistStore } from "@/stores/useTodosPersistStore"
import { toast } from "@/components/ui"
import { appendNotification } from "./notification-store"
import type { State } from "./types"
@@ -1159,7 +1160,11 @@ function handleEvent(
break
}
if (applyDirectoryEvent(draft, payload)) {
if (applyDirectoryEvent(draft, payload, {
onSetSessionTodo: (sessionID, todos) => {
useTodosPersistStore.getState().setSessionTodos(sessionID, todos)
},
})) {
store.setState(draft)
const sessionID = getSessionIdFromPayload(payload) ?? undefined
const messageID = getMessageIdFromPayload(payload) ?? undefined