feat: session rename — double-click or pencil icon to set custom session names
- Double-click session name in sidebar to rename inline - Pencil icon on hover for discoverability - Custom names persisted in localStorage - Enter to confirm, Escape to cancel, blur to save - Clear name to revert to auto-generated display name - Search filter respects custom names - i18n for all 8 languages
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pinchchat",
|
||||
"version": "1.61.0",
|
||||
"version": "1.62.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pinchchat",
|
||||
"version": "1.61.0",
|
||||
"version": "1.62.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pinchchat",
|
||||
"version": "1.61.0",
|
||||
"version": "1.62.0",
|
||||
"description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { X, Search, Pin, Trash2, Columns2, Clock, Bot, MessageSquare, Globe, Zap, ArrowUpCircle, Download } from 'lucide-react';
|
||||
import { X, Search, Pin, Trash2, Columns2, Clock, Bot, MessageSquare, Globe, Zap, ArrowUpCircle, Download, Pencil } from 'lucide-react';
|
||||
import type { Session } from '../types';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
import { SessionIcon } from './SessionIcon';
|
||||
@@ -64,6 +64,21 @@ const PINNED_KEY = 'pinchchat-pinned-sessions';
|
||||
const WIDTH_KEY = 'pinchchat-sidebar-width';
|
||||
const ORDER_KEY = 'pinchchat-session-order';
|
||||
const FILTER_KEY = 'pinchchat-session-filter';
|
||||
const NAMES_KEY = 'pinchchat-session-names';
|
||||
|
||||
function getCustomNames(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(NAMES_KEY);
|
||||
if (raw) return JSON.parse(raw) as Record<string, string>;
|
||||
} catch { /* noop */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveCustomNames(names: Record<string, string>) {
|
||||
try {
|
||||
localStorage.setItem(NAMES_KEY, JSON.stringify(names));
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
/** Detect the category of a session for filtering */
|
||||
function sessionCategory(s: Session): string {
|
||||
@@ -167,6 +182,10 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
|
||||
});
|
||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [customNames, setCustomNames] = useState<Record<string, string>>(getCustomNames);
|
||||
const [renamingKey, setRenamingKey] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef({ startX: 0, startW: 0 });
|
||||
@@ -219,6 +238,44 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startRename = useCallback((key: string, currentName: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setRenamingKey(key);
|
||||
setRenameValue(currentName);
|
||||
// Focus the input after render
|
||||
requestAnimationFrame(() => renameInputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
if (!renamingKey) return;
|
||||
const trimmed = renameValue.trim();
|
||||
setCustomNames(prev => {
|
||||
const next = { ...prev };
|
||||
if (trimmed) {
|
||||
next[renamingKey] = trimmed;
|
||||
} else {
|
||||
delete next[renamingKey];
|
||||
}
|
||||
saveCustomNames(next);
|
||||
return next;
|
||||
});
|
||||
setRenamingKey(null);
|
||||
setRenameValue('');
|
||||
}, [renamingKey, renameValue]);
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingKey(null);
|
||||
setRenameValue('');
|
||||
}, []);
|
||||
|
||||
// Focus rename input when it appears
|
||||
useEffect(() => {
|
||||
if (renamingKey) {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
}
|
||||
}, [renamingKey]);
|
||||
|
||||
// Keyboard shortcut: Ctrl+K or Cmd+K to focus search when sidebar is open
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -259,7 +316,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
|
||||
}
|
||||
if (filter.trim()) {
|
||||
const q = filter.toLowerCase();
|
||||
list = list.filter(s => sessionDisplayName(s).toLowerCase().includes(q));
|
||||
list = list.filter(s => (customNames[s.key] || sessionDisplayName(s)).toLowerCase().includes(q));
|
||||
}
|
||||
// Sort pinned sessions to top (preserving relative order within each group)
|
||||
const pinnedList = list.filter(s => pinned.has(s.key));
|
||||
@@ -277,7 +334,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
|
||||
pinnedList.sort(byCustomThenRecent);
|
||||
unpinnedList.sort(byCustomThenRecent);
|
||||
return [...pinnedList, ...unpinnedList];
|
||||
}, [sessions, filter, pinned, customOrder, channelFilter]);
|
||||
}, [sessions, filter, pinned, customOrder, channelFilter, customNames]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -473,11 +530,42 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="flex-1 truncate">{sessionDisplayName(s)}</span>
|
||||
{renamingKey === s.key ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancelRename(); }
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 min-w-0 bg-[var(--pc-hover)] text-pc-text-primary text-[13px] rounded px-1 py-0 border border-pc-border outline-none focus:ring-1 focus:ring-[var(--pc-accent-dim)]"
|
||||
maxLength={60}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex-1 truncate"
|
||||
onDoubleClick={(e) => startRename(s.key, customNames[s.key] || sessionDisplayName(s), e)}
|
||||
title={t('sidebar.rename')}
|
||||
>
|
||||
{customNames[s.key] || sessionDisplayName(s)}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const rel = relativeTime(s.updatedAt);
|
||||
return rel ? <span className="text-[10px] text-pc-text-muted tabular-nums shrink-0">{rel}</span> : null;
|
||||
})()}
|
||||
<button
|
||||
onClick={(e) => startRename(s.key, customNames[s.key] || sessionDisplayName(s), e)}
|
||||
className="shrink-0 p-0.5 rounded-lg transition-all text-pc-text-faint opacity-0 group-hover/item:opacity-60 hover:!opacity-100 hover:text-pc-text-secondary"
|
||||
title={t('sidebar.rename')}
|
||||
aria-label={t('sidebar.rename')}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => togglePin(s.key, e)}
|
||||
className={`shrink-0 p-0.5 rounded-lg transition-all ${
|
||||
|
||||
@@ -63,6 +63,7 @@ const en = {
|
||||
'sidebar.unpin': 'Unpin session',
|
||||
'sidebar.pinned': 'Pinned',
|
||||
'sidebar.delete': 'Delete session',
|
||||
'sidebar.rename': 'Rename session',
|
||||
'sidebar.deleteConfirm': 'Delete this session? This cannot be undone.',
|
||||
'sidebar.deleteCancel': 'Cancel',
|
||||
'sidebar.openSplit': 'Open in split view',
|
||||
@@ -235,6 +236,7 @@ const fr: Record<keyof typeof en, string> = {
|
||||
'sidebar.unpin': 'Désépingler la session',
|
||||
'sidebar.pinned': 'Épinglées',
|
||||
'sidebar.delete': 'Supprimer la session',
|
||||
'sidebar.rename': 'Renommer la session',
|
||||
'sidebar.deleteConfirm': 'Supprimer cette session ? Cette action est irréversible.',
|
||||
'sidebar.deleteCancel': 'Annuler',
|
||||
'sidebar.openSplit': 'Ouvrir en vue scindée',
|
||||
@@ -395,6 +397,7 @@ const es: Record<keyof typeof en, string> = {
|
||||
'sidebar.unpin': 'Desfijar sesión',
|
||||
'sidebar.pinned': 'Fijadas',
|
||||
'sidebar.delete': 'Eliminar sesión',
|
||||
'sidebar.rename': 'Renombrar sesión',
|
||||
'sidebar.deleteConfirm': '¿Eliminar esta sesión? Esta acción no se puede deshacer.',
|
||||
'sidebar.deleteCancel': 'Cancelar',
|
||||
'sidebar.openSplit': 'Abrir en vista dividida',
|
||||
@@ -557,6 +560,7 @@ const de: Record<keyof typeof en, string> = {
|
||||
'sidebar.unpin': 'Sitzung lösen',
|
||||
'sidebar.pinned': 'Angeheftet',
|
||||
'sidebar.delete': 'Sitzung löschen',
|
||||
'sidebar.rename': 'Sitzung umbenennen',
|
||||
'sidebar.deleteConfirm': 'Diese Sitzung löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
'sidebar.deleteCancel': 'Abbrechen',
|
||||
'sidebar.openSplit': 'In geteilter Ansicht öffnen',
|
||||
@@ -717,6 +721,7 @@ const ja: Record<keyof typeof en, string> = {
|
||||
'sidebar.unpin': 'ピン留めを解除',
|
||||
'sidebar.pinned': 'ピン留め',
|
||||
'sidebar.delete': 'セッションを削除',
|
||||
'sidebar.rename': 'セッション名変更',
|
||||
'sidebar.deleteConfirm': 'このセッションを削除しますか?元に戻せません。',
|
||||
'sidebar.deleteCancel': 'キャンセル',
|
||||
'sidebar.openSplit': '分割ビューで開く',
|
||||
@@ -877,6 +882,7 @@ const pt: Record<keyof typeof en, string> = {
|
||||
'sidebar.unpin': 'Desafixar sessão',
|
||||
'sidebar.pinned': 'Fixada',
|
||||
'sidebar.delete': 'Excluir sessão',
|
||||
'sidebar.rename': 'Renomear sessão',
|
||||
'sidebar.deleteConfirm': 'Excluir esta sessão? Esta ação não pode ser desfeita.',
|
||||
'sidebar.deleteCancel': 'Cancelar',
|
||||
'sidebar.openSplit': 'Abrir em visualização dividida',
|
||||
@@ -1037,6 +1043,7 @@ const zh: Record<keyof typeof en, string> = {
|
||||
'sidebar.unpin': '取消置顶',
|
||||
'sidebar.pinned': '已置顶',
|
||||
'sidebar.delete': '删除会话',
|
||||
'sidebar.rename': '重命名会话',
|
||||
'sidebar.deleteConfirm': '删除此会话?此操作无法撤消。',
|
||||
'sidebar.deleteCancel': '取消',
|
||||
'sidebar.openSplit': '在分屏中打开',
|
||||
@@ -1197,6 +1204,7 @@ const it: Record<keyof typeof en, string> = {
|
||||
'sidebar.unpin': 'Sgancia sessione',
|
||||
'sidebar.pinned': 'Fissate',
|
||||
'sidebar.delete': 'Elimina sessione',
|
||||
'sidebar.rename': 'Rinomina sessione',
|
||||
'sidebar.deleteConfirm': 'Eliminare questa sessione? L\'azione è irreversibile.',
|
||||
'sidebar.deleteCancel': 'Annulla',
|
||||
'sidebar.openSplit': 'Apri in vista divisa',
|
||||
|
||||
Reference in New Issue
Block a user