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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d73edc672e
commit
d4a4f43a83
@@ -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';
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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' },
|
||||
),
|
||||
);
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user