mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-26 05:15:48 +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.
419 lines
17 KiB
TypeScript
419 lines
17 KiB
TypeScript
import { Check, ChevronDown, ChevronRight, Edit3, Star, Trash2, X } from 'lucide-react';
|
|
import type { TFunction } from 'i18next';
|
|
|
|
import { Button } from '../../../../shared/view/ui';
|
|
import { cn } from '../../../../lib/utils';
|
|
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
|
import type { SessionActivityMap } from '../../../../hooks/useSessionProtection';
|
|
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
|
import { getTaskIndicatorStatus } from '../../utils/utils';
|
|
|
|
import TaskIndicator from './TaskIndicator';
|
|
import SidebarProjectSessions from './SidebarProjectSessions';
|
|
|
|
type SidebarProjectItemProps = {
|
|
project: Project;
|
|
selectedProject: Project | null;
|
|
selectedSession: ProjectSession | null;
|
|
isExpanded: boolean;
|
|
isDeleting: boolean;
|
|
isStarred: boolean;
|
|
editingProject: string | null;
|
|
editingName: string;
|
|
sessions: SessionWithProvider[];
|
|
initialSessionsLoaded: boolean;
|
|
isLoadingMoreSessions: boolean;
|
|
currentTime: Date;
|
|
editingSession: string | null;
|
|
editingSessionName: string;
|
|
tasksEnabled: boolean;
|
|
mcpServerStatus: MCPServerStatus;
|
|
onEditingNameChange: (name: string) => void;
|
|
onToggleProject: (projectName: string) => void;
|
|
onProjectSelect: (project: Project) => void;
|
|
onToggleStarProject: (projectName: string) => void;
|
|
onStartEditingProject: (project: Project) => void;
|
|
onCancelEditingProject: () => void;
|
|
onSaveProjectName: (projectName: string) => void;
|
|
onDeleteProject: (project: Project) => void;
|
|
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
|
|
onDeleteSession: (
|
|
projectName: string,
|
|
sessionId: string,
|
|
sessionTitle: string,
|
|
provider: LLMProvider,
|
|
) => void;
|
|
onLoadMoreSessions: (projectId: string) => void;
|
|
activeSessions: SessionActivityMap;
|
|
onNewSession: (project: Project) => void;
|
|
onEditingSessionNameChange: (value: string) => void;
|
|
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
|
onCancelEditingSession: () => void;
|
|
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: LLMProvider) => void;
|
|
t: TFunction;
|
|
};
|
|
|
|
const getSessionCountDisplay = (project: Project, sessions: SessionWithProvider[]): string => {
|
|
const total = Number(project.sessionMeta?.total ?? sessions.length);
|
|
return String(total);
|
|
};
|
|
|
|
export default function SidebarProjectItem({
|
|
project,
|
|
selectedProject,
|
|
selectedSession,
|
|
isExpanded,
|
|
isDeleting,
|
|
isStarred,
|
|
editingProject,
|
|
editingName,
|
|
sessions,
|
|
initialSessionsLoaded,
|
|
isLoadingMoreSessions,
|
|
currentTime,
|
|
editingSession,
|
|
editingSessionName,
|
|
tasksEnabled,
|
|
mcpServerStatus,
|
|
onEditingNameChange,
|
|
onToggleProject,
|
|
onProjectSelect,
|
|
onToggleStarProject,
|
|
onStartEditingProject,
|
|
onCancelEditingProject,
|
|
onSaveProjectName,
|
|
onDeleteProject,
|
|
onSessionSelect,
|
|
onDeleteSession,
|
|
onLoadMoreSessions,
|
|
activeSessions,
|
|
onNewSession,
|
|
onEditingSessionNameChange,
|
|
onStartEditingSession,
|
|
onCancelEditingSession,
|
|
onSaveEditingSession,
|
|
t,
|
|
}: SidebarProjectItemProps) {
|
|
// Project identity is tracked by the DB-assigned `projectId` everywhere
|
|
// after the projectName → projectId migration.
|
|
const isSelected = selectedProject?.projectId === project.projectId;
|
|
const isEditing = editingProject === project.projectId;
|
|
const totalSessionCount = Number(project.sessionMeta?.total ?? sessions.length);
|
|
const sessionCountDisplay = getSessionCountDisplay(project, sessions);
|
|
const sessionCountLabel = `${sessionCountDisplay} session${totalSessionCount === 1 ? '' : 's'}`;
|
|
const taskStatus = getTaskIndicatorStatus(project, mcpServerStatus);
|
|
|
|
const toggleProject = () => onToggleProject(project.projectId);
|
|
const toggleStarProject = () => onToggleStarProject(project.projectId);
|
|
|
|
const saveProjectName = () => {
|
|
onSaveProjectName(project.projectId);
|
|
};
|
|
|
|
const selectAndToggleProject = () => {
|
|
if (selectedProject?.projectId !== project.projectId) {
|
|
onProjectSelect(project);
|
|
}
|
|
|
|
toggleProject();
|
|
};
|
|
|
|
return (
|
|
<div className={cn('md:space-y-1', isDeleting && 'opacity-50 pointer-events-none')}>
|
|
<div className="md:group group">
|
|
<div className="md:hidden">
|
|
<div
|
|
className={cn(
|
|
'p-3 mx-3 my-1 rounded-lg bg-card border border-border/50 active:scale-[0.98] transition-all duration-150',
|
|
isSelected && 'bg-primary/5 border-primary/20',
|
|
isStarred &&
|
|
!isSelected &&
|
|
'bg-yellow-50/50 dark:bg-yellow-900/5 border-yellow-200/30 dark:border-yellow-800/30',
|
|
)}
|
|
onClick={toggleProject}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
<button
|
|
className={cn(
|
|
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border',
|
|
isStarred
|
|
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
|
|
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
|
|
)}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
toggleStarProject();
|
|
}}
|
|
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
|
|
>
|
|
<Star
|
|
className={cn(
|
|
'w-4 h-4 transition-colors',
|
|
isStarred
|
|
? 'text-yellow-600 dark:text-yellow-400 fill-current'
|
|
: 'text-gray-600 dark:text-gray-400',
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editingName}
|
|
onChange={(event) => onEditingNameChange(event.target.value)}
|
|
className="w-full rounded-lg border-2 border-primary/40 bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-all duration-200 focus:border-primary focus:shadow-md focus:outline-none"
|
|
placeholder={t('projects.projectNamePlaceholder')}
|
|
autoFocus
|
|
autoComplete="off"
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
saveProjectName();
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
onCancelEditingProject();
|
|
}
|
|
}}
|
|
style={{
|
|
fontSize: '16px',
|
|
WebkitAppearance: 'none',
|
|
borderRadius: '8px',
|
|
}}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="flex min-w-0 flex-1 items-center justify-between">
|
|
<h3 className="truncate text-sm font-medium text-foreground">{project.displayName}</h3>
|
|
{tasksEnabled && (
|
|
<TaskIndicator
|
|
status={taskStatus}
|
|
size="xs"
|
|
className="ml-2 hidden flex-shrink-0 md:inline-flex"
|
|
/>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{sessionCountLabel}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
{isEditing ? (
|
|
<>
|
|
<button
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-green-500 shadow-sm transition-all duration-150 active:scale-90 active:shadow-none dark:bg-green-600"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
saveProjectName();
|
|
}}
|
|
>
|
|
<Check className="h-4 w-4 text-white" />
|
|
</button>
|
|
<button
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-500 shadow-sm transition-all duration-150 active:scale-90 active:shadow-none dark:bg-gray-600"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onCancelEditingProject();
|
|
}}
|
|
>
|
|
<X className="h-4 w-4 text-white" />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onDeleteProject(project);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
|
|
</button>
|
|
|
|
<button
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-primary/20 bg-primary/10 active:scale-90 dark:border-primary/30 dark:bg-primary/20"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onStartEditingProject(project);
|
|
}}
|
|
>
|
|
<Edit3 className="h-4 w-4 text-primary" />
|
|
</button>
|
|
|
|
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-muted/30">
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
className={cn(
|
|
'hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50',
|
|
isSelected && 'bg-accent text-accent-foreground',
|
|
isStarred &&
|
|
!isSelected &&
|
|
'bg-yellow-50/50 dark:bg-yellow-900/10 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/20',
|
|
)}
|
|
onClick={selectAndToggleProject}
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
<div
|
|
className={cn(
|
|
'w-6 h-6 flex items-center justify-center rounded cursor-pointer transition-all duration-200',
|
|
isStarred
|
|
? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20'
|
|
: 'opacity-40 hover:opacity-100 hover:bg-accent',
|
|
)}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
toggleStarProject();
|
|
}}
|
|
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
|
|
>
|
|
<Star
|
|
className={cn(
|
|
'w-3 h-3 transition-colors',
|
|
isStarred
|
|
? 'text-yellow-600 dark:text-yellow-400 fill-current'
|
|
: 'text-muted-foreground',
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1 text-left">
|
|
{isEditing ? (
|
|
<div className="space-y-1">
|
|
<input
|
|
type="text"
|
|
value={editingName}
|
|
onChange={(event) => onEditingNameChange(event.target.value)}
|
|
className="w-full rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:ring-2 focus:ring-primary/20"
|
|
placeholder={t('projects.projectNamePlaceholder')}
|
|
autoFocus
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
saveProjectName();
|
|
}
|
|
if (event.key === 'Escape') {
|
|
onCancelEditingProject();
|
|
}
|
|
}}
|
|
/>
|
|
<div className="truncate text-xs text-muted-foreground" title={project.fullPath}>
|
|
{project.fullPath}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="truncate text-sm font-semibold text-foreground" title={project.displayName}>
|
|
{project.displayName}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{sessionCountDisplay}
|
|
{project.fullPath !== project.displayName && (
|
|
<span className="ml-1 opacity-60" title={project.fullPath}>
|
|
{' - '}
|
|
{project.fullPath.length > 25 ? `...${project.fullPath.slice(-22)}` : project.fullPath}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-shrink-0 items-center gap-1">
|
|
{isEditing ? (
|
|
<>
|
|
<div
|
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-green-600 transition-colors hover:bg-green-50 hover:text-green-700 dark:hover:bg-green-900/20"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
saveProjectName();
|
|
}}
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
</div>
|
|
<div
|
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-800"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onCancelEditingProject();
|
|
}}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div
|
|
className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onStartEditingProject(project);
|
|
}}
|
|
title={t('tooltips.renameProject')}
|
|
>
|
|
<Edit3 className="h-3 w-3" />
|
|
</div>
|
|
<div
|
|
className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-red-50 group-hover:opacity-100 dark:hover:bg-red-900/20"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onDeleteProject(project);
|
|
}}
|
|
title={t('tooltips.deleteProject')}
|
|
>
|
|
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</Button>
|
|
</div>
|
|
|
|
<SidebarProjectSessions
|
|
project={project}
|
|
isExpanded={isExpanded}
|
|
sessions={sessions}
|
|
selectedSession={selectedSession}
|
|
initialSessionsLoaded={initialSessionsLoaded}
|
|
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
|
isLoadingMoreSessions={isLoadingMoreSessions}
|
|
activeSessions={activeSessions}
|
|
currentTime={currentTime}
|
|
editingSession={editingSession}
|
|
editingSessionName={editingSessionName}
|
|
onEditingSessionNameChange={onEditingSessionNameChange}
|
|
onStartEditingSession={onStartEditingSession}
|
|
onCancelEditingSession={onCancelEditingSession}
|
|
onSaveEditingSession={onSaveEditingSession}
|
|
onProjectSelect={onProjectSelect}
|
|
onSessionSelect={onSessionSelect}
|
|
onDeleteSession={onDeleteSession}
|
|
onLoadMoreSessions={onLoadMoreSessions}
|
|
onNewSession={onNewSession}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|