Files
claudecodeui/src/components/sidebar/view/subcomponents/SidebarHeader.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

372 lines
16 KiB
TypeScript

import { Activity, Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button, Input, Tooltip } from '../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../constants/config';
import { cn } from '../../../../lib/utils';
import type { SidebarSearchMode } from '../../types/types';
import GitHubStarBadge from './GitHubStarBadge';
const MOD_KEY =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl';
type SidebarHeaderProps = {
isPWA: boolean;
isMobile: boolean;
isLoading: boolean;
projectsCount: number;
runningSessionsCount: number;
archivedSessionsCount: number;
isArchivedSessionsLoading: boolean;
searchFilter: string;
onSearchFilterChange: (value: string) => void;
onClearSearchFilter: () => void;
searchMode: SidebarSearchMode;
onSearchModeChange: (mode: SidebarSearchMode) => void;
onRefresh: () => void;
isRefreshing: boolean;
onCreateProject: () => void;
onCollapseSidebar: () => void;
t: TFunction;
};
export default function SidebarHeader({
isPWA,
isMobile,
isLoading,
projectsCount,
runningSessionsCount,
archivedSessionsCount,
isArchivedSessionsLoading,
searchFilter,
onSearchFilterChange,
onClearSearchFilter,
searchMode,
onSearchModeChange,
onRefresh,
isRefreshing,
onCreateProject,
onCollapseSidebar,
t,
}: SidebarHeaderProps) {
const showSearchTools = (projectsCount > 0 || runningSessionsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading;
const searchPlaceholder = searchMode === 'conversations'
? t('search.conversationsPlaceholder')
: searchMode === 'archived'
? t('search.archivedPlaceholder', 'Search archived sessions...')
: searchMode === 'running'
? t('search.runningPlaceholder', 'Search running sessions...')
: t('projects.searchPlaceholder');
const runningBadgeText = runningSessionsCount > 99 ? '99+' : String(runningSessionsCount);
const LogoBlock = () => (
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-primary/90 shadow-sm">
<svg className="h-3.5 w-3.5 text-primary-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</div>
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">{t('app.title')}</h1>
</div>
);
return (
<div className="flex-shrink-0">
{/* Desktop header */}
<div
className="hidden px-3 pb-2 pt-3 md:block"
style={{}}
>
<div className="flex items-center justify-between gap-2">
{IS_PLATFORM ? (
<a
href="https://cloudcli.ai/dashboard"
className="flex min-w-0 items-center gap-2.5 transition-opacity hover:opacity-80"
title={t('tooltips.viewEnvironments')}
>
<LogoBlock />
</a>
) : (
<LogoBlock />
)}
<div className="flex flex-shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
onClick={onRefresh}
disabled={isRefreshing}
title={t('tooltips.refresh')}
>
<RefreshCw
className={`h-3.5 w-3.5 ${
isRefreshing ? 'animate-spin' : ''
}`}
/>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
onClick={onCreateProject}
title={t('tooltips.createProject')}
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
onClick={onCollapseSidebar}
title={t('tooltips.hideSidebar')}
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<GitHubStarBadge />
{/* Search bar */}
{showSearchTools && (
<div className="mt-2.5 space-y-2">
{/* Search mode toggle */}
<div className="flex rounded-lg bg-muted/50 p-0.5">
<button
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Folder className="h-3 w-3" />
{t('search.modeProjects')}
</button>
<button
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="h-3 w-3" />
{t('search.modeConversations')}
</button>
<Tooltip content={t('search.runningTooltip', 'Running sessions')} position="top">
<button
onClick={() => onSearchModeChange('running')}
aria-pressed={searchMode === 'running'}
aria-label={t('search.runningTooltip', 'Running sessions')}
title={t('search.runningTooltip', 'Running sessions')}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'running'
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
: "text-muted-foreground hover:text-foreground"
)}
>
<span className="relative flex h-3 w-3 items-center justify-center">
<Activity className={cn("h-3 w-3", runningSessionsCount > 0 && "text-emerald-500")} />
{runningSessionsCount > 0 && (
<span className="absolute -right-2.5 -top-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-emerald-500 px-0.5 text-[8px] font-semibold leading-none text-white shadow-sm ring-1 ring-background">
{runningBadgeText}
</span>
)}
</span>
</button>
</Tooltip>
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
<button
onClick={() => onSearchModeChange('archived')}
aria-pressed={searchMode === 'archived'}
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
title={t('search.archiveOnlyTooltip', 'Archive only')}
className={cn(
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
searchMode === 'archived'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Archive className="h-3 w-3" />
</button>
</Tooltip>
</div>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={searchPlaceholder}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-14 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{searchFilter ? (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
) : (
<kbd
aria-hidden
title={t('tooltips.openCommandPalette')}
className="pointer-events-none absolute right-2.5 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 rounded border border-border/60 bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground md:inline-flex"
>
{MOD_KEY}
<span>K</span>
</kbd>
)}
</div>
</div>
)}
</div>
{/* Desktop divider */}
<div className="nav-divider hidden md:block" />
{/* Mobile header */}
<div
className="p-3 pb-2 md:hidden"
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
>
<div className="flex items-center justify-between">
{IS_PLATFORM ? (
<a
href="https://cloudcli.ai/dashboard"
className="flex min-w-0 items-center gap-2.5 transition-opacity active:opacity-70"
title={t('tooltips.viewEnvironments')}
>
<LogoBlock />
</a>
) : (
<LogoBlock />
)}
<div className="flex flex-shrink-0 gap-1.5">
<button
className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/50 transition-all active:scale-95"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 text-muted-foreground ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/90 text-primary-foreground transition-all active:scale-95"
onClick={onCreateProject}
>
<FolderPlus className="h-4 w-4" />
</button>
</div>
</div>
{/* Mobile search */}
{showSearchTools && (
<div className="mt-2.5 space-y-2">
<div className="flex rounded-lg bg-muted/50 p-0.5">
<button
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Folder className="h-3 w-3" />
{t('search.modeProjects')}
</button>
<button
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="h-3 w-3" />
{t('search.modeConversations')}
</button>
<Tooltip content={t('search.runningTooltip', 'Running sessions')} position="top">
<button
onClick={() => onSearchModeChange('running')}
aria-pressed={searchMode === 'running'}
aria-label={t('search.runningTooltip', 'Running sessions')}
title={t('search.runningTooltip', 'Running sessions')}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'running'
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
: "text-muted-foreground hover:text-foreground"
)}
>
<span className="relative flex h-3 w-3 items-center justify-center">
<Activity className={cn("h-3 w-3", runningSessionsCount > 0 && "text-emerald-500")} />
{runningSessionsCount > 0 && (
<span className="absolute -right-2.5 -top-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-emerald-500 px-0.5 text-[8px] font-semibold leading-none text-white shadow-sm ring-1 ring-background">
{runningBadgeText}
</span>
)}
</span>
<span className="sr-only">{t('search.modeRunning', 'Running')}</span>
</button>
</Tooltip>
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
<button
onClick={() => onSearchModeChange('archived')}
aria-pressed={searchMode === 'archived'}
aria-label={t('search.archiveOnlyTooltip', 'Archive only')}
title={t('search.archiveOnlyTooltip', 'Archive only')}
className={cn(
"flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
searchMode === 'archived'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Archive className="h-3 w-3" />
</button>
</Tooltip>
</div>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={searchPlaceholder}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{searchFilter && (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>
)}
</div>
{/* Mobile divider */}
<div className="nav-divider md:hidden" />
</div>
);
}