Files
claudecodeui/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx
Haileyesus 591b18e9e3 feat(sidebar): improve running session state tracking
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.
2026-06-11 20:04:38 +03:00

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>
);
}