Files
openchamber/packages/ui/src/components/chat/CommandAutocomplete.tsx
T
aptdnfapt 025a9a6b80 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
2026-01-03 01:33:35 +02:00

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 };