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:
@@ -553,6 +553,139 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
handleSlashUndo: async (sessionId: string) => {
|
||||
const messages = get().messages.get(sessionId) || [];
|
||||
const userMessages = messages.filter(m => m.info.role === 'user');
|
||||
const sessions = get().sessions;
|
||||
const currentSession = sessions.find(s => s.id === sessionId);
|
||||
|
||||
// Silent no-op like OpenCode CLI
|
||||
if (userMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current revert state to determine which message to undo next
|
||||
const revertToId = currentSession?.revert?.messageID;
|
||||
|
||||
// Find the user message AFTER the revert point (or last message if no revert)
|
||||
let targetMessage;
|
||||
if (revertToId) {
|
||||
const revertIndex = userMessages.findIndex(m => m.info.id === revertToId);
|
||||
targetMessage = userMessages[revertIndex + 1];
|
||||
} else {
|
||||
targetMessage = userMessages[userMessages.length - 1];
|
||||
}
|
||||
|
||||
// Silent no-op like OpenCode CLI
|
||||
if (!targetMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper to extract text preview
|
||||
const textPart = targetMessage.parts.find(p => p.type === 'text');
|
||||
const preview = typeof textPart === 'object' && textPart && 'text' in textPart
|
||||
? String(textPart.text).slice(0, 50) + (String(textPart.text).length > 50 ? '...' : '')
|
||||
: '[No text]';
|
||||
|
||||
await get().revertToMessage(sessionId, targetMessage.info.id);
|
||||
|
||||
const { toast } = await import('sonner');
|
||||
toast.success(`Undid to: ${preview}`);
|
||||
},
|
||||
|
||||
handleSlashRedo: async (sessionId: string) => {
|
||||
const sessions = get().sessions;
|
||||
const currentSession = sessions.find(s => s.id === sessionId);
|
||||
const revertToId = currentSession?.revert?.messageID;
|
||||
|
||||
// Silent no-op like OpenCode CLI
|
||||
if (!revertToId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = get().messages.get(sessionId) || [];
|
||||
const userMessages = messages.filter(m => m.info.role === 'user');
|
||||
|
||||
// Find the user message BEFORE the revert point
|
||||
const revertIndex = userMessages.findIndex(m => m.info.id === revertToId);
|
||||
const targetMessage = userMessages[revertIndex - 1];
|
||||
|
||||
if (targetMessage) {
|
||||
// Partial redo: move to previous message
|
||||
const textPart = targetMessage.parts.find(p => p.type === 'text');
|
||||
const preview = typeof textPart === 'object' && textPart && 'text' in textPart
|
||||
? String(textPart.text).slice(0, 50) + (String(textPart.text).length > 50 ? '...' : '')
|
||||
: '[No text]';
|
||||
|
||||
await get().revertToMessage(sessionId, targetMessage.info.id);
|
||||
|
||||
const { toast } = await import('sonner');
|
||||
toast.success(`Redid to: ${preview}`);
|
||||
} else {
|
||||
// Full unrevert: restore all
|
||||
const session = await opencodeClient.unrevertSession(sessionId);
|
||||
await useSessionManagementStore.getState().updateSession(session);
|
||||
await get().loadMessages(sessionId);
|
||||
|
||||
const { toast } = await import('sonner');
|
||||
toast.success('Restored all messages');
|
||||
}
|
||||
},
|
||||
|
||||
forkFromMessage: async (sessionId: string, messageId: string) => {
|
||||
const sessions = get().sessions;
|
||||
const existingSession = sessions.find(s => s.id === sessionId);
|
||||
if (!existingSession) return;
|
||||
|
||||
try {
|
||||
// 1. Call SDK fork - backend copies all messages up to messageId
|
||||
const result = await opencodeClient.forkSession(sessionId, messageId);
|
||||
|
||||
if (!result || !result.id) {
|
||||
const { toast } = await import('sonner');
|
||||
toast.error('Failed to fork session');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Extract fork point content for input field (text + file attachments)
|
||||
const messages = get().messages.get(sessionId) || [];
|
||||
const message = messages.find(m => m.info.id === messageId);
|
||||
|
||||
if (!message) {
|
||||
const { toast } = await import('sonner');
|
||||
toast.error('Message not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract text content from non-synthetic, non-ignored text parts
|
||||
let inputText = '';
|
||||
for (const part of message.parts) {
|
||||
if (part.type === 'text' && !part.synthetic && !part.ignored) {
|
||||
const typedPart = part as { text?: string };
|
||||
inputText += typedPart.text || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Switch to new session
|
||||
get().setCurrentSession(result.id);
|
||||
|
||||
// 4. Show fork point as pending input (will populate ChatInput)
|
||||
if (inputText) {
|
||||
set({ pendingInputText: inputText });
|
||||
}
|
||||
|
||||
// Load the new session's messages
|
||||
await get().loadMessages(result.id);
|
||||
|
||||
const { toast } = await import('sonner');
|
||||
toast.success(`Forked from ${existingSession.title}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to fork session:', error);
|
||||
const { toast } = await import('sonner');
|
||||
toast.error('Failed to fork session');
|
||||
}
|
||||
},
|
||||
|
||||
setPendingInputText: (text: string | null) => {
|
||||
set({ pendingInputText: text });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user