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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
947f725976
commit
9c71119835
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user