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 (
+
+ );
+};
+
+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');