feat: refactored timeline dialog visuals and update keyboard shortcuts
This commit is contained in:
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Added timeline dialog (`/timeline` command or Cmd/Ctrl+T) for navigating, reverting, and forking from any point in the conversation (thanks to @aptdnfapt).
|
||||||
|
- Added `/undo` and `/redo` commands for reverting and restoring messages in a session (thanks to @aptdnfapt).
|
||||||
|
- Added fork button on user messages to create a new session from any point (thanks to @aptdnfapt).
|
||||||
|
- Desktop app: keyboard shortcuts now use Cmd on macOS and Ctrl on web/other platforms (thanks to @sakhnyuk).
|
||||||
|
- Migrated to OpenCode SDK v2 with improved API types and streaming.
|
||||||
|
|
||||||
## [1.4.1] - 2026-01-02
|
## [1.4.1] - 2026-01-02
|
||||||
|
|
||||||
- Added the ability to select the same model multiple times in multi-agent runs for response comparison.
|
- Added the ability to select the same model multiple times in multi-agent runs for response comparison.
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine } from '@remixicon/react';
|
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
interface TimelineDialogProps {
|
interface TimelineDialogProps {
|
||||||
@@ -93,85 +94,89 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({ open, onOpenChan
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="relative mt-2">
|
||||||
<RiSearchLine className="h-4 w-4 text-muted-foreground" />
|
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search messages..."
|
placeholder="Search messages..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="flex-1"
|
className="pl-9 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-2">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{filteredMessages.length === 0 ? (
|
{filteredMessages.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-8">
|
<div className="text-center text-muted-foreground py-8">
|
||||||
{searchQuery ? 'No messages found' : 'No messages in this session yet'}
|
{searchQuery ? 'No messages found' : 'No messages in this session yet'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredMessages.map((message) => {
|
filteredMessages.map((message, index) => {
|
||||||
const preview = getMessagePreview(message.parts);
|
const preview = getMessagePreview(message.parts);
|
||||||
const timestamp = message.info.time.created;
|
const timestamp = message.info.time.created;
|
||||||
const relativeTime = formatRelativeTime(timestamp);
|
const relativeTime = formatRelativeTime(timestamp);
|
||||||
|
const messageNumber = userMessages.length - userMessages.indexOf(message);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.info.id}
|
key={message.info.id}
|
||||||
className={cn(
|
className="group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer"
|
||||||
"group flex items-start gap-3 p-3 rounded-lg border transition-all",
|
onClick={() => {
|
||||||
"hover:border-primary/50 hover:bg-muted/30"
|
onScrollToMessage?.(message.info.id);
|
||||||
)}
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
{messageNumber}.
|
||||||
<span className="typography-meta text-muted-foreground">
|
</span>
|
||||||
Message {userMessages.length - userMessages.indexOf(message)}
|
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||||||
</span>
|
{preview || '[No text content]'}
|
||||||
<span className="typography-meta text-muted-foreground">
|
{preview && preview.length >= 80 && '…'}
|
||||||
•
|
</p>
|
||||||
</span>
|
|
||||||
<span className="typography-meta text-muted-foreground">
|
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||||
{relativeTime}
|
<span className="typography-meta text-muted-foreground whitespace-nowrap group-hover:hidden">
|
||||||
</span>
|
{relativeTime}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="hidden group-hover:flex gap-1">
|
||||||
|
<Tooltip delayDuration={1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-5 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await revertToMessage(currentSessionId, message.info.id);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiArrowGoBackLine className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip delayDuration={1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-5 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleFork(message.info.id);
|
||||||
|
}}
|
||||||
|
disabled={forkingMessageId === message.info.id}
|
||||||
|
>
|
||||||
|
{forkingMessageId === message.info.id ? (
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RiGitBranchLine className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
@@ -180,13 +185,18 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({ open, onOpenChan
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
||||||
<div className="flex items-start gap-2 typography-meta text-muted-foreground">
|
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
|
||||||
<RiGitBranchLine className="h-4 w-4 mt-0.5" />
|
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-medium mb-1">Actions</p>
|
<span>Click on a message to scroll to it in the conversation</span>
|
||||||
<p>• <strong>Go here</strong> - Scroll to this message in the conversation</p>
|
</div>
|
||||||
<p>• <strong>Revert</strong> - Undo to this point (message text will populate input)</p>
|
<div className="flex items-center gap-2">
|
||||||
<p>• <strong>Fork</strong> - Create a new session starting from here</p>
|
<RiArrowGoBackLine className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>Undo to this point (message text will populate input)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RiGitBranchLine className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>Create a new session starting from here</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
|||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react';
|
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
|
||||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||||
import { getModifierLabel } from '@/lib/utils';
|
import { getModifierLabel } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export const CommandPalette: React.FC = () => {
|
|||||||
setActiveMainTab,
|
setActiveMainTab,
|
||||||
setSettingsDialogOpen,
|
setSettingsDialogOpen,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
|
setTimelineDialogOpen,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
} = useUIStore();
|
} = useUIStore();
|
||||||
|
|
||||||
@@ -107,6 +108,11 @@ export const CommandPalette: React.FC = () => {
|
|||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenTimeline = () => {
|
||||||
|
setTimelineDialogOpen(true);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
const directorySessions = getSessionsByDirectory(currentDirectory ?? '');
|
const directorySessions = getSessionsByDirectory(currentDirectory ?? '');
|
||||||
const currentSessions = React.useMemo(() => {
|
const currentSessions = React.useMemo(() => {
|
||||||
return directorySessions.slice(0, 5);
|
return directorySessions.slice(0, 5);
|
||||||
@@ -142,16 +148,21 @@ export const CommandPalette: React.FC = () => {
|
|||||||
<CommandItem onSelect={handleOpenDiffPanel}>
|
<CommandItem onSelect={handleOpenDiffPanel}>
|
||||||
<RiCodeLine className="mr-2 h-4 w-4" />
|
<RiCodeLine className="mr-2 h-4 w-4" />
|
||||||
<span>Open Diff Panel</span>
|
<span>Open Diff Panel</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + E</CommandShortcut>
|
<CommandShortcut>{getModifierLabel()} + 2</CommandShortcut>
|
||||||
</CommandItem>
|
|
||||||
<CommandItem onSelect={handleOpenGitPanel}>
|
|
||||||
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
|
||||||
<span>Open Git Panel</span>
|
|
||||||
<CommandShortcut>{getModifierLabel()} + G</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleOpenTerminal}>
|
<CommandItem onSelect={handleOpenTerminal}>
|
||||||
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
|
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
|
||||||
<span>Open Terminal</span>
|
<span>Open Terminal</span>
|
||||||
|
<CommandShortcut>{getModifierLabel()} + 3</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem onSelect={handleOpenGitPanel}>
|
||||||
|
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
||||||
|
<span>Open Git Panel</span>
|
||||||
|
<CommandShortcut>{getModifierLabel()} + 4</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem onSelect={handleOpenTimeline}>
|
||||||
|
<RiTimeLine className="mr-2 h-4 w-4" />
|
||||||
|
<span>Open Timeline</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + T</CommandShortcut>
|
<CommandShortcut>{getModifierLabel()} + T</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleOpenSettings}>
|
<CommandItem onSelect={handleOpenSettings}>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
RiSettings3Line,
|
RiSettings3Line,
|
||||||
RiTerminalBoxLine,
|
RiTerminalBoxLine,
|
||||||
RiText,
|
RiText,
|
||||||
|
RiTimeLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { getModifierLabel } from "@/lib/utils";
|
import { getModifierLabel } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -140,19 +141,24 @@ export const HelpDialog: React.FC = () => {
|
|||||||
icon: RiPaletteLine,
|
icon: RiPaletteLine,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`${mod} + E`],
|
keys: [`${mod} + 2`],
|
||||||
description: "Open Diff Panel",
|
description: "Open Diff Panel",
|
||||||
icon: RiCodeLine,
|
icon: RiCodeLine,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`${mod} + G`],
|
keys: [`${mod} + 3`],
|
||||||
|
description: "Open Terminal",
|
||||||
|
icon: RiTerminalBoxLine,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: [`${mod} + 4`],
|
||||||
description: "Open Git Panel",
|
description: "Open Git Panel",
|
||||||
icon: RiGitBranchLine,
|
icon: RiGitBranchLine,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`${mod} + T`],
|
keys: [`${mod} + T`],
|
||||||
description: "Open Terminal",
|
description: "Open Timeline",
|
||||||
icon: RiTerminalBoxLine,
|
icon: RiTimeLine,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`${mod} + ,`],
|
keys: [`${mod} + ,`],
|
||||||
|
|||||||
@@ -99,24 +99,10 @@ export const useKeyboardShortcuts = () => {
|
|||||||
setThemeMode(modes[nextIndex]);
|
setThemeMode(modes[nextIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'g') {
|
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 't') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { activeMainTab } = useUIStore.getState();
|
const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState();
|
||||||
setActiveMainTab(activeMainTab === 'git' ? 'chat' : 'git');
|
setTimelineDialogOpen(!isTimelineDialogOpen);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'e') {
|
|
||||||
e.preventDefault();
|
|
||||||
const { activeMainTab } = useUIStore.getState();
|
|
||||||
setActiveMainTab(activeMainTab === 'diff' ? 'chat' : 'diff');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 't') {
|
|
||||||
e.preventDefault();
|
|
||||||
const { activeMainTab } = useUIStore.getState();
|
|
||||||
setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user