feat: implement Undo/Redo/Timeline and Fork Features and fixed opencode.json reading issue on broken json (#99)

* feat: add /undo, /redo, /timeline slash commands and fork button

- Add /undo command to revert to previous user message
- Add /redo command to redo previously undone messages
- Add /timeline command to show conversation history
- Add fork button on user messages (hover) to create new session
- Add TimelineDialog component for navigating conversation history
- Show undo/redo/timeline in autocomplete when session exists
- Fix fork to use SDK session.fork API
- Silent no-op behavior for undo/redo when no messages (matches OpenCode CLI)

* fix: correct fork session endpoint and session switching

- Add hybrid SDK approach using v1 for existing methods and v2 for fork only
- v2 SDK has correct endpoint /session/{sessionID}/fork instead of broken /session/{id}/fork
- Fix forkFromMessage to use setCurrentSession instead of direct set
- Ensures both useSessionStore and useSessionManagementStore are consistent
- Fixes issue where forked sessions appeared empty until sending a new message

* fix: use direct fetch for fork to avoid v2 SDK build errors

- Remove v2 SDK imports and hybrid SDK approach
- Use direct fetch() call to /api/session/{sessionID}/fork endpoint
- Avoids type conflicts between v1 and v2 SDK
- Bypasses broken v1 SDK fork endpoint (wrong path parameter)
- All codebase now uses v1 SDK consistently

* fix: use jsonc-parser for config file parsing

- Replace strip-json-comments + JSON.parse with jsonc-parser
- Handles comments, trailing commas, and unquoted keys
- Matches OpenCode CLI config parsing behavior
- Fixes SyntaxError when reading global opencode.json config

* feat: improve timeline dialog with loading state and search

- Add loading spinner on fork button during operation (~30s on big sessions)
- Refresh session list after fork so new session appears in sidebar immediately
- Add search bar to filter messages by prompt text content
- Show 'No messages found' when search has no results
- Disable fork button while forking to prevent duplicate operations
This commit is contained in:
aptdnfapt
2026-01-03 01:33:35 +02:00
committed by GitHub
parent da7f0679d8
commit 025a9a6b80
16 changed files with 508 additions and 24 deletions
@@ -3,6 +3,7 @@ import { RiArrowDownLine } from '@remixicon/react';
import { ChatInput } from './ChatInput';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { Skeleton } from '@/components/ui/skeleton';
import ChatEmptyState from './ChatEmptyState';
import MessageList from './MessageList';
@@ -11,6 +12,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useDeviceInfo } from '@/lib/device';
import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { TimelineDialog } from './TimelineDialog';
export const ChatContainer: React.FC = () => {
const {
@@ -33,6 +35,11 @@ export const ChatContainer: React.FC = () => {
newSessionDraft,
} = useSessionStore();
const {
isTimelineDialogOpen,
setTimelineDialogOpen,
} = useUIStore();
const streamingMessageId = React.useMemo(() => {
if (!currentSessionId) return null;
return streamingMessageIds.get(currentSessionId) ?? null;
@@ -130,6 +137,27 @@ export const ChatContainer: React.FC = () => {
}
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef]);
// Scroll to a specific message by ID (for timeline dialog)
const scrollToMessage = React.useCallback((messageId: string) => {
const container = scrollRef.current;
if (!container) return;
// Find the message element by looking for data-message-id attribute
const messageElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement;
if (messageElement) {
// Scroll to the message with some padding (50px from top)
const containerRect = container.getBoundingClientRect();
const messageRect = messageElement.getBoundingClientRect();
const offset = 50;
const scrollTop = messageRect.top - containerRect.top + container.scrollTop - offset;
container.scrollTo({
top: scrollTop,
behavior: 'smooth'
});
}
}, [scrollRef]);
React.useEffect(() => {
if (!currentSessionId) {
return;
@@ -199,7 +227,7 @@ export const ChatContainer: React.FC = () => {
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<div className="flex-1 overflow-y-auto p-4 bg-background">
<div className="chat-column space-y-4">
<div className="chat-column space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3 p-4">
<Skeleton className="h-8 w-8 rounded-full" />
@@ -246,7 +274,6 @@ export const ChatContainer: React.FC = () => {
ref={scrollRef}
style={{
contain: 'strict',
['--scroll-shadow-size' as string]: '48px',
}}
data-scroll-shadow="true"
@@ -296,6 +323,12 @@ export const ChatContainer: React.FC = () => {
)}
<ChatInput scrollToBottom={scrollToBottom} />
</div>
<TimelineDialog
open={isTimelineDialogOpen}
onOpenChange={setTimelineDialogOpen}
onScrollToMessage={scrollToMessage}
/>
</div>
);
};
+25 -3
View File
@@ -82,7 +82,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const { currentProviderId, currentModelId, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
const agents = getVisibleAgents();
const { isMobile, inputBarOffset, isKeyboardOpen } = useUIStore();
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen } = useUIStore();
const { working } = useAssistantStatus();
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -355,7 +355,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
textareaRef.current?.blur();
}
// Handle /summarize command scroll
// Handle slash commands locally before sending
const normalizedCommand = primaryText.trimStart();
if (normalizedCommand.startsWith('/')) {
const commandName = normalizedCommand
@@ -363,7 +363,29 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
.trim()
.split(/\s+/)[0]
?.toLowerCase();
if (commandName === 'summarize') {
// NEW: /undo - revert to last message (populates input with reverted message text)
if (commandName === 'undo' && currentSessionId) {
await useSessionStore.getState().handleSlashUndo(currentSessionId);
// Don't clear message - pendingInputText will populate it with reverted message
scrollToBottom?.({ instant: true, force: true });
return; // Don't send to assistant
}
// NEW: /redo - unrevert or partial redo (populates input with message text)
else if (commandName === 'redo' && currentSessionId) {
await useSessionStore.getState().handleSlashRedo(currentSessionId);
// Don't clear message - pendingInputText will populate it
scrollToBottom?.({ instant: true, force: true });
return; // Don't send to assistant
}
// NEW: /timeline - open timeline dialog
else if (commandName === 'timeline' && currentSessionId) {
setTimelineDialogOpen(true);
setMessage('');
return; // Don't send to assistant
}
// Existing: /summarize command scroll
else if (commandName === 'summarize') {
scrollToBottom?.({ instant: true, force: true });
}
}
@@ -578,12 +578,19 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [messageTextContent]);
const revertToMessage = useSessionStore((state) => state.revertToMessage);
const forkFromMessage = useSessionStore((state) => state.forkFromMessage);
const handleRevert = React.useCallback(() => {
if (!sessionId || !message.info.id) return;
revertToMessage(sessionId, message.info.id);
}, [sessionId, message.info.id, revertToMessage]);
// NEW: Fork handler
const handleFork = React.useCallback(() => {
if (!sessionId || !message.info.id) return;
forkFromMessage(sessionId, message.info.id);
}, [sessionId, message.info.id, forkFromMessage]);
const handleToggleTool = React.useCallback((toolId: string) => {
setExpandedTools((prev) => {
const next = new Set(prev);
@@ -788,6 +795,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
errorMessage={assistantErrorText}
/>
</div>
@@ -1,5 +1,5 @@
import React from 'react';
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine } from '@remixicon/react';
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client';
import { useSessionStore } from '@/stores/useSessionStore';
@@ -29,15 +29,17 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
onCommandSelect,
onClose
}, ref) => {
const { hasMessagesInCurrentSession } = useSessionStore(
const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore(
useShallow((state) => {
const sessionId = state.currentSessionId;
const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
return {
hasMessagesInCurrentSession: messageCount > 0,
currentSessionId: sessionId,
};
})
);
const hasSession = Boolean(currentSessionId);
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
const [loading, setLoading] = React.useState(false);
@@ -79,9 +81,18 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
}));
const builtInCommands: CommandInfo[] = [
...(hasMessagesInCurrentSession
? []
: [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]),
...(hasSession && !hasMessagesInCurrentSession
? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
{ name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
]
: []
),
{ name: 'summarize', description: 'Generate a summary of the current session', isBuiltIn: true },
];
@@ -114,9 +125,18 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const allowInitCommand = !hasMessagesInCurrentSession;
const builtInCommands: CommandInfo[] = [
...(hasMessagesInCurrentSession
? []
: [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]),
...(hasSession && !hasMessagesInCurrentSession
? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
: []
),
...(hasSession // Show when session exists, not when hasMessages
? [
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
{ name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
]
: []
),
{ name: 'summarize', description: 'Generate a summary of the current session', isBuiltIn: true },
];
@@ -134,7 +154,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
};
loadCommands();
}, [searchQuery, hasMessagesInCurrentSession]);
}, [searchQuery, hasMessagesInCurrentSession, hasSession]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -184,6 +204,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
switch (command.name) {
case 'init':
return <RiFileLine className="h-3.5 w-3.5 text-green-500" />;
case 'undo':
return <RiArrowGoBackLine className="h-3.5 w-3.5 text-orange-500" />;
case 'redo':
return <RiArrowGoForwardLine className="h-3.5 w-3.5 text-orange-500" />;
case 'timeline':
return <RiTimeLine className="h-3.5 w-3.5 text-blue-500" />;
case 'summarize':
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
case 'test':
@@ -0,0 +1,202 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useSessionStore } from '@/stores/useSessionStore';
import { useMessageStore } from '@/stores/messageStore';
import { cn } from '@/lib/utils';
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine } from '@remixicon/react';
import type { Part } from '@opencode-ai/sdk';
interface TimelineDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onScrollToMessage?: (messageId: string) => void;
}
// Helper: format relative time (e.g., "2 hours ago")
function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diffMs = now - timestamp;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return new Date(timestamp).toLocaleDateString();
}
export const TimelineDialog: React.FC<TimelineDialogProps> = ({ open, onOpenChange, onScrollToMessage }) => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const messages = useMessageStore((state) =>
currentSessionId ? state.messages.get(currentSessionId) || [] : []
);
const revertToMessage = useSessionStore((state) => state.revertToMessage);
const forkFromMessage = useSessionStore((state) => state.forkFromMessage);
const loadSessions = useSessionStore((state) => state.loadSessions);
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState('');
// Filter user messages (reversed for newest first)
const userMessages = React.useMemo(() => {
const filtered = messages.filter(m => m.info.role === 'user');
return filtered.reverse();
}, [messages]);
// Filter by search query
const filteredMessages = React.useMemo(() => {
if (!searchQuery.trim()) return userMessages;
const query = searchQuery.toLowerCase();
return userMessages.filter((message) => {
const preview = getMessagePreview(message.parts).toLowerCase();
return preview.includes(query);
});
}, [userMessages, searchQuery]);
// Handle fork with loading state and session refresh
const handleFork = async (messageId: string) => {
if (!currentSessionId) return;
setForkingMessageId(messageId);
try {
await forkFromMessage(currentSessionId, messageId);
await loadSessions();
onOpenChange(false);
} finally {
setForkingMessageId(null);
}
};
if (!currentSessionId) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiTimeLine className="h-5 w-5" />
Conversation Timeline
</DialogTitle>
<DialogDescription>
Navigate to any point in the conversation or fork a new session
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 mt-2">
<RiSearchLine className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search messages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
</div>
<div className="flex-1 overflow-y-auto space-y-2">
{filteredMessages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? 'No messages found' : 'No messages in this session yet'}
</div>
) : (
filteredMessages.map((message) => {
const preview = getMessagePreview(message.parts);
const timestamp = message.info.time.created;
const relativeTime = formatRelativeTime(timestamp);
return (
<div
key={message.info.id}
className={cn(
"group flex items-start gap-3 p-3 rounded-lg border transition-all",
"hover:border-primary/50 hover:bg-muted/30"
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground">
Message {userMessages.length - userMessages.indexOf(message)}
</span>
<span className="typography-meta text-muted-foreground">
</span>
<span className="typography-meta text-muted-foreground">
{relativeTime}
</span>
</div>
<p className="typography-small text-foreground mt-1 line-clamp-2">
{preview || '[No text content]'}
</p>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="sm"
variant="outline"
onClick={() => {
onScrollToMessage?.(message.info.id);
onOpenChange(false);
}}
>
Go here
</Button>
<Button
size="sm"
variant="default"
onClick={async () => {
await revertToMessage(currentSessionId, message.info.id);
onOpenChange(false);
}}
>
Revert
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleFork(message.info.id)}
disabled={forkingMessageId === message.info.id}
>
{forkingMessageId === message.info.id ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : 'Fork'}
</Button>
</div>
</div>
);
})
)}
</div>
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<div className="flex items-start gap-2 typography-meta text-muted-foreground">
<RiGitBranchLine className="h-4 w-4 mt-0.5" />
<div>
<p className="font-medium mb-1">Actions</p>
<p> <strong>Go here</strong> - Scroll to this message in the conversation</p>
<p> <strong>Revert</strong> - Undo to this point (message text will populate input)</p>
<p> <strong>Fork</strong> - Create a new session starting from here</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
function getMessagePreview(parts: Part[]): string {
const textPart = parts.find(p => p.type === 'text');
if (!textPart || typeof textPart.text !== 'string') return '';
return textPart.text.replace(/\n/g, ' ').slice(0, 80);
}
@@ -16,7 +16,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine } from '@remixicon/react';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine } from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
@@ -133,6 +133,7 @@ interface MessageBodyProps {
agentMention?: AgentMentionInfo;
turnGroupingContext?: TurnGroupingContext;
onRevert?: () => void;
onFork?: () => void;
errorMessage?: string;
}
@@ -147,7 +148,8 @@ const UserMessageBody: React.FC<{
onShowPopup: (content: ToolPopupContent) => void;
agentMention?: AgentMentionInfo;
onRevert?: () => void;
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert }) => {
onFork?: () => void;
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork }) => {
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
@@ -239,7 +241,7 @@ const UserMessageBody: React.FC<{
})}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
{(canCopyMessage && hasCopyableText) || onRevert ? (
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
<div className={cn(
"mt-1 flex items-center justify-end gap-2 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/message:opacity-100 group-hover/message:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto",
copyHintVisible && "opacity-100 pointer-events-auto"
@@ -265,6 +267,26 @@ const UserMessageBody: React.FC<{
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
</Tooltip>
)}
{onFork && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onFork();
}}
>
<RiGitBranchLine className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
@@ -1226,6 +1248,7 @@ const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
onShowPopup={props.onShowPopup}
agentMention={props.agentMention}
onRevert={props.onRevert}
onFork={props.onFork}
/>
);
}