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
+133
View File
@@ -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 });
},