feat(ui): revert indicator with undo/redo, message list, and attachment restore (#1279)

* feat(ui): revert indicator with undo/redo, message list, and attachment restore

Add bidirectional undo/redo with redo stack navigation and expandable
revert indicator in StatusRow. List reverted messages with inline
revert/fork actions. Restore file attachments on revert.

- Add revert indicator with count, expandable popover, per-button spinner
- Restore file/image attachments when reverting to a message
- Clear previous attachments on revert when target has none
- Restore-all bypasses redo stack for direct unrevert
- Survive popover close/reopen without losing loading state
- Fix flash when sending message after revert
- Fix count disappearing on browser refresh
- Remove dead code (undoStack, getRevertHistory, fork-from-here)
- Fix toast grammar (Undid -> Reverted, Redid -> Redone)
- Add i18n keys for revert popover across all locales

* fix(ui): Greptile review fixes and i18n for revert toasts

- Fix handleSlashUndo toast always showing [No text] (moved getSyncParts before revertToMessage)
- Add inputStore rollback in revertToMessage catch (restore attachments + text on API failure)
- Change portal ID to per-session (prevent multi-session collisions)
- Add i18n keys for undo/redo/restored toasts across all 7 locales
- Use formatMessage in store for localized toast strings

* fix(ui): add missing sessionId to click-outside effect deps

* fix(ui): remove unused sessionActions import in ChatMessage

* fix(ui): derive revert dock from session state

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
youfch
2026-05-16 16:44:37 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 947f725976
commit 9c71119835
13 changed files with 371 additions and 47 deletions
+170 -1
View File
@@ -10,7 +10,7 @@ import { useSelectionStore } from '@/sync/selection-store';
import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import * as sessionActions from '@/sync/session-actions';
import { useUserMessageHistory } from '@/sync/sync-context';
import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context';
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts';
@@ -30,6 +30,7 @@ import { MobileModelButton } from './MobileModelButton';
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
// useMessageStore removed — messages now come from sync system
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
import { isIMECompositionEvent } from '@/lib/ime';
@@ -63,9 +64,12 @@ import { fetchResponseStyleInstruction } from '@/lib/responseStyle';
import { wrapSystemReminder } from '@/lib/systemReminder';
import { getSyncMessages } from '@/sync/sync-refs';
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
import { isSyntheticPart } from '@/lib/messages/synthetic';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_MESSAGES: Message[] = [];
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
const VS_CODE_DROP_DATA_TYPES = [
@@ -81,6 +85,26 @@ const hasUserMessages = (sessionId: string, directory?: string) => {
return getSyncMessages(sessionId, directory).some((message) => message.role === 'user');
};
const getRevertedPreview = (parts: Part[], fallback: string): string => {
const text = parts
.filter((part) => part.type === 'text' && !isSyntheticPart(part))
.map((part) => {
const record = part as Record<string, unknown>;
return typeof record.text === 'string'
? record.text
: typeof record.content === 'string'
? record.content
: '';
})
.join('\n')
.replace(/\s+/g, ' ')
.trim();
if (text) return text;
const filePart = parts.find((part) => part.type === 'file') as (Part & { filename?: string }) | undefined;
return filePart?.filename ? `[${filePart.filename}]` : fallback;
};
const FILE_URI_PREFIX = 'file://';
const encodeFilePath = (filepath: string): string => {
@@ -228,6 +252,144 @@ const MemoMobileAgentButton = React.memo(MobileAgentButton);
const MemoMobileModelButton = React.memo(MobileModelButton);
const MemoStatusRow = React.memo(StatusRow);
type RevertedMessageDockProps = {
sessionId: string | null;
directory?: string;
};
const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.memo(({ sessionId, directory }) => {
const { t } = useI18n();
const revertToMessage = useSessionUIStore((s) => s.revertToMessage);
const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage);
const handleSlashRedo = useSessionUIStore((s) => s.handleSlashRedo);
const [restoringId, setRestoringId] = React.useState<string | null>(null);
const [forkingId, setForkingId] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState(true);
const revertMessageID = useDirectorySync(
React.useCallback((state) => {
if (!sessionId) return undefined;
const session = state.session.find((item) => item.id === sessionId);
return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
}, [sessionId]),
directory,
);
const sessionMessages = useDirectorySync(
React.useCallback((state) => (sessionId ? state.message[sessionId] ?? EMPTY_MESSAGES : EMPTY_MESSAGES), [sessionId]),
directory,
);
const partsByMessage = useDirectorySync(React.useCallback((state) => state.part, []), directory);
const userMessages = React.useMemo(
() => sessionMessages.filter((message): message is Message & { role: 'user' } => message.role === 'user'),
[sessionMessages],
);
const noTextContent = t('chat.revertPopover.noTextContent');
const items = React.useMemo(() => {
if (!revertMessageID) return [];
return userMessages
.filter((message) => message.id >= revertMessageID)
.map((message) => ({
id: message.id,
text: getRevertedPreview(partsByMessage[message.id] ?? [], noTextContent),
}));
}, [noTextContent, partsByMessage, revertMessageID, userMessages]);
const firstRevertedMessageId = items[0]?.id;
React.useEffect(() => {
setCollapsed(true);
}, [revertMessageID, firstRevertedMessageId]);
const handleRestore = React.useCallback(async (messageId: string) => {
if (!sessionId || restoringId) return;
setRestoringId(messageId);
try {
const nextMessage = userMessages.find((message) => message.id > messageId);
if (nextMessage) {
await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true });
} else {
await handleSlashRedo(sessionId, { fullUnrevert: true });
}
} finally {
setRestoringId(null);
}
}, [handleSlashRedo, revertToMessage, restoringId, sessionId, userMessages]);
const handleFork = React.useCallback(async (messageId: string) => {
if (!sessionId || forkingId) return;
setForkingId(messageId);
try {
await forkFromMessage(sessionId, messageId);
} finally {
setForkingId(null);
}
}, [forkFromMessage, forkingId, sessionId]);
if (!sessionId || items.length === 0) return null;
return (
<div className="pb-2 w-full px-1">
<div className="rounded-xl border border-border/60 bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] shadow-sm overflow-hidden">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[var(--interactive-hover)] transition-colors"
onClick={() => setCollapsed((value) => !value)}
aria-expanded={!collapsed}
>
<span className="typography-ui-label font-medium text-foreground flex-shrink-0">
{t('chat.revertPopover.title')} messages {items.length}
</span>
<Icon
name="arrow-down-s"
className={cn("ml-auto h-4 w-4 text-muted-foreground transition-transform", !collapsed && "rotate-180")}
aria-hidden="true"
/>
</button>
{!collapsed && (
<div className="px-3 pb-3 flex flex-col gap-1.5 max-h-[10.5rem] overflow-y-auto">
{items.map((item) => (
<div key={item.id} className="flex min-w-0 items-center gap-2 py-1">
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
{item.text}
</span>
<Button
type="button"
variant="secondary"
size="xs"
disabled={Boolean(restoringId || forkingId)}
onClick={() => { void handleFork(item.id); }}
>
{forkingId === item.id ? (
<Icon name="loader-4" className="h-3 w-3 animate-spin" aria-hidden="true" />
) : (
<Icon name="git-branch" className="h-3 w-3" aria-hidden="true" />
)}
{t('chat.revertPopover.fork')}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
disabled={Boolean(restoringId || forkingId)}
onClick={() => { void handleRestore(item.id); }}
>
{restoringId === item.id ? (
<Icon name="loader-4" className="h-3 w-3 animate-spin" aria-hidden="true" />
) : (
<Icon name="arrow-go-forward" className="h-3 w-3" aria-hidden="true" />
)}
{t('chat.revertPopover.restore')}
</Button>
</div>
))}
</div>
)}
</div>
</div>
);
});
RevertedMessageDock.displayName = 'RevertedMessageDock';
type ComposerAttachmentControlsProps = {
isMobile: boolean;
isVSCode: boolean;
@@ -754,6 +916,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
).current;
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
@@ -3523,6 +3688,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
</div>
</div>
)}
<RevertedMessageDock
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
/>
<MemoStatusRow
showAbortStatus={showAbortStatus}
showAssistantStatus={false}
@@ -10,7 +10,6 @@ import { useUIStore } from '@/stores/useUIStore';
import { useContextStore } from '@/stores/contextStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
@@ -158,8 +157,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
const revertToMessage = sessionActions.revertToMessage;
const forkFromMessage = sessionActions.forkFromMessage;
const revertToMessage = useSessionUIStore((s) => s.revertToMessage);
const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage);
streamPerfCount('ui.chat_message.render');
if (isInActiveTurn) {
@@ -288,8 +288,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return (
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={STATUS_ROW_CONTAINER_STYLE}>
<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")}>
{/* Left: Abort status | Working placeholder | leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-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">