mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-12 09:02:08 +08:00
Add a running-session view to the sidebar, including header controls, running counts, empty states, and row-level processing indicators so active provider work is visible outside the current chat. Hydrate running state after refresh through a status-only /api/providers/sessions/running endpoint backed by chatRunRegistry.listRunningRuns, then sync and poll the frontend processingSessions map from AppContent without attaching to chat streams or replaying messages. Preserve fresh local processing entries during sync so newly sent messages are not cleared before the backend registry catches up, and clear completed sessions once the status endpoint no longer reports them. Thread active session state through sidebar project/session components, show rotating loaders for processing sessions, and keep the running search mode expanded and filterable. Fix optimistic local user-message dedupe so repeated prompts are only collapsed when a matching server echo appears from the same send window, preventing sent messages from disappearing until assistant completion. Add registry test coverage for listing currently running app sessions. Tests: npx eslint on changed files; npx tsc --noEmit -p tsconfig.json; npx tsc --noEmit -p server/tsconfig.json; npx tsx --tsconfig server/tsconfig.json --test server/modules/websocket/tests/chat-run-registry.test.ts.
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
|
import type { TFunction } from 'i18next';
|
|
|
|
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
|
import { cn } from '../../../../lib/utils';
|
|
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
|
import type { SessionWithProvider } from '../../types/types';
|
|
import { createSessionViewModel } from '../../utils/utils';
|
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
|
|
|
type SidebarSessionItemProps = {
|
|
project: Project;
|
|
session: SessionWithProvider;
|
|
selectedSession: ProjectSession | null;
|
|
isProcessing: boolean;
|
|
currentTime: Date;
|
|
editingSession: string | null;
|
|
editingSessionName: string;
|
|
onEditingSessionNameChange: (value: string) => void;
|
|
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
|
onCancelEditingSession: () => void;
|
|
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: LLMProvider) => void;
|
|
onProjectSelect: (project: Project) => void;
|
|
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
|
|
onDeleteSession: (
|
|
projectName: string,
|
|
sessionId: string,
|
|
sessionTitle: string,
|
|
provider: LLMProvider,
|
|
) => void;
|
|
t: TFunction;
|
|
};
|
|
|
|
/**
|
|
* Compact relative time for sidebar rows:
|
|
* <1m, Xm, Xhr, Xd.
|
|
*/
|
|
const formatCompactSessionAge = (dateString: string, currentTime: Date): string => {
|
|
const date = new Date(dateString);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '';
|
|
}
|
|
|
|
const diffInMinutes = Math.floor(Math.max(0, currentTime.getTime() - date.getTime()) / (1000 * 60));
|
|
if (diffInMinutes < 1) {
|
|
return '<1m';
|
|
}
|
|
|
|
if (diffInMinutes < 60) {
|
|
return `${diffInMinutes}m`;
|
|
}
|
|
|
|
const diffInHours = Math.floor(diffInMinutes / 60);
|
|
if (diffInHours < 24) {
|
|
return `${diffInHours}hr`;
|
|
}
|
|
|
|
const diffInDays = Math.floor(diffInHours / 24);
|
|
return `${diffInDays}d`;
|
|
};
|
|
|
|
export default function SidebarSessionItem({
|
|
project,
|
|
session,
|
|
selectedSession,
|
|
isProcessing,
|
|
currentTime,
|
|
editingSession,
|
|
editingSessionName,
|
|
onEditingSessionNameChange,
|
|
onStartEditingSession,
|
|
onCancelEditingSession,
|
|
onSaveEditingSession,
|
|
onProjectSelect,
|
|
onSessionSelect,
|
|
onDeleteSession,
|
|
t,
|
|
}: SidebarSessionItemProps) {
|
|
const sessionView = createSessionViewModel(session, currentTime, t);
|
|
const isSelected = selectedSession?.id === session.id;
|
|
const isEditing = editingSession === session.id;
|
|
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
|
const editingContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// The rename panel sits inside a group-hover opacity wrapper, so leaving the row
|
|
// would visually hide it. While editing, dismiss only when the user clicks outside
|
|
// the panel (matches Escape / cancel-button behaviour).
|
|
useEffect(() => {
|
|
if (!isEditing) {
|
|
return;
|
|
}
|
|
|
|
const handlePointerDown = (event: MouseEvent) => {
|
|
const container = editingContainerRef.current;
|
|
if (container && !container.contains(event.target as Node)) {
|
|
onCancelEditingSession();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handlePointerDown);
|
|
return () => document.removeEventListener('mousedown', handlePointerDown);
|
|
}, [isEditing, onCancelEditingSession]);
|
|
|
|
// Sessions are owned by a project identified by `projectId` (DB primary key)
|
|
// after the projectName → projectId migration.
|
|
const selectMobileSession = () => {
|
|
onProjectSelect(project);
|
|
onSessionSelect(session, project.projectId);
|
|
};
|
|
|
|
const saveEditedSession = () => {
|
|
onSaveEditingSession(project.projectId, session.id, editingSessionName, session.__provider);
|
|
};
|
|
|
|
const requestDeleteSession = () => {
|
|
onDeleteSession(project.projectId, session.id, sessionView.sessionName, session.__provider);
|
|
};
|
|
|
|
return (
|
|
<div className="group relative">
|
|
{!isProcessing && sessionView.isActive && (
|
|
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
|
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
|
|
<div
|
|
role="status"
|
|
aria-label={t('tooltips.activeSessionIndicator')}
|
|
className="h-2 w-2 animate-pulse rounded-full bg-green-500"
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
|
|
<div className="md:hidden">
|
|
<div
|
|
className={cn(
|
|
'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',
|
|
isSelected ? 'bg-primary/5 border-primary/20' : '',
|
|
!isSelected && isProcessing
|
|
? 'border-border/60 bg-muted/20'
|
|
: !isSelected && sessionView.isActive
|
|
? 'border-green-500/30 bg-green-50/5 dark:bg-green-900/5'
|
|
: 'border-border/30',
|
|
)}
|
|
onClick={selectMobileSession}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={cn(
|
|
'w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0',
|
|
isSelected ? 'bg-primary/10' : 'bg-muted/50',
|
|
)}
|
|
>
|
|
<SessionProviderLogo provider={session.__provider} className="h-3 w-3" />
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
|
{isProcessing ? (
|
|
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
|
<span className="ml-auto flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
</span>
|
|
</Tooltip>
|
|
) : compactSessionAge && (
|
|
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center">
|
|
{sessionView.messageCount > 0 && (
|
|
<Badge variant="secondary" className="px-1 py-0 text-xs">
|
|
{sessionView.messageCount}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!sessionView.isCursorSession && (
|
|
<button
|
|
className="ml-1 flex h-5 w-5 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
requestDeleteSession();
|
|
}}
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5 text-red-600 dark:text-red-400" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden md:block">
|
|
<Button
|
|
variant="ghost"
|
|
className={cn(
|
|
'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200',
|
|
isSelected && 'bg-accent text-accent-foreground',
|
|
!isSelected && isProcessing && 'bg-muted/20 hover:bg-accent/50',
|
|
)}
|
|
onClick={() => onSessionSelect(session, project.projectId)}
|
|
>
|
|
<div className="flex w-full min-w-0 items-start gap-2">
|
|
<SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className={cn('flex items-center gap-2', isProcessing && 'pr-7')}>
|
|
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
|
{!isProcessing && compactSessionAge && (
|
|
<span
|
|
className={cn(
|
|
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
|
|
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
|
|
)}
|
|
>
|
|
{compactSessionAge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center">
|
|
{sessionView.messageCount > 0 && <Badge variant="secondary" className="px-1 py-0 text-xs">{sessionView.messageCount}</Badge>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Button>
|
|
|
|
{isProcessing && (
|
|
<div
|
|
role="status"
|
|
aria-label={t('tooltips.processingSessionIndicator', 'Processing session')}
|
|
className={cn(
|
|
'pointer-events-none absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-opacity duration-200',
|
|
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
|
|
)}
|
|
>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={editingContainerRef}
|
|
className={cn(
|
|
'absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 transition-all duration-200',
|
|
isEditing ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
|
)}
|
|
>
|
|
{isEditing ? (
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={editingSessionName}
|
|
onChange={(event) => onEditingSessionNameChange(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
event.stopPropagation();
|
|
if (event.key === 'Enter') {
|
|
saveEditedSession();
|
|
} else if (event.key === 'Escape') {
|
|
onCancelEditingSession();
|
|
}
|
|
}}
|
|
onClick={(event) => event.stopPropagation()}
|
|
className="w-32 rounded border border-border bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
className="flex h-6 w-6 items-center justify-center rounded bg-green-50 hover:bg-green-100 dark:bg-green-900/20 dark:hover:bg-green-900/40"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
saveEditedSession();
|
|
}}
|
|
title={t('tooltips.save')}
|
|
>
|
|
<Check className="h-3 w-3 text-green-600 dark:text-green-400" />
|
|
</button>
|
|
<button
|
|
className="flex h-6 w-6 items-center justify-center rounded bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onCancelEditingSession();
|
|
}}
|
|
title={t('tooltips.cancel')}
|
|
>
|
|
<X className="h-3 w-3 text-gray-600 dark:text-gray-400" />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
className="flex h-6 w-6 items-center justify-center rounded bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onStartEditingSession(session.id, sessionView.sessionName);
|
|
}}
|
|
title={t('tooltips.editSessionName')}
|
|
>
|
|
<Edit2 className="h-3 w-3 text-gray-600 dark:text-gray-400" />
|
|
</button>
|
|
{!sessionView.isCursorSession && (
|
|
<button
|
|
className="flex h-6 w-6 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
requestDeleteSession();
|
|
}}
|
|
title={t('tooltips.deleteSessionOptions', 'Archive or permanently delete this session')}
|
|
>
|
|
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|