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">
+13
View File
@@ -1402,6 +1402,19 @@ export const dict = {
'chat.statusRow.tasksTitle': 'Tasks',
'chat.statusRow.summary.activeLeft': '{active} active · {left} left',
'chat.statusRow.aborted': 'Aborted',
'chat.revertIndicator.redo': 'Redo',
'chat.revertIndicator.redoAria': 'Redo — restore reverted messages',
'chat.revertPopover.title': 'Reverted',
'chat.revertPopover.noTextContent': 'No text content',
'chat.revertPopover.forkFromHere': 'Fork from here',
'chat.revertPopover.forkFromMessage': 'Fork from this message',
'chat.revertPopover.restoreAll': 'Restore all',
'chat.revertPopover.restore': 'Restore',
'chat.revertPopover.revert': 'Revert',
'chat.revertPopover.fork': 'Fork',
'chat.revert.toast.undo': 'Reverted to {preview}',
'chat.revert.toast.redo': 'Redone',
'chat.revert.toast.restored': 'Restored all messages',
'chat.errorBoundary.title': 'Chat Error',
'chat.errorBoundary.description': 'The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.',
'chat.errorBoundary.sessionLabel': 'Session',
+13
View File
@@ -1368,6 +1368,19 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.tasksTitle": "Tareas",
"chat.statusRow.summary.activeLeft": "{active} activas · {left} restantes",
"chat.statusRow.aborted": "Interrumpido",
"chat.revertIndicator.redo": "Rehacer",
"chat.revertIndicator.redoAria": "Rehacer — restaurar mensajes revertidos",
"chat.revertPopover.title": "Revertidos",
"chat.revertPopover.noTextContent": "Sin contenido de texto",
"chat.revertPopover.forkFromHere": "Bifurcar desde aquí",
"chat.revertPopover.forkFromMessage": "Bifurcar desde este mensaje",
"chat.revertPopover.restoreAll": "Restaurar todo",
"chat.revertPopover.restore": "Restaurar",
"chat.revertPopover.revert": "Revertir",
"chat.revertPopover.fork": "Bifurcar",
"chat.revert.toast.undo": "Revertido a {preview}",
"chat.revert.toast.redo": "Rehecho",
"chat.revert.toast.restored": "Todos los mensajes restaurados",
"chat.errorBoundary.title": "Error en la conversación",
"chat.errorBoundary.description": "La interfaz de la conversación encontró un error. Esto podría deberse a un problema de red temporal o a datos de mensaje corruptos.",
"chat.errorBoundary.sessionLabel": "Sesión",
+13
View File
@@ -1404,6 +1404,19 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': '작업',
'chat.statusRow.summary.activeLeft': '{active}개 활성 · {left}개 남음',
'chat.statusRow.aborted': '중단됨',
'chat.revertIndicator.redo': '다시 실행',
'chat.revertIndicator.redoAria': '다시 실행 — 되돌린 메시지 복원',
'chat.revertPopover.title': '되돌림',
'chat.revertPopover.noTextContent': '텍스트 없음',
'chat.revertPopover.forkFromHere': '여기서 분기',
'chat.revertPopover.forkFromMessage': '이 메시지에서 분기',
'chat.revertPopover.restoreAll': '모두 복원',
'chat.revertPopover.restore': '복원',
'chat.revertPopover.revert': '되돌리기',
'chat.revertPopover.fork': '분기',
'chat.revert.toast.undo': '{preview}(으)로 되돌림',
'chat.revert.toast.redo': '다시 실행',
'chat.revert.toast.restored': '모든 메시지 복원됨',
'chat.errorBoundary.title': '채팅 오류',
'chat.errorBoundary.description': '채팅 인터페이스에서 오류가 발생했습니다. 일시적인 네트워크 이슈 또는 손상된 메시지 데이터 때문일 수 있습니다.',
'chat.errorBoundary.sessionLabel': '세션',
+13
View File
@@ -459,6 +459,19 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': 'Zadania',
'chat.statusRow.summary.activeLeft': '{active} aktywne · {left} pozostało',
'chat.statusRow.aborted': 'Przerwane',
'chat.revertIndicator.redo': 'Ponów',
'chat.revertIndicator.redoAria': 'Ponów — przywróć cofnięte wiadomości',
'chat.revertPopover.title': 'Cofnięte',
'chat.revertPopover.noTextContent': 'Brak treści tekstowej',
'chat.revertPopover.forkFromHere': 'Rozgałęź stąd',
'chat.revertPopover.forkFromMessage': 'Rozgałęź od tej wiadomości',
'chat.revertPopover.restoreAll': 'Przywróć wszystkie',
'chat.revertPopover.restore': 'Przywróć',
'chat.revertPopover.revert': 'Cofnij',
'chat.revertPopover.fork': 'Rozgałęź',
'chat.revert.toast.undo': 'Cofnięte do {preview}',
'chat.revert.toast.redo': 'Ponowione',
'chat.revert.toast.restored': 'Przywrócono wszystkie wiadomości',
'chat.errorBoundary.title': 'Błąd Czatu',
'chat.errorBoundary.description': 'Interfejs czatu napotkał błąd. Może to być spowodowane tymczasowym problemem sieciowym lub uszkodzonymi danymi wiadomości.',
'chat.errorBoundary.sessionLabel': 'Sesja',
@@ -1368,6 +1368,19 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.tasksTitle": "Tarefas",
"chat.statusRow.summary.activeLeft": "{active} ativas · {left} restantes",
"chat.statusRow.aborted": "Interrompido",
"chat.revertIndicator.redo": "Refazer",
"chat.revertIndicator.redoAria": "Refazer — restaurar mensagens revertidas",
"chat.revertPopover.title": "Revertidas",
"chat.revertPopover.noTextContent": "Sem conteúdo de texto",
"chat.revertPopover.forkFromHere": "Criar ramificação aqui",
"chat.revertPopover.forkFromMessage": "Criar ramificação desta mensagem",
"chat.revertPopover.restoreAll": "Restaurar todas",
"chat.revertPopover.restore": "Restaurar",
"chat.revertPopover.revert": "Reverter",
"chat.revertPopover.fork": "Bifurcar",
"chat.revert.toast.undo": "Revertido para {preview}",
"chat.revert.toast.redo": "Refeito",
"chat.revert.toast.restored": "Todas as mensagens restauradas",
"chat.errorBoundary.title": "Erro na conversa",
"chat.errorBoundary.description": "A interface da conversa encontrou um erro. Isso pode ter sido causado por um problema temporário de rede ou por dados de mensagem corrompidos.",
"chat.errorBoundary.sessionLabel": "Sessão",
+13
View File
@@ -1368,6 +1368,19 @@ export const dict: Record<I18nKey, string> = {
"chat.statusRow.tasksTitle": "завдання",
"chat.statusRow.summary.activeLeft": "Активних: {active} · залишилось: {left}",
"chat.statusRow.aborted": "Перервано",
"chat.revertIndicator.redo": "Повторити",
"chat.revertIndicator.redoAria": "Повторити — відновити відкочені повідомлення",
"chat.revertPopover.title": "Відкочено",
"chat.revertPopover.noTextContent": "Немає текстового змісту",
"chat.revertPopover.forkFromHere": "Розгалуження тут",
"chat.revertPopover.forkFromMessage": "Розгалуження від цього повідомлення",
"chat.revertPopover.restoreAll": "Відновити всі",
"chat.revertPopover.restore": "Відновити",
"chat.revertPopover.revert": "Відкотити",
"chat.revertPopover.fork": "Розгалуження",
"chat.revert.toast.undo": "Відкочено до {preview}",
"chat.revert.toast.redo": "Повторено",
"chat.revert.toast.restored": "Всі повідомлення відновлено",
"chat.errorBoundary.title": "Помилка чату",
"chat.errorBoundary.description": "В інтерфейсі чату сталася помилка. Причиною може бути тимчасова проблема з мережею або пошкоджені дані повідомлення.",
"chat.errorBoundary.sessionLabel": "Сесія",
@@ -1368,6 +1368,19 @@ export const dict: Record<I18nKey, string> = {
'chat.statusRow.tasksTitle': '任务',
'chat.statusRow.summary.activeLeft': '{active} 个活跃 · 剩余 {left} 个',
'chat.statusRow.aborted': '已中止',
'chat.revertIndicator.redo': '重做',
'chat.revertIndicator.redoAria': '重做 — 恢复已撤回的消息',
'chat.revertPopover.title': '已撤回',
'chat.revertPopover.noTextContent': '无文本内容',
'chat.revertPopover.forkFromHere': '从此处分支',
'chat.revertPopover.forkFromMessage': '从此消息分支',
'chat.revertPopover.restoreAll': '恢复全部',
'chat.revertPopover.restore': '恢复',
'chat.revertPopover.revert': '撤回',
'chat.revertPopover.fork': '分叉',
'chat.revert.toast.undo': '已撤回至 {preview}',
'chat.revert.toast.redo': '已重做',
'chat.revert.toast.restored': '已恢复全部消息',
'chat.errorBoundary.title': '聊天错误',
'chat.errorBoundary.description': '聊天界面发生错误,可能是临时网络问题或消息数据损坏导致。',
'chat.errorBoundary.sessionLabel': '会话',
+21
View File
@@ -86,6 +86,8 @@ export type InputState = {
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => void
addVSCodeSelectionAttachment: (path: string, file: File) => Promise<void>
setActiveEditorFile: (file: VSCodeActiveEditorFile | null) => void
/** Add attachments restored from a reverted message (file already on server) */
addRestoredAttachment: (file: { url: string; mimeType: string; filename: string }) => void
}
export const useInputStore = create<InputState>()((set, get) => ({
@@ -210,4 +212,23 @@ export const useInputStore = create<InputState>()((set, get) => ({
if (isSameVSCodeActiveEditorFile(get().activeEditorFile, file)) return
set({ activeEditorFile: file })
},
addRestoredAttachment: ({ url, mimeType, filename }) => {
const id = `restored-${Date.now()}-${Math.random().toString(36).slice(2)}`
// Use "local" source so the file renders in AttachedFilesList.
// Set serverPath to the URL so ImagePreview can use it as the img src
// when dataUrl is not a data: URL. sanitizeAttachmentsForSend leaves
// dataUrl alone for non-server sources, so the URL stays intact on send.
const attached: AttachedFile = {
id,
file: new File([], filename, { type: mimeType }),
dataUrl: url,
mimeType,
filename,
size: 0,
source: "local",
serverPath: url,
}
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
},
}))
+48 -18
View File
@@ -18,6 +18,8 @@ import { stripMessageDiffSnapshots } from "./sanitize"
const MESSAGE_REFETCH_LIMIT = 200
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const UNREVERT_REFETCH_ATTEMPTS = 3
const UNREVERT_REFETCH_RETRY_MS = 150
// Reference set by SyncProvider — allows actions to access SDK and stores
let _sdk: OpencodeClient | null = null
@@ -26,6 +28,8 @@ let _getDirectory: () => string = () => ""
let _optimisticAdd: ((input: { sessionID: string; message: Message; parts: Part[] }) => void) | null = null
let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => void) | null = null
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export function setActionRefs(
sdk: OpencodeClient,
childStores: ChildStoreManager,
@@ -577,6 +581,7 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
const messages = state.message[sessionId] ?? []
const targetMsg = messages.find((m) => m.id === messageId)
let messageText = ""
let submittedFileParts: Array<Record<string, unknown>> = []
if (targetMsg && targetMsg.role === "user") {
const parts = state.part[messageId] ?? []
const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p))
@@ -584,9 +589,16 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
.map((p: Record<string, unknown>) => (p as { text?: string }).text || (p as { content?: string }).content || "")
.join("\n")
.trim()
// Snapshot file parts for later restoration to the input (line ~626).
// File parts (type="file") contain url/mime/filename — the file already
// exists on the server so we record it as a "server" source attachment.
submittedFileParts = parts.filter((p) => p.type === "file") as Array<Record<string, unknown>>
}
// Optimistically remove reverted messages + set marker
// Optimistically set only the revert marker. Keep messages and parts in the
// local store; visible-message selectors derive the displayed timeline from
// session.revert. This matches the server model and preserves reverted
// messages for the restore dock without maintaining a separate shadow copy.
const prevRevert = (() => {
const s = state.session.find((s) => s.id === sessionId)
return (s as Session & { revert?: unknown })?.revert
@@ -594,19 +606,7 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
const sessions = [...state.session]
const sessionIdx = sessions.findIndex((s) => s.id === sessionId)
// Remove messages at and after the revert point from the store
const prevMessages = state.message[sessionId] ?? []
const prevPart = { ...state.part }
const keptMessages = prevMessages.filter((m) => m.id < messageId)
const removedMessages = prevMessages.filter((m) => m.id >= messageId)
for (const m of removedMessages) {
delete prevPart[m.id]
}
const patch: Record<string, unknown> = {
message: { ...state.message, [sessionId]: keptMessages },
part: prevPart,
}
const patch: Record<string, unknown> = {}
if (sessionIdx >= 0) {
sessions[sessionIdx] = { ...sessions[sessionIdx], revert: { messageID: messageId } } as Session
@@ -615,7 +615,13 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
store.setState(patch)
// Restore reverted message text to input
// Save input store state before mutations — if the API fails we need to
// roll back both text and attachments to their previous values.
const prevInputAttachments = [...useInputStore.getState().attachedFiles]
const prevInputText = useInputStore.getState().pendingInputText
const prevInputMode = useInputStore.getState().pendingInputMode
// Restore reverted message text and file attachments to input
if (messageText) {
useInputStore.setState({
pendingInputText: messageText,
@@ -623,6 +629,20 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
})
}
// Restore file/image attachments from the target message.
// Clear existing attachments first — previous revert's attachments
// must not carry over, even when the current message has no files.
useInputStore.getState().clearAttachedFiles()
for (const fp of submittedFileParts) {
const f = fp as Record<string, unknown>
const url = typeof f.url === "string" ? f.url : ""
const mime = typeof f.mime === "string" ? f.mime : "application/octet-stream"
const filename = typeof f.filename === "string" ? f.filename : "attachment"
if (url) {
useInputStore.getState().addRestoredAttachment({ url, mimeType: mime, filename })
}
}
// Call SDK and merge authoritative result into store
try {
const result = await sdk().session.revert({ sessionID: sessionId, directory: dir(), messageID: messageId })
@@ -645,8 +665,12 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
}
store.setState({
session: rollback,
message: { ...current.message, [sessionId]: prevMessages },
part: { ...current.part, ...Object.fromEntries(removedMessages.map((m) => [m.id, state.part[m.id] ?? []])) },
})
// Rollback input store: restore previous text and attachments
useInputStore.setState({
pendingInputText: prevInputText,
pendingInputMode: prevInputMode,
attachedFiles: prevInputAttachments,
})
throw err
}
@@ -679,6 +703,7 @@ export async function refetchSessionMessages(sessionId: string): Promise<void> {
export async function unrevertSession(sessionId: string): Promise<void> {
const store = dirStore()
const state = store.getState()
const previousMessageCount = state.message[sessionId]?.length ?? 0
// Abort if busy
const status = state.session_status[sessionId]
@@ -700,7 +725,12 @@ export async function unrevertSession(sessionId: string): Promise<void> {
store.setState({ session: sessions })
}
}
await refetchSessionMessages(sessionId)
for (let attempt = 0; attempt < UNREVERT_REFETCH_ATTEMPTS; attempt += 1) {
if (attempt > 0) await wait(UNREVERT_REFETCH_RETRY_MS)
await refetchSessionMessages(sessionId)
const nextMessageCount = store.getState().message[sessionId]?.length ?? 0
if (nextMessageCount > previousMessageCount) return
}
}
/**
+37 -23
View File
@@ -249,10 +249,10 @@ export type SessionUIState = {
updateSessionTitle: (sessionId: string, title: string) => Promise<void>
shareSession: (sessionId: string) => Promise<Session | null>
unshareSession: (sessionId: string) => Promise<Session | null>
revertToMessage: (sessionId: string, messageId: string) => Promise<void>
revertToMessage: (sessionId: string, messageId: string, options?: { skipRedoPush?: boolean }) => Promise<void>
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>
handleSlashUndo: (sessionId: string) => Promise<void>
handleSlashRedo: (sessionId: string) => Promise<void>
handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise<void>
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>
// Data access helpers (read from sync)
@@ -955,12 +955,15 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// revertToMessage — delegates to session-actions (single implementation)
// ---------------------------------------------------------------------------
revertToMessage: async (sessionId, messageId) => {
// Ensure the complete message range is present before applying the revert
// marker. Reverted UI is derived from session.revert + stored messages.
await refetchSessionMessages(sessionId)
const { revertToMessage: revert } = await import("./session-actions")
await revert(sessionId, messageId)
},
// ---------------------------------------------------------------------------
// handleSlashUndo — reads from sync
// handleSlashUndo — reads from sync, records history for redo
// ---------------------------------------------------------------------------
handleSlashUndo: async (sessionId) => {
const messages = getSyncMessages(sessionId)
@@ -980,52 +983,63 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (!targetMessage) return
// Read target message parts BEFORE calling revertToMessage.
// revertToMessage optimistically deletes messages from the sync store
// before the API call, so getSyncParts must run first.
const targetParts = getSyncParts(targetMessage.id)
const textPart = targetParts.find((p: Part) => p.type === "text") as TextPart | undefined
const preview = textPart?.text
? String(textPart.text).slice(0, 50) + (textPart.text.length > 50 ? "..." : "")
: "[No text]"
// revertToMessage handles the redo stack push internally
await get().revertToMessage(sessionId, targetMessage.id)
const { toast } = await import("sonner")
toast.success(`Undid to: ${preview}`)
const { useI18nStore, formatMessage } = await import("@/lib/i18n/store")
const { dictionary } = useI18nStore.getState()
toast.success(formatMessage(dictionary, "chat.revert.toast.undo", { preview }))
},
// ---------------------------------------------------------------------------
// handleSlashRedo — reads from sync
// handleSlashRedo — moves the authoritative revert marker forward
// ---------------------------------------------------------------------------
handleSlashRedo: async (sessionId) => {
handleSlashRedo: async (sessionId, options) => {
if (options?.fullUnrevert) {
const { unrevertSession } = await import("./session-actions")
await unrevertSession(sessionId)
const { toast } = await import("sonner")
const { useI18nStore, formatMessage } = await import("@/lib/i18n/store")
const { dictionary } = useI18nStore.getState()
toast.success(formatMessage(dictionary, "chat.revert.toast.restored"))
return
}
const sessions = getSyncSessions()
const currentSession = sessions.find((s) => s.id === sessionId)
const revertToId = currentSession?.revert?.messageID
if (!revertToId) return
await refetchSessionMessages(sessionId)
const messages = getSyncMessages(sessionId)
const userMessages = messages.filter((m) => m.role === "user")
const targetMessage = userMessages.find((m) => m.id > revertToId)
if (targetMessage) {
const targetParts = getSyncParts(targetMessage.id)
const textPart = targetParts.find((p: Part) => p.type === "text") as TextPart | undefined
const preview = textPart?.text
? String(textPart.text).slice(0, 50) + (textPart.text.length > 50 ? "..." : "")
: "[No text]"
await get().revertToMessage(sessionId, targetMessage.id)
await get().revertToMessage(sessionId, targetMessage.id, { skipRedoPush: true })
const { toast } = await import("sonner")
toast.success(`Redid to: ${preview}`)
} else {
// Full unrevert
const { unrevertSession } = await import("./session-actions")
await unrevertSession(sessionId)
const { toast } = await import("sonner")
toast.success("Restored all messages")
const { useI18nStore, formatMessage } = await import("@/lib/i18n/store")
const { dictionary } = useI18nStore.getState()
toast.success(formatMessage(dictionary, "chat.revert.toast.redo"))
return
}
const { unrevertSession } = await import("./session-actions")
await unrevertSession(sessionId)
const { toast } = await import("sonner")
const { useI18nStore, formatMessage } = await import("@/lib/i18n/store")
const { dictionary } = useI18nStore.getState()
toast.success(formatMessage(dictionary, "chat.revert.toast.restored"))
},
// ---------------------------------------------------------------------------