refactor IPC (#341)
This commit is contained in:
committed by
GitHub
Unverified
parent
c03d92e9a2
commit
3d804a9f5e
25
src/components/common/FeedbackState.tsx
Normal file
25
src/components/common/FeedbackState.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AlertCircle, Inbox, Loader2 } from 'lucide-react';
|
||||
|
||||
interface FeedbackStateProps {
|
||||
state: 'loading' | 'empty' | 'error';
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FeedbackState({ state, title, description, action }: FeedbackStateProps) {
|
||||
const icon = state === 'loading'
|
||||
? <Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
: state === 'error'
|
||||
? <AlertCircle className="h-8 w-8 text-destructive" />
|
||||
: <Inbox className="h-8 w-8 text-muted-foreground" />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="mb-3">{icon}</div>
|
||||
<p className="font-medium">{title}</p>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
{action && <div className="mt-3">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Navigation sidebar with menu items.
|
||||
* No longer fixed - sits inside the flex layout below the title bar.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
@@ -24,8 +24,17 @@ import { useChatStore } from '@/stores/chat';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SessionBucketKey =
|
||||
| 'today'
|
||||
| 'yesterday'
|
||||
| 'withinWeek'
|
||||
| 'withinTwoWeeks'
|
||||
| 'withinMonth'
|
||||
| 'older';
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -66,6 +75,25 @@ 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';
|
||||
}
|
||||
|
||||
const INITIAL_NOW_MS = Date.now();
|
||||
|
||||
export function Sidebar() {
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||
@@ -82,15 +110,12 @@ export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
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) =>
|
||||
sessionLabels[key] ?? label ?? displayName ?? key;
|
||||
|
||||
const openDevConsole = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
||||
const result = await invokeIpc('gateway:getControlUiUrl') as {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
@@ -105,8 +130,35 @@ export function Sidebar() {
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { t } = useTranslation(['common', 'chat']);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null);
|
||||
const [nowMs, setNowMs] = useState(INITIAL_NOW_MS);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 60 * 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
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 = [
|
||||
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: t('sidebar.cronTasks') },
|
||||
@@ -153,43 +205,47 @@ 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">
|
||||
{[...mainSessions, ...[...otherSessions].sort((a, b) =>
|
||||
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
|
||||
)].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',
|
||||
!s.key.endsWith(':main') && '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>
|
||||
{!s.key.endsWith(':main') && (
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{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',
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Minus, Square, X, Copy } from 'lucide-react';
|
||||
import logoSvg from '@/assets/logo.svg';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
const isMac = window.electron?.platform === 'darwin';
|
||||
|
||||
@@ -23,25 +24,25 @@ function WindowsTitleBar() {
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial state
|
||||
window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => {
|
||||
invokeIpc('window:isMaximized').then((val) => {
|
||||
setMaximized(val as boolean);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.electron.ipcRenderer.invoke('window:minimize');
|
||||
invokeIpc('window:minimize');
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
window.electron.ipcRenderer.invoke('window:maximize').then(() => {
|
||||
window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => {
|
||||
invokeIpc('window:maximize').then(() => {
|
||||
invokeIpc('window:isMaximized').then((val) => {
|
||||
setMaximized(val as boolean);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
window.electron.ipcRenderer.invoke('window:close');
|
||||
invokeIpc('window:close');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
shouldInvertInDark,
|
||||
} from '@/lib/providers';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
@@ -704,7 +705,7 @@ function AddProviderDialog({
|
||||
setOauthError(null);
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType);
|
||||
await invokeIpc('provider:requestOAuth', selectedType);
|
||||
} catch (e) {
|
||||
setOauthError(String(e));
|
||||
setOauthFlowing(false);
|
||||
@@ -715,7 +716,7 @@ function AddProviderDialog({
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setOauthError(null);
|
||||
await window.electron.ipcRenderer.invoke('provider:cancelOAuth');
|
||||
await invokeIpc('provider:cancelOAuth');
|
||||
};
|
||||
|
||||
// Only custom can be added multiple times.
|
||||
@@ -1013,7 +1014,7 @@ function AddProviderDialog({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
|
||||
onClick={() => invokeIpc('shell:openExternal', oauthData.verificationUri)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{t('aiProviders.oauth.openLoginPage')}
|
||||
|
||||
Reference in New Issue
Block a user