* 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
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import React from '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';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
|
|
interface CommandInfo {
|
|
name: string;
|
|
description?: string;
|
|
agent?: string;
|
|
model?: string;
|
|
isBuiltIn?: boolean;
|
|
}
|
|
|
|
export interface CommandAutocompleteHandle {
|
|
handleKeyDown: (key: string) => void;
|
|
}
|
|
|
|
interface CommandAutocompleteProps {
|
|
searchQuery: string;
|
|
onCommandSelect: (command: CommandInfo) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, CommandAutocompleteProps>(({
|
|
searchQuery,
|
|
onCommandSelect,
|
|
onClose
|
|
}, ref) => {
|
|
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<CommandInfo[]>([]);
|
|
const [loading, setLoading] = React.useState(false);
|
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
|
const target = event.target as Node | null;
|
|
if (!target || !containerRef.current) {
|
|
return;
|
|
}
|
|
if (containerRef.current.contains(target)) {
|
|
return;
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
|
return () => {
|
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
|
};
|
|
}, [onClose]);
|
|
|
|
React.useEffect(() => {
|
|
const loadCommands = async () => {
|
|
setLoading(true);
|
|
try {
|
|
|
|
const apiCommands = await opencodeClient.listCommands();
|
|
|
|
const customCommands: CommandInfo[] = apiCommands.map(cmd => ({
|
|
name: cmd.name,
|
|
description: cmd.description,
|
|
agent: cmd.agent,
|
|
model: cmd.model,
|
|
isBuiltIn: false
|
|
}));
|
|
|
|
const builtInCommands: CommandInfo[] = [
|
|
...(hasSession && !hasMessagesInCurrentSession
|
|
? [{ 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 },
|
|
];
|
|
|
|
const commandMap = new Map<string, CommandInfo>();
|
|
|
|
builtInCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
|
|
|
|
customCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
|
|
|
|
const allCommands = Array.from(commandMap.values());
|
|
|
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
|
const filtered = (searchQuery
|
|
? allCommands.filter(cmd =>
|
|
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
)
|
|
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
|
|
|
filtered.sort((a, b) => {
|
|
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
|
const bStartsWith = b.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
|
if (aStartsWith && !bStartsWith) return -1;
|
|
if (!aStartsWith && bStartsWith) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
setCommands(filtered);
|
|
} catch {
|
|
|
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
|
const builtInCommands: CommandInfo[] = [
|
|
...(hasSession && !hasMessagesInCurrentSession
|
|
? [{ 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 },
|
|
];
|
|
|
|
const filtered = (searchQuery
|
|
? builtInCommands.filter(cmd =>
|
|
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
)
|
|
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
|
|
|
setCommands(filtered);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadCommands();
|
|
}, [searchQuery, hasMessagesInCurrentSession, hasSession]);
|
|
|
|
React.useEffect(() => {
|
|
setSelectedIndex(0);
|
|
}, [commands]);
|
|
|
|
React.useEffect(() => {
|
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'nearest'
|
|
});
|
|
}, [selectedIndex]);
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
handleKeyDown: (key: string) => {
|
|
const total = commands.length;
|
|
if (key === 'Escape') {
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
if (total === 0) {
|
|
return;
|
|
}
|
|
|
|
if (key === 'ArrowDown') {
|
|
setSelectedIndex((prev) => (prev + 1) % total);
|
|
return;
|
|
}
|
|
|
|
if (key === 'ArrowUp') {
|
|
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
|
return;
|
|
}
|
|
|
|
if (key === 'Enter' || key === 'Tab') {
|
|
const safeIndex = ((selectedIndex % total) + total) % total;
|
|
const command = commands[safeIndex];
|
|
if (command) {
|
|
onCommandSelect(command);
|
|
}
|
|
}
|
|
}
|
|
}), [commands, selectedIndex, onClose, onCommandSelect]);
|
|
|
|
const getCommandIcon = (command: CommandInfo) => {
|
|
|
|
switch (command.name) {
|
|
case 'init':
|
|
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':
|
|
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
|
|
case 'test':
|
|
case 'build':
|
|
case 'run':
|
|
return <RiTerminalBoxLine className="h-3.5 w-3.5 text-cyan-500" />;
|
|
default:
|
|
if (command.isBuiltIn) {
|
|
return <RiFlashlightLine className="h-3.5 w-3.5 text-yellow-500" />;
|
|
}
|
|
return <RiCommandLine className="h-3.5 w-3.5 text-muted-foreground" />;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
|
>
|
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{commands.map((command, index) => (
|
|
<div
|
|
key={command.name}
|
|
ref={(el) => { itemRefs.current[index] = el; }}
|
|
className={cn(
|
|
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
|
|
index === selectedIndex && "bg-muted"
|
|
)}
|
|
onClick={() => onCommandSelect(command)}
|
|
onMouseEnter={() => setSelectedIndex(index)}
|
|
>
|
|
<div className="mt-0.5">
|
|
{getCommandIcon(command)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="typography-ui-label font-medium">/{command.name}</span>
|
|
{command.agent && (
|
|
<span className="typography-meta text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
{command.agent}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{command.description && (
|
|
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
|
{command.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{commands.length === 0 && (
|
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
|
No commands found
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</ScrollableOverlay>
|
|
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
|
↑↓ navigate • Enter select • Esc close
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
CommandAutocomplete.displayName = 'CommandAutocomplete';
|
|
|
|
export type { CommandInfo };
|