mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-26 21:55:50 +08:00
Fix/websocket streaming issues (#748)
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Folder, MessageSquare, Search } from 'lucide-react';
|
||||
import { Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ScrollArea } from '../../../../shared/view/ui';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
||||
import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import SidebarFooter from './SidebarFooter';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||
|
||||
type SearchMode = 'projects' | 'conversations';
|
||||
import { getAllSessions } from '../../utils/utils';
|
||||
|
||||
function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {
|
||||
const parts: ReactNode[] = [];
|
||||
@@ -35,19 +36,100 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
||||
);
|
||||
}
|
||||
|
||||
type ArchivedSessionGroup = {
|
||||
key: string;
|
||||
projectId: string | null;
|
||||
projectDisplayName: string;
|
||||
projectPath: string | null;
|
||||
isProjectArchived: boolean;
|
||||
sessions: ArchivedSessionListItem[];
|
||||
latestActivity: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups archived sessions by project metadata so the archive view preserves
|
||||
* the same mental model as the active sidebar: projects first, then sessions.
|
||||
*/
|
||||
function groupArchivedSessionsByProject(sessions: ArchivedSessionListItem[]): ArchivedSessionGroup[] {
|
||||
const groups = new Map<string, ArchivedSessionGroup>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = session.projectId ?? session.projectPath ?? `session:${session.sessionId}`;
|
||||
const existingGroup = groups.get(key);
|
||||
|
||||
if (existingGroup) {
|
||||
existingGroup.sessions.push(session);
|
||||
if (!existingGroup.latestActivity || (session.lastActivity && session.lastActivity > existingGroup.latestActivity)) {
|
||||
existingGroup.latestActivity = session.lastActivity;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(key, {
|
||||
key,
|
||||
projectId: session.projectId,
|
||||
projectDisplayName: session.projectDisplayName,
|
||||
projectPath: session.projectPath,
|
||||
isProjectArchived: session.isProjectArchived,
|
||||
sessions: [session],
|
||||
latestActivity: session.lastActivity,
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups.values()].sort((groupA, groupB) => {
|
||||
const a = groupA.latestActivity ?? '';
|
||||
const b = groupB.latestActivity ?? '';
|
||||
return b.localeCompare(a);
|
||||
});
|
||||
}
|
||||
|
||||
function formatCompactArchivedAge(dateString: string | null): string {
|
||||
if (!dateString) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(Math.max(0, Date.now() - 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`;
|
||||
}
|
||||
|
||||
return `${Math.floor(diffInHours / 24)}d`;
|
||||
}
|
||||
|
||||
type SidebarContentProps = {
|
||||
isPWA: boolean;
|
||||
isMobile: boolean;
|
||||
isLoading: boolean;
|
||||
projects: Project[];
|
||||
archivedProjects: ArchivedProjectListItem[];
|
||||
archivedSessions: ArchivedSessionListItem[];
|
||||
archivedSessionsCount: number;
|
||||
isArchivedSessionsLoading: boolean;
|
||||
searchFilter: string;
|
||||
onSearchFilterChange: (value: string) => void;
|
||||
onClearSearchFilter: () => void;
|
||||
searchMode: SearchMode;
|
||||
onSearchModeChange: (mode: SearchMode) => void;
|
||||
searchMode: SidebarSearchMode;
|
||||
onSearchModeChange: (mode: SidebarSearchMode) => void;
|
||||
conversationResults: ConversationSearchResults | null;
|
||||
isSearching: boolean;
|
||||
searchProgress: SearchProgress | null;
|
||||
onRestoreArchivedProject: (projectId: string) => void;
|
||||
onArchivedSessionClick: (session: ArchivedSessionListItem) => void;
|
||||
onRestoreArchivedSession: (sessionId: string) => void;
|
||||
onDeleteArchivedSession: (session: ArchivedSessionListItem) => void;
|
||||
// Conversation result clicks pass back the DB projectId (or null when the
|
||||
// server couldn't resolve it). Consumers must handle the null case.
|
||||
onConversationResultClick: (projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;
|
||||
@@ -70,6 +152,10 @@ export default function SidebarContent({
|
||||
isMobile,
|
||||
isLoading,
|
||||
projects,
|
||||
archivedProjects,
|
||||
archivedSessions,
|
||||
archivedSessionsCount,
|
||||
isArchivedSessionsLoading,
|
||||
searchFilter,
|
||||
onSearchFilterChange,
|
||||
onClearSearchFilter,
|
||||
@@ -78,6 +164,10 @@ export default function SidebarContent({
|
||||
conversationResults,
|
||||
isSearching,
|
||||
searchProgress,
|
||||
onRestoreArchivedProject,
|
||||
onArchivedSessionClick,
|
||||
onRestoreArchivedSession,
|
||||
onDeleteArchivedSession,
|
||||
onConversationResultClick,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
@@ -94,6 +184,7 @@ export default function SidebarContent({
|
||||
}: SidebarContentProps) {
|
||||
const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2;
|
||||
const hasPartialResults = conversationResults && conversationResults.results.length > 0;
|
||||
const groupedArchivedSessions = groupArchivedSessionsByProject(archivedSessions);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -105,6 +196,8 @@ export default function SidebarContent({
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
projectsCount={projects.length}
|
||||
archivedSessionsCount={archivedSessionsCount}
|
||||
isArchivedSessionsLoading={isArchivedSessionsLoading}
|
||||
searchFilter={searchFilter}
|
||||
onSearchFilterChange={onSearchFilterChange}
|
||||
onClearSearchFilter={onClearSearchFilter}
|
||||
@@ -214,6 +307,207 @@ export default function SidebarContent({
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
) : searchMode === 'archived' ? (
|
||||
isArchivedSessionsLoading ? (
|
||||
<div className="px-4 py-12 text-center md:py-8">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">
|
||||
{t('archived.loadingTitle', 'Loading archive...')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('archived.loadingDescription', 'Fetching hidden workspaces and sessions you can restore later.')}
|
||||
</p>
|
||||
</div>
|
||||
) : archivedProjects.length === 0 && groupedArchivedSessions.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center md:py-8">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
|
||||
<Archive className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">
|
||||
{archivedSessionsCount > 0
|
||||
? t('archived.noMatchingSessions', 'No matching archived items')
|
||||
: t('archived.emptyTitle', 'No archived items')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{archivedSessionsCount > 0
|
||||
? t('archived.tryDifferentSearch', 'Try a different search term.')
|
||||
: t('archived.emptyDescription', 'Archived workspaces and sessions will appear here when you hide them from the active list.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 px-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{`${archivedSessionsCount} ${t(
|
||||
archivedSessionsCount === 1 ? 'archived.sessionCountOne' : 'archived.sessionCountOther',
|
||||
archivedSessionsCount === 1 ? 'archived item' : 'archived items',
|
||||
)}`}
|
||||
</p>
|
||||
</div>
|
||||
{archivedProjects.map((project) => {
|
||||
const projectSessions = getAllSessions(project);
|
||||
|
||||
return (
|
||||
<div key={project.projectId} className="overflow-hidden rounded-xl border border-border/70 bg-card/60 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border/60 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{project.displayName}
|
||||
</span>
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-center text-muted-foreground">
|
||||
{t('archived.projectArchived', 'Project archived')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground/70" title={project.fullPath}>
|
||||
{project.fullPath}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 transition-colors hover:bg-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/30"
|
||||
onClick={() => onRestoreArchivedProject(project.projectId)}
|
||||
title={t('archived.restoreProject', 'Restore workspace')}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{projectSessions.length > 0 && (
|
||||
<div className="divide-y divide-border/50">
|
||||
{projectSessions.map((session) => (
|
||||
<button
|
||||
key={String(session.id)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-accent/40"
|
||||
onClick={() => onArchivedSessionClick({
|
||||
sessionId: String(session.id),
|
||||
provider: session.__provider,
|
||||
projectId: project.projectId,
|
||||
projectPath: project.fullPath,
|
||||
projectDisplayName: project.displayName,
|
||||
sessionTitle:
|
||||
(typeof session.summary === 'string' && session.summary.trim().length > 0
|
||||
? session.summary
|
||||
: typeof session.name === 'string' && session.name.trim().length > 0
|
||||
? session.name
|
||||
: String(session.id)),
|
||||
createdAt: typeof session.created_at === 'string' ? session.created_at : null,
|
||||
updatedAt: typeof session.updated_at === 'string' ? session.updated_at : null,
|
||||
lastActivity:
|
||||
typeof session.lastActivity === 'string'
|
||||
? session.lastActivity
|
||||
: typeof session.updated_at === 'string'
|
||||
? session.updated_at
|
||||
: typeof session.created_at === 'string'
|
||||
? session.created_at
|
||||
: null,
|
||||
isProjectArchived: true,
|
||||
})}
|
||||
>
|
||||
<SessionProviderLogo provider={session.__provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{(typeof session.summary === 'string' && session.summary.trim().length > 0
|
||||
? session.summary
|
||||
: typeof session.name === 'string' && session.name.trim().length > 0
|
||||
? session.name
|
||||
: String(session.id))}
|
||||
</span>
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">
|
||||
{formatCompactArchivedAge(
|
||||
typeof session.lastActivity === 'string'
|
||||
? session.lastActivity
|
||||
: typeof session.updated_at === 'string'
|
||||
? session.updated_at
|
||||
: typeof session.created_at === 'string'
|
||||
? session.created_at
|
||||
: null,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] uppercase tracking-wide text-muted-foreground/70">
|
||||
{session.__provider}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{groupedArchivedSessions.map((group) => (
|
||||
<div key={group.key} className="overflow-hidden rounded-xl border border-border/70 bg-card/60 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border/60 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{group.projectDisplayName}
|
||||
</span>
|
||||
{group.isProjectArchived && (
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-center text-muted-foreground">
|
||||
{t('archived.projectArchived', 'Project archived')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{group.projectPath && (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground/70" title={group.projectPath}>
|
||||
{group.projectPath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-[11px] text-muted-foreground">
|
||||
{group.sessions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{group.sessions.map((session) => (
|
||||
<div key={session.sessionId} className="flex items-center gap-2 px-3 py-2.5">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left transition-colors hover:text-foreground"
|
||||
onClick={() => onArchivedSessionClick(session)}
|
||||
>
|
||||
<SessionProviderLogo provider={session.provider} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{session.sessionTitle}
|
||||
</span>
|
||||
{session.lastActivity && (
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">
|
||||
{formatCompactArchivedAge(session.lastActivity)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] uppercase tracking-wide text-muted-foreground/70">
|
||||
{session.provider}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 transition-colors hover:bg-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/30"
|
||||
onClick={() => onRestoreArchivedSession(session.sessionId)}
|
||||
title={t('archived.restore', 'Restore session')}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-red-50 text-red-700 transition-colors hover:bg-red-100 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||
onClick={() => onDeleteArchivedSession(session)}
|
||||
title={t('archived.deletePermanently', 'Delete permanently')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<SidebarProjectList {...projectListProps} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user