Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 148 additions & 41 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,21 @@ import {
ChevronRight,
Terminal,
ExternalLink,
Pencil,
Check,
X,
Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings';
import { useChatStore } from '@/stores/chat';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { hostApiFetch } from '@/lib/host-api';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';

type SessionBucketKey =
| 'today'
Expand Down Expand Up @@ -105,13 +110,14 @@ export function Sidebar() {
const sessionLastActivity = useChatStore((s) => s.sessionLastActivity);
const switchSession = useChatStore((s) => s.switchSession);
const newSession = useChatStore((s) => s.newSession);
const renameSession = useChatStore((s) => s.renameSession);
const deleteSession = useChatStore((s) => s.deleteSession);

const navigate = useNavigate();
const isOnChat = useLocation().pathname === '/';

const getSessionLabel = (key: string, displayName?: string, label?: string) =>
sessionLabels[key] ?? label ?? displayName ?? key;
label ?? sessionLabels[key] ?? displayName ?? key;

const openDevConsole = async () => {
try {
Expand All @@ -132,6 +138,8 @@ export function Sidebar() {

const { t } = useTranslation(['common', 'chat']);
const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null);
const [sessionToRename, setSessionToRename] = useState<{ key: string; value: string } | null>(null);
const [renamingSessionKey, setRenamingSessionKey] = useState<string | null>(null);
const [nowMs, setNowMs] = useState(INITIAL_NOW_MS);

useEffect(() => {
Expand All @@ -140,6 +148,32 @@ export function Sidebar() {
}, 60 * 1000);
return () => window.clearInterval(timer);
}, []);

const handleStartRename = (key: string, label: string) => {
setSessionToRename({ key, value: label });
};

const handleRenameSubmit = async () => {
if (!sessionToRename) return;

const nextLabel = sessionToRename.value.trim();
if (!nextLabel) {
toast.error(t('sidebar.renameSessionEmpty', { ns: 'common', defaultValue: 'Session title cannot be empty' }));
return;
}

setRenamingSessionKey(sessionToRename.key);
try {
await renameSession(sessionToRename.key, nextLabel);
toast.success(t('sidebar.renameSessionSuccess', { ns: 'common', defaultValue: 'Session title updated' }));
setSessionToRename(null);
} catch (error) {
toast.error(`${t('sidebar.renameSessionFailed', { ns: 'common', defaultValue: 'Failed to rename session' })}: ${String(error)}`);
} finally {
setRenamingSessionKey(null);
}
};

const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [
{ key: 'today', label: t('chat:historyBuckets.today'), sessions: [] },
{ key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] },
Expand Down Expand Up @@ -176,7 +210,7 @@ export function Sidebar() {
)}
>
{/* Navigation */}
<nav className="flex-1 overflow-hidden flex flex-col p-2 gap-1">
<nav className="flex flex-1 flex-col gap-1 overflow-hidden p-2">
{/* Chat nav item: acts as "New Chat" button, never highlighted as active */}
<button
onClick={() => {
Expand All @@ -186,7 +220,7 @@ export function Sidebar() {
}}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
'hover:bg-accent hover:text-accent-foreground text-muted-foreground',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
sidebarCollapsed && 'justify-center px-2',
)}
>
Expand All @@ -204,46 +238,119 @@ export function Sidebar() {

{/* Session list — below Settings, only when expanded */}
{!sidebarCollapsed && sessions.length > 0 && (
<div className="mt-1 overflow-y-auto max-h-72 space-y-0.5">
<div className="mt-1 max-h-72 space-y-0.5 overflow-y-auto">
{sessionBuckets.map((bucket) => (
bucket.sessions.length > 0 ? (
<div key={bucket.key} className="pt-1">
<div className="px-3 py-1 text-[11px] font-medium text-muted-foreground/80">
{bucket.label}
</div>
{bucket.sessions.map((s) => (
<div key={s.key} className="group relative flex items-center">
<button
onClick={() => { switchSession(s.key); navigate('/'); }}
className={cn(
'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors pr-7',
'hover:bg-accent hover:text-accent-foreground',
isOnChat && currentSessionKey === s.key
? 'bg-accent/60 text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
{getSessionLabel(s.key, s.displayName, s.label)}
</button>
<button
aria-label="Delete session"
onClick={(e) => {
e.stopPropagation();
setSessionToDelete({
key: s.key,
label: getSessionLabel(s.key, s.displayName, s.label),
});
}}
className={cn(
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
{bucket.sessions.map((session) => {
const isMainSession = session.key.endsWith(':main');
const isEditing = sessionToRename?.key === session.key;
const isSavingRename = renamingSessionKey === session.key;

return (
<div key={session.key} className="group relative flex items-center">
{isEditing ? (
<div className="flex w-full items-center gap-1 px-2 py-1">
<Input
value={sessionToRename.value}
onChange={(event) => setSessionToRename((prev) => (
prev ? { ...prev, value: event.target.value } : prev
))}
placeholder={t('sidebar.renameSessionPlaceholder', { ns: 'common', defaultValue: 'Session title' })}
className="h-8 text-sm"
disabled={isSavingRename}
autoFocus
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleRenameSubmit();
}
if (event.key === 'Escape') {
event.preventDefault();
setSessionToRename(null);
}
}}
/>
<button
aria-label={t('sidebar.saveSessionRename', { ns: 'common', defaultValue: 'Save session title' })}
onClick={() => void handleRenameSubmit()}
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors',
'text-muted-foreground hover:bg-accent hover:text-primary',
isSavingRename && 'opacity-60'
)}
disabled={isSavingRename}
>
<Check className="h-3.5 w-3.5" />
</button>
<button
aria-label={t('sidebar.cancelSessionRename', { ns: 'common', defaultValue: 'Cancel renaming' })}
onClick={() => setSessionToRename(null)}
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
disabled={isSavingRename}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<>
<button
onClick={() => { switchSession(session.key); navigate('/'); }}
className={cn(
'w-full truncate rounded-md px-3 py-1.5 text-left text-sm transition-colors',
isMainSession ? 'pr-7' : 'pr-12',
'hover:bg-accent hover:text-accent-foreground',
isOnChat && currentSessionKey === session.key
? 'bg-accent/60 font-medium text-accent-foreground'
: 'text-muted-foreground',
)}
>
{getSessionLabel(session.key, session.displayName, session.label)}
</button>
{!isMainSession && (
<button
aria-label={t('sidebar.renameSession', { ns: 'common', defaultValue: 'Rename session' })}
onClick={(event) => {
event.stopPropagation();
handleStartRename(
session.key,
getSessionLabel(session.key, session.displayName, session.label),
);
}}
className={cn(
'absolute right-6 flex items-center justify-center rounded p-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
'text-muted-foreground hover:bg-accent hover:text-primary',
)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
<button
aria-label={t('sidebar.deleteSession', { ns: 'common', defaultValue: 'Delete session' })}
onClick={(event) => {
event.stopPropagation();
setSessionToDelete({
key: session.key,
label: getSessionLabel(session.key, session.displayName, session.label),
});
}}
className={cn(
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
'text-muted-foreground hover:bg-destructive/10 hover:text-destructive',
)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
);
})}
</div>
) : null
))}
Expand All @@ -252,17 +359,17 @@ export function Sidebar() {
</nav>

{/* Footer */}
<div className="p-2 space-y-2">
<div className="space-y-2 p-2">
{devModeUnlocked && !sidebarCollapsed && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={openDevConsole}
>
<Terminal className="h-4 w-4 mr-2" />
<Terminal className="mr-2 h-4 w-4" />
{t('sidebar.devConsole')}
<ExternalLink className="h-3 w-3 ml-auto" />
<ExternalLink className="ml-auto h-3 w-3" />
</Button>
)}

Expand All @@ -283,7 +390,7 @@ export function Sidebar() {
<ConfirmDialog
open={!!sessionToDelete}
title={t('common.confirm', 'Confirm')}
message={sessionToDelete ? t('sidebar.deleteSessionConfirm', `Delete "${sessionToDelete.label}"?`) : ''}
message={sessionToDelete ? t('sidebar.deleteSessionConfirm', { label: sessionToDelete.label }) : ''}
confirmLabel={t('common.delete', 'Delete')}
cancelLabel={t('common.cancel', 'Cancel')}
variant="destructive"
Expand All @@ -297,4 +404,4 @@ export function Sidebar() {
/>
</aside>
);
}
}
13 changes: 11 additions & 2 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@
"channels": "Channels",
"dashboard": "Dashboard",
"settings": "Settings",
"devConsole": "Developer Console"
"devConsole": "Developer Console",
"renameSession": "Rename Session",
"deleteSession": "Delete Session",
"deleteSessionConfirm": "Delete \"{{label}}\"?",
"renameSessionPlaceholder": "Session title",
"saveSessionRename": "Save session title",
"cancelSessionRename": "Cancel renaming",
"renameSessionSuccess": "Session title updated",
"renameSessionFailed": "Failed to rename session",
"renameSessionEmpty": "Session title cannot be empty"
},
"actions": {
"save": "Save",
Expand Down Expand Up @@ -49,4 +58,4 @@
"notRunningDesc": "The OpenClaw Gateway needs to be running to use this feature. It will start automatically, or you can start it from Settings.",
"warning": "Gateway is not running."
}
}
}
13 changes: 11 additions & 2 deletions src/i18n/locales/ja/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@
"channels": "チャンネル",
"dashboard": "ダッシュボード",
"settings": "設定",
"devConsole": "開発者コンソール"
"devConsole": "開発者コンソール",
"renameSession": "セッション名を変更",
"deleteSession": "セッションを削除",
"deleteSessionConfirm": "「{{label}}」を削除しますか?",
"renameSessionPlaceholder": "セッションタイトル",
"saveSessionRename": "セッションタイトルを保存",
"cancelSessionRename": "名前変更をキャンセル",
"renameSessionSuccess": "セッションタイトルを更新しました",
"renameSessionFailed": "セッション名の変更に失敗しました",
"renameSessionEmpty": "セッションタイトルは空にできません"
},
"actions": {
"save": "保存",
Expand Down Expand Up @@ -49,4 +58,4 @@
"notRunningDesc": "この機能を使用するには OpenClaw ゲートウェイが実行されている必要があります。自動的に起動するか、設定から起動できます。",
"warning": "ゲートウェイが停止中です。"
}
}
}
13 changes: 11 additions & 2 deletions src/i18n/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@
"channels": "频道",
"dashboard": "仪表盘",
"settings": "设置",
"devConsole": "开发者控制台"
"devConsole": "开发者控制台",
"renameSession": "重命名会话",
"deleteSession": "删除会话",
"deleteSessionConfirm": "删除“{{label}}”?",
"renameSessionPlaceholder": "会话标题",
"saveSessionRename": "保存会话标题",
"cancelSessionRename": "取消重命名",
"renameSessionSuccess": "会话标题已更新",
"renameSessionFailed": "重命名会话失败",
"renameSessionEmpty": "会话标题不能为空"
},
"actions": {
"save": "保存",
Expand Down Expand Up @@ -49,4 +58,4 @@
"notRunningDesc": "OpenClaw 网关需要运行才能使用此功能。它将自动启动,或者您可以从设置中启动。",
"warning": "网关未运行。"
}
}
}
Loading