Group chat history by time buckets and fix sidebar i18n
This commit is contained in:
@@ -132,3 +132,12 @@ This branch captures local refactors focused on frontend UX polish, IPC call con
|
|||||||
### 17. External gateway shutdown compatibility
|
### 17. External gateway shutdown compatibility
|
||||||
- Added capability cache for externally managed Gateway shutdown RPC.
|
- Added capability cache for externally managed Gateway shutdown RPC.
|
||||||
- If `shutdown` is unsupported (`unknown method: shutdown`), mark it unsupported and skip future shutdown RPC attempts to avoid repeated warnings.
|
- If `shutdown` is unsupported (`unknown method: shutdown`), mark it unsupported and skip future shutdown RPC attempts to avoid repeated warnings.
|
||||||
|
|
||||||
|
### 18. Chat history sidebar grouping (ChatGPT-style buckets)
|
||||||
|
- Updated chat session history display in sidebar to time buckets:
|
||||||
|
- Today / Yesterday / Within 1 Week / Within 2 Weeks / Within 1 Month / Older than 1 Month
|
||||||
|
- Added `historyBuckets` locale keys in EN/ZH/JA (`chat` namespace).
|
||||||
|
- Fixed i18n namespace usage for bucket labels in sidebar:
|
||||||
|
- explicitly resolves via `chat:historyBuckets.*` to avoid raw key fallback.
|
||||||
|
- Removed forced uppercase rendering for bucket headers to preserve localized casing.
|
||||||
|
- Grouping now applies to all sessions (including `:main`) for consistent bucket visibility and behavior.
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|||||||
import { invokeIpc } from '@/lib/api-client';
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
type SessionBucketKey =
|
||||||
|
| 'today'
|
||||||
|
| 'yesterday'
|
||||||
|
| 'withinWeek'
|
||||||
|
| 'withinTwoWeeks'
|
||||||
|
| 'withinMonth'
|
||||||
|
| 'older';
|
||||||
|
|
||||||
interface NavItemProps {
|
interface NavItemProps {
|
||||||
to: string;
|
to: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@@ -67,6 +75,23 @@ function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
|
||||||
|
if (!activityMs || activityMs <= 0) return 'older';
|
||||||
|
|
||||||
|
const now = new Date(nowMs);
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
|
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (activityMs >= startOfToday) return 'today';
|
||||||
|
if (activityMs >= startOfYesterday) return 'yesterday';
|
||||||
|
|
||||||
|
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
|
||||||
|
if (daysAgo <= 7) return 'withinWeek';
|
||||||
|
if (daysAgo <= 14) return 'withinTwoWeeks';
|
||||||
|
if (daysAgo <= 30) return 'withinMonth';
|
||||||
|
return 'older';
|
||||||
|
}
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||||
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||||
@@ -83,9 +108,6 @@ export function Sidebar() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isOnChat = useLocation().pathname === '/';
|
const isOnChat = useLocation().pathname === '/';
|
||||||
|
|
||||||
const mainSessions = sessions.filter((s) => s.key.endsWith(':main'));
|
|
||||||
const otherSessions = sessions.filter((s) => !s.key.endsWith(':main'));
|
|
||||||
|
|
||||||
const getSessionLabel = (key: string, displayName?: string, label?: string) =>
|
const getSessionLabel = (key: string, displayName?: string, label?: string) =>
|
||||||
sessionLabels[key] ?? label ?? displayName ?? key;
|
sessionLabels[key] ?? label ?? displayName ?? key;
|
||||||
|
|
||||||
@@ -106,8 +128,28 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation(['common', 'chat']);
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null);
|
const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null);
|
||||||
|
const nowMs = Date.now();
|
||||||
|
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: [] },
|
||||||
|
{ key: 'withinWeek', label: t('chat:historyBuckets.withinWeek'), sessions: [] },
|
||||||
|
{ key: 'withinTwoWeeks', label: t('chat:historyBuckets.withinTwoWeeks'), sessions: [] },
|
||||||
|
{ key: 'withinMonth', label: t('chat:historyBuckets.withinMonth'), sessions: [] },
|
||||||
|
{ key: 'older', label: t('chat:historyBuckets.older'), sessions: [] },
|
||||||
|
];
|
||||||
|
const sessionBucketMap = Object.fromEntries(sessionBuckets.map((bucket) => [bucket.key, bucket])) as Record<
|
||||||
|
SessionBucketKey,
|
||||||
|
(typeof sessionBuckets)[number]
|
||||||
|
>;
|
||||||
|
|
||||||
|
for (const session of [...sessions].sort((a, b) =>
|
||||||
|
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
|
||||||
|
)) {
|
||||||
|
const bucketKey = getSessionBucket(sessionLastActivity[session.key] ?? 0, nowMs);
|
||||||
|
sessionBucketMap[bucketKey].sessions.push(session);
|
||||||
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: t('sidebar.cronTasks') },
|
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: t('sidebar.cronTasks') },
|
||||||
@@ -154,43 +196,47 @@ export function Sidebar() {
|
|||||||
{/* Session list — below Settings, only when expanded */}
|
{/* Session list — below Settings, only when expanded */}
|
||||||
{!sidebarCollapsed && sessions.length > 0 && (
|
{!sidebarCollapsed && sessions.length > 0 && (
|
||||||
<div className="mt-1 overflow-y-auto max-h-72 space-y-0.5">
|
<div className="mt-1 overflow-y-auto max-h-72 space-y-0.5">
|
||||||
{[...mainSessions, ...[...otherSessions].sort((a, b) =>
|
{sessionBuckets.map((bucket) => (
|
||||||
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
|
bucket.sessions.length > 0 ? (
|
||||||
)].map((s) => (
|
<div key={bucket.key} className="pt-1">
|
||||||
<div key={s.key} className="group relative flex items-center">
|
<div className="px-3 py-1 text-[11px] font-medium text-muted-foreground/80">
|
||||||
<button
|
{bucket.label}
|
||||||
onClick={() => { switchSession(s.key); navigate('/'); }}
|
</div>
|
||||||
className={cn(
|
{bucket.sessions.map((s) => (
|
||||||
'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors',
|
<div key={s.key} className="group relative flex items-center">
|
||||||
!s.key.endsWith(':main') && 'pr-7',
|
<button
|
||||||
'hover:bg-accent hover:text-accent-foreground',
|
onClick={() => { switchSession(s.key); navigate('/'); }}
|
||||||
isOnChat && currentSessionKey === s.key
|
className={cn(
|
||||||
? 'bg-accent/60 text-accent-foreground font-medium'
|
'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors pr-7',
|
||||||
: 'text-muted-foreground',
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
)}
|
isOnChat && currentSessionKey === s.key
|
||||||
>
|
? 'bg-accent/60 text-accent-foreground font-medium'
|
||||||
{getSessionLabel(s.key, s.displayName, s.label)}
|
: 'text-muted-foreground',
|
||||||
</button>
|
)}
|
||||||
{!s.key.endsWith(':main') && (
|
>
|
||||||
<button
|
{getSessionLabel(s.key, s.displayName, s.label)}
|
||||||
aria-label="Delete session"
|
</button>
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
aria-label="Delete session"
|
||||||
setSessionToDelete({
|
onClick={(e) => {
|
||||||
key: s.key,
|
e.stopPropagation();
|
||||||
label: getSessionLabel(s.key, s.displayName, s.label),
|
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',
|
className={cn(
|
||||||
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
|
'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',
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
)}
|
||||||
</button>
|
>
|
||||||
)}
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,5 +14,13 @@
|
|||||||
"refresh": "Refresh chat",
|
"refresh": "Refresh chat",
|
||||||
"showThinking": "Show thinking",
|
"showThinking": "Show thinking",
|
||||||
"hideThinking": "Hide thinking"
|
"hideThinking": "Hide thinking"
|
||||||
|
},
|
||||||
|
"historyBuckets": {
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday",
|
||||||
|
"withinWeek": "Within 1 Week",
|
||||||
|
"withinTwoWeeks": "Within 2 Weeks",
|
||||||
|
"withinMonth": "Within 1 Month",
|
||||||
|
"older": "Older than 1 Month"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,5 +14,13 @@
|
|||||||
"refresh": "チャットを更新",
|
"refresh": "チャットを更新",
|
||||||
"showThinking": "思考を表示",
|
"showThinking": "思考を表示",
|
||||||
"hideThinking": "思考を非表示"
|
"hideThinking": "思考を非表示"
|
||||||
|
},
|
||||||
|
"historyBuckets": {
|
||||||
|
"today": "今日",
|
||||||
|
"yesterday": "昨日",
|
||||||
|
"withinWeek": "1週間以内",
|
||||||
|
"withinTwoWeeks": "2週間以内",
|
||||||
|
"withinMonth": "1か月以内",
|
||||||
|
"older": "1か月より前"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,5 +14,13 @@
|
|||||||
"refresh": "刷新聊天",
|
"refresh": "刷新聊天",
|
||||||
"showThinking": "显示思考过程",
|
"showThinking": "显示思考过程",
|
||||||
"hideThinking": "隐藏思考过程"
|
"hideThinking": "隐藏思考过程"
|
||||||
|
},
|
||||||
|
"historyBuckets": {
|
||||||
|
"today": "今天",
|
||||||
|
"yesterday": "昨天",
|
||||||
|
"withinWeek": "一周内",
|
||||||
|
"withinTwoWeeks": "两周内",
|
||||||
|
"withinMonth": "一个月内",
|
||||||
|
"older": "一个月之前"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user