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:
@@ -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
|
- 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
|
- 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
|
- 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
|
- Refactors or new features must keep existing lint/type baselines green
|
||||||
|
|
||||||
### Theme Integration
|
### 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
|
- **Functional components**: Exclusive use of function components with hooks
|
||||||
- **Custom hooks**: Extract logic for reusability
|
- **Custom hooks**: Extract logic for reusability
|
||||||
- **Type-first development**: Comprehensive TypeScript usage
|
- **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
|
- **Component composition**: Prefer composition over inheritance
|
||||||
|
|
||||||
## Feature Implementation Map
|
## Feature Implementation Map
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"simple-git": "^3.28.0",
|
"simple-git": "^3.28.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"strip-json-comments": "^5.0.3",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"yaml": "^2.8.1",
|
"yaml": "^2.8.1",
|
||||||
"zustand": "^5.0.8",
|
"zustand": "^5.0.8",
|
||||||
@@ -120,7 +119,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"ghostty-web": "0.3.0",
|
"ghostty-web": "^0.4.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
@@ -212,6 +211,7 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"ghostty-web": "0.3.0",
|
"ghostty-web": "0.3.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.1.1",
|
"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=="],
|
"@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-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=="],
|
"@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=="],
|
||||||
|
|||||||
@@ -93,7 +93,6 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"simple-git": "^3.28.0",
|
"simple-git": "^3.28.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"strip-json-comments": "^5.0.3",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"yaml": "^2.8.1",
|
"yaml": "^2.8.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
|
|||||||
@@ -27,11 +27,11 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"ghostty-web": "0.3.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"ghostty-web": "^0.4.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { RiArrowDownLine } from '@remixicon/react';
|
|||||||
|
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import ChatEmptyState from './ChatEmptyState';
|
import ChatEmptyState from './ChatEmptyState';
|
||||||
import MessageList from './MessageList';
|
import MessageList from './MessageList';
|
||||||
@@ -11,6 +12,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
|||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||||
|
import { TimelineDialog } from './TimelineDialog';
|
||||||
|
|
||||||
export const ChatContainer: React.FC = () => {
|
export const ChatContainer: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -33,6 +35,11 @@ export const ChatContainer: React.FC = () => {
|
|||||||
newSessionDraft,
|
newSessionDraft,
|
||||||
} = useSessionStore();
|
} = useSessionStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isTimelineDialogOpen,
|
||||||
|
setTimelineDialogOpen,
|
||||||
|
} = useUIStore();
|
||||||
|
|
||||||
const streamingMessageId = React.useMemo(() => {
|
const streamingMessageId = React.useMemo(() => {
|
||||||
if (!currentSessionId) return null;
|
if (!currentSessionId) return null;
|
||||||
return streamingMessageIds.get(currentSessionId) ?? null;
|
return streamingMessageIds.get(currentSessionId) ?? null;
|
||||||
@@ -130,6 +137,27 @@ export const ChatContainer: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
return;
|
return;
|
||||||
@@ -199,7 +227,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-y-auto p-4 bg-background">
|
<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) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="flex gap-3 p-4">
|
<div key={i} className="flex gap-3 p-4">
|
||||||
<Skeleton className="h-8 w-8 rounded-full" />
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
@@ -246,7 +274,6 @@ export const ChatContainer: React.FC = () => {
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
style={{
|
style={{
|
||||||
contain: 'strict',
|
contain: 'strict',
|
||||||
|
|
||||||
['--scroll-shadow-size' as string]: '48px',
|
['--scroll-shadow-size' as string]: '48px',
|
||||||
}}
|
}}
|
||||||
data-scroll-shadow="true"
|
data-scroll-shadow="true"
|
||||||
@@ -296,6 +323,12 @@ export const ChatContainer: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<ChatInput scrollToBottom={scrollToBottom} />
|
<ChatInput scrollToBottom={scrollToBottom} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TimelineDialog
|
||||||
|
open={isTimelineDialogOpen}
|
||||||
|
onOpenChange={setTimelineDialogOpen}
|
||||||
|
onScrollToMessage={scrollToMessage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
const { currentProviderId, currentModelId, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
const { currentProviderId, currentModelId, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
||||||
const agents = getVisibleAgents();
|
const agents = getVisibleAgents();
|
||||||
const { isMobile, inputBarOffset, isKeyboardOpen } = useUIStore();
|
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen } = useUIStore();
|
||||||
const { working } = useAssistantStatus();
|
const { working } = useAssistantStatus();
|
||||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -355,7 +355,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
textareaRef.current?.blur();
|
textareaRef.current?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle /summarize command scroll
|
// Handle slash commands locally before sending
|
||||||
const normalizedCommand = primaryText.trimStart();
|
const normalizedCommand = primaryText.trimStart();
|
||||||
if (normalizedCommand.startsWith('/')) {
|
if (normalizedCommand.startsWith('/')) {
|
||||||
const commandName = normalizedCommand
|
const commandName = normalizedCommand
|
||||||
@@ -363,7 +363,29 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
.trim()
|
.trim()
|
||||||
.split(/\s+/)[0]
|
.split(/\s+/)[0]
|
||||||
?.toLowerCase();
|
?.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 });
|
scrollToBottom?.({ instant: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -578,12 +578,19 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
}, [messageTextContent]);
|
}, [messageTextContent]);
|
||||||
|
|
||||||
const revertToMessage = useSessionStore((state) => state.revertToMessage);
|
const revertToMessage = useSessionStore((state) => state.revertToMessage);
|
||||||
|
const forkFromMessage = useSessionStore((state) => state.forkFromMessage);
|
||||||
|
|
||||||
const handleRevert = React.useCallback(() => {
|
const handleRevert = React.useCallback(() => {
|
||||||
if (!sessionId || !message.info.id) return;
|
if (!sessionId || !message.info.id) return;
|
||||||
revertToMessage(sessionId, message.info.id);
|
revertToMessage(sessionId, message.info.id);
|
||||||
}, [sessionId, message.info.id, revertToMessage]);
|
}, [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) => {
|
const handleToggleTool = React.useCallback((toolId: string) => {
|
||||||
setExpandedTools((prev) => {
|
setExpandedTools((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -788,6 +795,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||||
agentMention={agentMention}
|
agentMention={agentMention}
|
||||||
onRevert={handleRevert}
|
onRevert={handleRevert}
|
||||||
|
onFork={isUser ? handleFork : undefined}
|
||||||
errorMessage={assistantErrorText}
|
errorMessage={assistantErrorText}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { cn } from '@/lib/utils';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
@@ -29,15 +29,17 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
onCommandSelect,
|
onCommandSelect,
|
||||||
onClose
|
onClose
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { hasMessagesInCurrentSession } = useSessionStore(
|
const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
const sessionId = state.currentSessionId;
|
const sessionId = state.currentSessionId;
|
||||||
const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
|
const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
|
||||||
return {
|
return {
|
||||||
hasMessagesInCurrentSession: messageCount > 0,
|
hasMessagesInCurrentSession: messageCount > 0,
|
||||||
|
currentSessionId: sessionId,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
const hasSession = Boolean(currentSessionId);
|
||||||
|
|
||||||
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
@@ -79,9 +81,18 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const builtInCommands: CommandInfo[] = [
|
const builtInCommands: CommandInfo[] = [
|
||||||
...(hasMessagesInCurrentSession
|
...(hasSession && !hasMessagesInCurrentSession
|
||||||
? []
|
? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
|
||||||
: [{ 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 },
|
{ 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 allowInitCommand = !hasMessagesInCurrentSession;
|
||||||
const builtInCommands: CommandInfo[] = [
|
const builtInCommands: CommandInfo[] = [
|
||||||
...(hasMessagesInCurrentSession
|
...(hasSession && !hasMessagesInCurrentSession
|
||||||
? []
|
? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]
|
||||||
: [{ 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 },
|
{ name: 'summarize', description: 'Generate a summary of the current session', isBuiltIn: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -134,7 +154,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadCommands();
|
loadCommands();
|
||||||
}, [searchQuery, hasMessagesInCurrentSession]);
|
}, [searchQuery, hasMessagesInCurrentSession, hasSession]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
@@ -184,6 +204,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
switch (command.name) {
|
switch (command.name) {
|
||||||
case 'init':
|
case 'init':
|
||||||
return <RiFileLine className="h-3.5 w-3.5 text-green-500" />;
|
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':
|
case 'summarize':
|
||||||
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
|
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
|
||||||
case 'test':
|
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 { FadeInOnReveal } from './FadeInOnReveal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
|
||||||
@@ -133,6 +133,7 @@ interface MessageBodyProps {
|
|||||||
agentMention?: AgentMentionInfo;
|
agentMention?: AgentMentionInfo;
|
||||||
turnGroupingContext?: TurnGroupingContext;
|
turnGroupingContext?: TurnGroupingContext;
|
||||||
onRevert?: () => void;
|
onRevert?: () => void;
|
||||||
|
onFork?: () => void;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +148,8 @@ const UserMessageBody: React.FC<{
|
|||||||
onShowPopup: (content: ToolPopupContent) => void;
|
onShowPopup: (content: ToolPopupContent) => void;
|
||||||
agentMention?: AgentMentionInfo;
|
agentMention?: AgentMentionInfo;
|
||||||
onRevert?: () => void;
|
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 [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -239,7 +241,7 @@ const UserMessageBody: React.FC<{
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
||||||
{(canCopyMessage && hasCopyableText) || onRevert ? (
|
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
|
||||||
<div className={cn(
|
<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",
|
"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"
|
copyHintVisible && "opacity-100 pointer-events-auto"
|
||||||
@@ -265,6 +267,26 @@ const UserMessageBody: React.FC<{
|
|||||||
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
||||||
</Tooltip>
|
</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 && (
|
{canCopyMessage && hasCopyableText && (
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={1000}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -1226,6 +1248,7 @@ const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
|
|||||||
onShowPopup={props.onShowPopup}
|
onShowPopup={props.onShowPopup}
|
||||||
agentMention={props.agentMention}
|
agentMention={props.agentMention}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -664,6 +664,28 @@ class OpencodeService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async forkSession(sessionId: string, messageId?: string): Promise<Session> {
|
||||||
|
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<
|
async getSessionStatus(): Promise<
|
||||||
Record<string, { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }>
|
Record<string, { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }>
|
||||||
> {
|
> {
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ export interface SessionStore {
|
|||||||
updateSession: (session: Session) => void;
|
updateSession: (session: Session) => void;
|
||||||
|
|
||||||
revertToMessage: (sessionId: string, messageId: string) => Promise<void>;
|
revertToMessage: (sessionId: string, messageId: string) => Promise<void>;
|
||||||
|
handleSlashUndo: (sessionId: string) => Promise<void>;
|
||||||
|
handleSlashRedo: (sessionId: string) => Promise<void>;
|
||||||
|
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>;
|
||||||
setPendingInputText: (text: string | null) => void;
|
setPendingInputText: (text: string | null) => void;
|
||||||
consumePendingInputText: () => string | null;
|
consumePendingInputText: () => string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
setPendingInputText: (text: string | null) => {
|
||||||
set({ pendingInputText: text });
|
set({ pendingInputText: text });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ interface UIStore {
|
|||||||
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
|
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
|
||||||
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
|
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
|
||||||
diffWrapLines: boolean;
|
diffWrapLines: boolean;
|
||||||
|
isTimelineDialogOpen: boolean;
|
||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
@@ -93,6 +94,7 @@ interface UIStore {
|
|||||||
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
|
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
|
||||||
setDiffWrapLines: (wrap: boolean) => void;
|
setDiffWrapLines: (wrap: boolean) => void;
|
||||||
setMultiRunLauncherOpen: (open: boolean) => void;
|
setMultiRunLauncherOpen: (open: boolean) => void;
|
||||||
|
setTimelineDialogOpen: (open: boolean) => void;
|
||||||
openMultiRunLauncher: () => void;
|
openMultiRunLauncher: () => void;
|
||||||
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
||||||
}
|
}
|
||||||
@@ -135,6 +137,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
diffLayoutPreference: 'dynamic',
|
diffLayoutPreference: 'dynamic',
|
||||||
diffFileLayout: {},
|
diffFileLayout: {},
|
||||||
diffWrapLines: false,
|
diffWrapLines: false,
|
||||||
|
isTimelineDialogOpen: false,
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
@@ -458,6 +461,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
isSessionSwitcherOpen: false,
|
isSessionSwitcherOpen: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setTimelineDialogOpen: (open) => {
|
||||||
|
set({ isTimelineDialogOpen: open });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ui-store',
|
name: 'ui-store',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"bun-pty": "^0.4.5",
|
"bun-pty": "^0.4.5",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import yaml from 'yaml';
|
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 OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
||||||
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
|
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
|
||||||
@@ -431,11 +431,12 @@ function readConfigFile(filePath) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
const normalized = stripJsonComments(content).trim();
|
const normalized = content.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return JSON.parse(normalized);
|
// jsonc-parser handles comments, trailing commas, unquoted keys
|
||||||
|
return parseJsonc(normalized, [], { allowTrailingComma: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to read config file: ${filePath}`, error);
|
console.error(`Failed to read config file: ${filePath}`, error);
|
||||||
throw new Error('Failed to read OpenCode configuration');
|
throw new Error('Failed to read OpenCode configuration');
|
||||||
|
|||||||
Reference in New Issue
Block a user