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 { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
|
||||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||||
import { StatusRow } from './StatusRow';
|
import { StatusRow } from './StatusRow';
|
||||||
|
import { PendingChangesBar } from './PendingChangesBar';
|
||||||
import { MobileAgentButton } from './MobileAgentButton';
|
import { MobileAgentButton } from './MobileAgentButton';
|
||||||
import { MobileModelButton } from './MobileModelButton';
|
import { MobileModelButton } from './MobileModelButton';
|
||||||
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
|
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
|
||||||
@@ -3397,6 +3398,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
showAbortStatus={showAbortStatus}
|
showAbortStatus={showAbortStatus}
|
||||||
showAssistantStatus={false}
|
showAssistantStatus={false}
|
||||||
showTodos
|
showTodos
|
||||||
|
leftAccessory={newSessionDraftOpen ? null : <PendingChangesBar />}
|
||||||
/>
|
/>
|
||||||
{showDraftTargetSelectors && selectedDraftProject ? (
|
{showDraftTargetSelectors && selectedDraftProject ? (
|
||||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
<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 TodoStatus = string;
|
||||||
type TodoPriority = string;
|
type TodoPriority = string;
|
||||||
import { useUIStore } from "@/stores/useUIStore";
|
import { useUIStore } from "@/stores/useUIStore";
|
||||||
|
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
||||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
@@ -134,6 +135,7 @@ interface StatusRowProps {
|
|||||||
showAssistantStatus?: boolean;
|
showAssistantStatus?: boolean;
|
||||||
showTodos?: boolean;
|
showTodos?: boolean;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
|
leftAccessory?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||||
@@ -150,14 +152,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
showAssistantStatus = true,
|
showAssistantStatus = true,
|
||||||
showTodos = true,
|
showTodos = true,
|
||||||
agentName,
|
agentName,
|
||||||
|
leftAccessory,
|
||||||
}) => {
|
}) => {
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const todosRecord = useDirectorySync((state) => state.todo);
|
const todosRecord = useDirectorySync((state) => state.todo);
|
||||||
const todos: TodoItem[] = React.useMemo(
|
const persistedSessionTodos = useTodosPersistStore(
|
||||||
() => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS),
|
React.useCallback(
|
||||||
[todosRecord, currentSessionId],
|
(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 isMobile = useUIStore((state) => state.isMobile);
|
||||||
const isCompact = isMobile || isVSCodeRuntime();
|
const isCompact = isMobile || isVSCodeRuntime();
|
||||||
|
|
||||||
@@ -189,17 +200,17 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
return { active, left };
|
return { active, left };
|
||||||
}, [visibleTodos]);
|
}, [visibleTodos]);
|
||||||
|
|
||||||
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
|
const hasTodoContent = showTodos && visibleTodos.length > 0;
|
||||||
const hasTodoContent = showTodos && hasActiveTodos;
|
|
||||||
const hasAssistantContent = showAssistantStatus && (
|
const hasAssistantContent = showAssistantStatus && (
|
||||||
isWorking ||
|
isWorking ||
|
||||||
Boolean(wasAborted) ||
|
Boolean(wasAborted) ||
|
||||||
Boolean(showAbortStatus)
|
Boolean(showAbortStatus)
|
||||||
);
|
);
|
||||||
|
const hasLeftAccessory = Boolean(leftAccessory);
|
||||||
// Original logic from ChatInput
|
// Original logic from ChatInput
|
||||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||||
|
|
||||||
const hasContent = hasAssistantContent || hasTodoContent;
|
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
|
||||||
|
|
||||||
// Close popover when clicking outside
|
// Close popover when clicking outside
|
||||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -239,7 +250,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
>
|
>
|
||||||
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
|
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
|
||||||
{!isCompact && activeTodo ? (
|
{!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}
|
{activeTodo.content}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -262,10 +273,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-column mb-1" style={{ containerType: "inline-size" }}>
|
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={{ containerType: "inline-size", containerName: "status-row" }}>
|
||||||
<div className="flex items-center justify-between py-0.5 gap-2 h-[1.2rem]">
|
<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 */}
|
{/* Left: Abort status or Working placeholder or leftAccessory */}
|
||||||
<div className="flex-1 flex items-center overflow-hidden min-w-0">
|
<div className={cn("flex-1 flex items-center min-w-0", hasLeftAccessory ? "pl-1.5" : "overflow-hidden")}>
|
||||||
{showAssistantStatus && showAbortStatus ? (
|
{showAssistantStatus && showAbortStatus ? (
|
||||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
<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">
|
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||||
@@ -283,36 +294,43 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
retryInfo={retryInfo}
|
retryInfo={retryInfo}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
/>
|
/>
|
||||||
|
) : leftAccessory ? (
|
||||||
|
leftAccessory
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Abort (mobile only) + Todo */}
|
{/* 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}
|
{abortButton}
|
||||||
{todoTrigger}
|
{todoTrigger}
|
||||||
|
|
||||||
{/* Popover dropdown */}
|
{/* Popover dropdown */}
|
||||||
{isExpanded && hasActiveTodos && (
|
{isExpanded && hasTodoContent && (
|
||||||
<div
|
<div
|
||||||
style={{ maxWidth: "calc(100cqw - 4ch)" }}
|
style={{
|
||||||
|
maxWidth: "min(28rem, calc(100cqw - 4ch))",
|
||||||
|
backgroundColor: "var(--surface-elevated)",
|
||||||
|
color: "var(--surface-elevated-foreground)",
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-0 bottom-full mb-1 z-50",
|
"absolute right-0 bottom-full mb-1 z-50",
|
||||||
"w-max min-w-[200px]",
|
"w-max min-w-[200px] rounded-xl p-1",
|
||||||
"rounded-xl border border-border bg-background shadow-none",
|
"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",
|
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
||||||
"duration-150"
|
"duration-150"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
||||||
<span className="typography-ui-label text-muted-foreground">Tasks</span>
|
<span>Tasks</span>
|
||||||
<span className="typography-meta text-muted-foreground">
|
<span className="typography-meta tabular-nums">
|
||||||
{progress.completed}/{progress.total}
|
{progress.completed}/{progress.total}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Todo list */}
|
{/* 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) => (
|
{visibleTodos.map((todo, index) => (
|
||||||
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
<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. */
|
/* Animated tabs: collapse labels based on local container width. */
|
||||||
@container animated-tabs (max-width: 23rem) {
|
@container animated-tabs (max-width: 23rem) {
|
||||||
.animated-tabs__label {
|
.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
|
markSessionPlanAvailable: (sessionId: string) => void
|
||||||
isSessionPlanAvailable: (sessionId: string) => boolean
|
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
|
// Actions — UI state management
|
||||||
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
|
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
|
||||||
openNewSessionDraft: (options?: Partial<NewSessionDraftState>) => void
|
openNewSessionDraft: (options?: Partial<NewSessionDraftState>) => void
|
||||||
@@ -343,6 +347,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
lastLoadedDirectory: null,
|
lastLoadedDirectory: null,
|
||||||
sessionPlanAvailable: new Map(),
|
sessionPlanAvailable: new Map(),
|
||||||
|
pendingChangesBarDismissed: new Map(),
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// setCurrentSession
|
// setCurrentSession
|
||||||
@@ -650,6 +655,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
|
|
||||||
getWorktreeMetadata: (sessionId) => get().worktreeMetadata.get(sessionId),
|
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
|
// sendMessage — calls SDK, reads domain data from sync
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -664,6 +679,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
variant?: string,
|
variant?: string,
|
||||||
inputMode?: "normal" | "shell",
|
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 draft = get().newSessionDraft
|
||||||
const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined
|
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 { opencodeClient } from "@/lib/opencode/client"
|
||||||
import { usePermissionStore } from "@/stores/permissionStore"
|
import { usePermissionStore } from "@/stores/permissionStore"
|
||||||
import { useConfigStore } from "@/stores/useConfigStore"
|
import { useConfigStore } from "@/stores/useConfigStore"
|
||||||
|
import { useTodosPersistStore } from "@/stores/useTodosPersistStore"
|
||||||
import { toast } from "@/components/ui"
|
import { toast } from "@/components/ui"
|
||||||
import { appendNotification } from "./notification-store"
|
import { appendNotification } from "./notification-store"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
@@ -1159,7 +1160,11 @@ function handleEvent(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (applyDirectoryEvent(draft, payload)) {
|
if (applyDirectoryEvent(draft, payload, {
|
||||||
|
onSetSessionTodo: (sessionID, todos) => {
|
||||||
|
useTodosPersistStore.getState().setSessionTodos(sessionID, todos)
|
||||||
|
},
|
||||||
|
})) {
|
||||||
store.setState(draft)
|
store.setState(draft)
|
||||||
const sessionID = getSessionIdFromPayload(payload) ?? undefined
|
const sessionID = getSessionIdFromPayload(payload) ?? undefined
|
||||||
const messageID = getMessageIdFromPayload(payload) ?? undefined
|
const messageID = getMessageIdFromPayload(payload) ?? undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user