diff --git a/AGENTS.md b/AGENTS.md index 40d39ce3..a698c344 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ SDK-managed SSE with AsyncGenerator, temp->real session ID swap (optimistic UI), - Run `bun run lint` and `bun run type-check` before finalizing changes - Adding `eslint-disable` requires justification in a comment explaining why typing is impossible - Do **not** use `any` or `unknown` casts as escape hatches; build narrow adapter interfaces instead + - Refactors or new features must keep existing lint/type baselines green ### Theme Integration @@ -128,6 +129,7 @@ SDK-managed SSE with AsyncGenerator, temp->real session ID swap (optimistic UI), - **Functional components**: Exclusive use of function components with hooks - **Custom hooks**: Extract logic for reusability - **Type-first development**: Comprehensive TypeScript usage +- opencode repo is in ../opencode dir from this pwd dir check the codebase for any api undersatning . dont change opencode dir . - **Component composition**: Prefer composition over inheritance ## Feature Implementation Map diff --git a/bun.lock b/bun.lock index 7ac28cdc..563ea443 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,6 @@ "remark-gfm": "^4.0.1", "simple-git": "^3.28.0", "sonner": "^2.0.7", - "strip-json-comments": "^5.0.3", "tailwind-merge": "^3.3.1", "yaml": "^2.8.1", "zustand": "^5.0.8", @@ -120,7 +119,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", - "ghostty-web": "0.3.0", + "ghostty-web": "^0.4.0", "heic2any": "^0.0.4", "http-proxy-middleware": "^3.0.5", "motion": "^12.23.24", @@ -212,6 +211,7 @@ "express": "^5.1.0", "ghostty-web": "0.3.0", "http-proxy-middleware": "^3.0.5", + "jsonc-parser": "^3.3.1", "next-themes": "^0.4.6", "node-pty": "^1.1.0", "react": "^19.1.1", @@ -2586,6 +2586,8 @@ "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@openchamber/ui/ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/package.json b/package.json index 4e8ebcfb..ce582bd2 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,6 @@ "remark-gfm": "^4.0.1", "simple-git": "^3.28.0", "sonner": "^2.0.7", - "strip-json-comments": "^5.0.3", "tailwind-merge": "^3.3.1", "yaml": "^2.8.1", "zustand": "^5.0.8" diff --git a/packages/ui/package.json b/packages/ui/package.json index 546ae5c8..ee2d0586 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -27,11 +27,11 @@ "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@types/react-syntax-highlighter": "^15.5.13", - "ghostty-web": "0.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", + "ghostty-web": "^0.4.0", "heic2any": "^0.0.4", "http-proxy-middleware": "^3.0.5", "motion": "^12.23.24", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index ad04a264..804a4398 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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} >
-
+
{[1, 2, 3].map((i) => (
@@ -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 = () => { )}
+ +
); }; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index cc5e11a9..1a8f5645 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -82,7 +82,7 @@ export const ChatInput: React.FC = ({ 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 | null>(null); @@ -355,7 +355,7 @@ export const ChatInput: React.FC = ({ 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 = ({ 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 }); } } diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 07d9c76f..ec253f20 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -578,12 +578,19 @@ const ChatMessage: React.FC = ({ }, [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 = ({ onAuxiliaryContentComplete={handleAuxiliaryContentComplete} agentMention={agentMention} onRevert={handleRevert} + onFork={isUser ? handleFork : undefined} errorMessage={assistantErrorText} />
diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index 83e3d7e5..96a3f15d 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -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 { - 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([]); const [loading, setLoading] = React.useState(false); @@ -79,9 +81,18 @@ export const CommandAutocomplete = React.forwardRef { setSelectedIndex(0); @@ -184,6 +204,12 @@ export const CommandAutocomplete = React.forwardRef; + case 'undo': + return ; + case 'redo': + return ; + case 'timeline': + return ; case 'summarize': return ; case 'test': diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx new file mode 100644 index 00000000..5ea11915 --- /dev/null +++ b/packages/ui/src/components/chat/TimelineDialog.tsx @@ -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 = ({ 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(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 ( + + + + + + Conversation Timeline + + + Navigate to any point in the conversation or fork a new session + + + +
+ + setSearchQuery(e.target.value)} + className="flex-1" + /> +
+ +
+ {filteredMessages.length === 0 ? ( +
+ {searchQuery ? 'No messages found' : 'No messages in this session yet'} +
+ ) : ( + filteredMessages.map((message) => { + const preview = getMessagePreview(message.parts); + const timestamp = message.info.time.created; + const relativeTime = formatRelativeTime(timestamp); + + return ( +
+
+
+ + Message {userMessages.length - userMessages.indexOf(message)} + + + • + + + {relativeTime} + +
+

+ {preview || '[No text content]'} +

+
+ +
+ + + + + +
+
+ ); + }) + )} +
+ +
+
+ +
+

Actions

+

Go here - Scroll to this message in the conversation

+

Revert - Undo to this point (message text will populate input)

+

Fork - Create a new session starting from here

+
+
+
+
+
+ ); +}; + +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); +} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index bde40080..571294da 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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(null); @@ -239,7 +241,7 @@ const UserMessageBody: React.FC<{ })}
- {(canCopyMessage && hasCopyableText) || onRevert ? ( + {(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
Revert from here )} + {onFork && ( + + + + + Fork from here + + )} {canCopyMessage && hasCopyableText && ( @@ -1226,6 +1248,7 @@ const MessageBody: React.FC = ({ isUser, ...props }) => { onShowPopup={props.onShowPopup} agentMention={props.agentMention} onRevert={props.onRevert} + onFork={props.onFork} /> ); } diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 6ab5681c..b52e3cbe 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -664,6 +664,28 @@ class OpencodeService { return response.data; } + async forkSession(sessionId: string, messageId?: string): Promise { + const baseUrl = this.baseUrl.replace(/\/$/, ''); + const url = new URL(`/api/session/${sessionId}/fork`, baseUrl); + + if (this.currentDirectory) { + url.searchParams.set('directory', this.currentDirectory); + } + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(messageId ? { messageID: messageId } : {}), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to fork session: ${text || response.statusText}`); + } + + return response.json(); + } + async getSessionStatus(): Promise< Record > { diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 4059aef3..1852b402 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -192,6 +192,9 @@ export interface SessionStore { updateSession: (session: Session) => void; revertToMessage: (sessionId: string, messageId: string) => Promise; + handleSlashUndo: (sessionId: string) => Promise; + handleSlashRedo: (sessionId: string) => Promise; + forkFromMessage: (sessionId: string, messageId: string) => Promise; setPendingInputText: (text: string | null) => void; consumePendingInputText: () => string | null; } diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index 2ea3fd11..c6266727 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -553,6 +553,139 @@ export const useSessionStore = create()( } }, + 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 }); }, diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 60818261..4417fd23 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -52,6 +52,7 @@ interface UIStore { diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; diffFileLayout: Record; diffWrapLines: boolean; + isTimelineDialogOpen: boolean; setTheme: (theme: 'light' | 'dark' | 'system') => void; toggleSidebar: () => void; @@ -93,6 +94,7 @@ interface UIStore { setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void; setDiffWrapLines: (wrap: boolean) => void; setMultiRunLauncherOpen: (open: boolean) => void; + setTimelineDialogOpen: (open: boolean) => void; openMultiRunLauncher: () => void; openMultiRunLauncherWithPrompt: (prompt: string) => void; } @@ -135,6 +137,7 @@ export const useUIStore = create()( diffLayoutPreference: 'dynamic', diffFileLayout: {}, diffWrapLines: false, + isTimelineDialogOpen: false, setTheme: (theme) => { set({ theme }); @@ -458,6 +461,10 @@ export const useUIStore = create()( isSessionSwitcherOpen: false, }); }, + + setTimelineDialogOpen: (open) => { + set({ isTimelineDialogOpen: open }); + }, }), { name: 'ui-store', diff --git a/packages/web/package.json b/packages/web/package.json index eb910162..c6e6a358 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -43,6 +43,7 @@ "cmdk": "^1.1.1", "express": "^5.1.0", "http-proxy-middleware": "^3.0.5", + "jsonc-parser": "^3.3.1", "next-themes": "^0.4.6", "bun-pty": "^0.4.5", "node-pty": "^1.1.0", diff --git a/packages/web/server/lib/opencode-config.js b/packages/web/server/lib/opencode-config.js index 3d44e2e3..cb7b808f 100644 --- a/packages/web/server/lib/opencode-config.js +++ b/packages/web/server/lib/opencode-config.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import yaml from 'yaml'; -import stripJsonComments from 'strip-json-comments'; +import { parse as parseJsonc } from 'jsonc-parser'; const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent'); @@ -431,11 +431,12 @@ function readConfigFile(filePath) { } try { const content = fs.readFileSync(filePath, 'utf8'); - const normalized = stripJsonComments(content).trim(); + const normalized = content.trim(); if (!normalized) { return {}; } - return JSON.parse(normalized); + // jsonc-parser handles comments, trailing commas, unquoted keys + return parseJsonc(normalized, [], { allowTrailingComma: true }); } catch (error) { console.error(`Failed to read config file: ${filePath}`, error); throw new Error('Failed to read OpenCode configuration');