Fix/websocket streaming issues (#748)

This commit is contained in:
Haile
2026-05-08 22:51:03 +03:00
committed by GitHub
parent beb0a50413
commit 039696c2de
47 changed files with 2194 additions and 369 deletions

View File

@@ -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} />
)}

View File

@@ -1,25 +1,26 @@
import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
import { Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button, Input } from '../../../../shared/view/ui';
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 SearchMode = 'projects' | 'conversations';
type SidebarHeaderProps = {
isPWA: boolean;
isMobile: boolean;
isLoading: boolean;
projectsCount: number;
archivedSessionsCount: number;
isArchivedSessionsLoading: boolean;
searchFilter: string;
onSearchFilterChange: (value: string) => void;
onClearSearchFilter: () => void;
searchMode: SearchMode;
onSearchModeChange: (mode: SearchMode) => void;
searchMode: SidebarSearchMode;
onSearchModeChange: (mode: SidebarSearchMode) => void;
onRefresh: () => void;
isRefreshing: boolean;
onCreateProject: () => void;
@@ -32,6 +33,8 @@ export default function SidebarHeader({
isMobile,
isLoading,
projectsCount,
archivedSessionsCount,
isArchivedSessionsLoading,
searchFilter,
onSearchFilterChange,
onClearSearchFilter,
@@ -43,6 +46,13 @@ export default function SidebarHeader({
onCollapseSidebar,
t,
}: SidebarHeaderProps) {
const showSearchTools = (projectsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading;
const searchPlaceholder = searchMode === 'conversations'
? t('search.conversationsPlaceholder')
: searchMode === 'archived'
? t('search.archivedPlaceholder', 'Search archived sessions...')
: t('projects.searchPlaceholder');
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">
@@ -113,7 +123,7 @@ export default function SidebarHeader({
<GitHubStarBadge />
{/* Search bar */}
{projectsCount > 0 && !isLoading && (
{showSearchTools && (
<div className="mt-2.5 space-y-2">
{/* Search mode toggle */}
<div className="flex rounded-lg bg-muted/50 p-0.5">
@@ -143,12 +153,28 @@ export default function SidebarHeader({
<MessageSquare className="h-3 w-3" />
{t('search.modeConversations')}
</button>
<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={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
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"
@@ -215,7 +241,7 @@ export default function SidebarHeader({
</div>
{/* Mobile search */}
{projectsCount > 0 && !isLoading && (
{showSearchTools && (
<div className="mt-2.5 space-y-2">
<div className="flex rounded-lg bg-muted/50 p-0.5">
<button
@@ -244,12 +270,28 @@ export default function SidebarHeader({
<MessageSquare className="h-3 w-3" />
{t('search.modeConversations')}
</button>
<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={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
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"

View File

@@ -25,7 +25,7 @@ type SidebarModalsProps = {
onConfirmDeleteProject: (deleteData?: boolean) => void;
sessionDeleteConfirmation: SessionDeleteConfirmation | null;
onCancelDeleteSession: () => void;
onConfirmDeleteSession: () => void;
onConfirmDeleteSession: (hardDelete?: boolean) => void;
showVersionModal: boolean;
onCloseVersionModal: () => void;
releaseInfo: ReleaseInfo | null;
@@ -133,7 +133,7 @@ export default function SidebarModals({
onClick={() => onConfirmDeleteProject(false)}
>
<EyeOff className="mr-2 h-4 w-4" />
{t('deleteConfirmation.removeFromSidebar')}
{t('deleteConfirmation.archiveProject', 'Archive project')}
</Button>
<Button
variant="destructive"
@@ -173,22 +173,34 @@ export default function SidebarModals({
?
</p>
<p className="mt-3 text-xs text-muted-foreground">
{t('deleteConfirmation.cannotUndo')}
{sessionDeleteConfirmation.isArchived
? t('deleteConfirmation.archivedSessionNotice', 'This session is already archived. You can keep it hidden or delete it permanently.')
: t('deleteConfirmation.archiveSessionNotice', 'Archive keeps the session out of the active list while preserving its history.')}
</p>
</div>
</div>
</div>
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
<Button variant="outline" className="flex-1" onClick={onCancelDeleteSession}>
{t('actions.cancel')}
</Button>
<div className="flex flex-col gap-2 border-t border-border bg-muted/30 p-4">
{!sessionDeleteConfirmation.isArchived && (
<Button
variant="outline"
className="w-full justify-start"
onClick={() => onConfirmDeleteSession(false)}
>
<EyeOff className="mr-2 h-4 w-4" />
{t('deleteConfirmation.archiveSession', 'Archive session')}
</Button>
)}
<Button
variant="destructive"
className="flex-1 bg-red-600 text-white hover:bg-red-700"
onClick={onConfirmDeleteSession}
className="w-full justify-start bg-red-600 text-white hover:bg-red-700"
onClick={() => onConfirmDeleteSession(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('actions.delete')}
{t('deleteConfirmation.deleteSessionPermanently', 'Delete permanently')}
</Button>
<Button variant="ghost" className="w-full" onClick={onCancelDeleteSession}>
{t('actions.cancel')}
</Button>
</div>
</div>

View File

@@ -239,7 +239,7 @@ export default function SidebarSessionItem({
event.stopPropagation();
requestDeleteSession();
}}
title={t('tooltips.deleteSession')}
title={t('tooltips.deleteSessionOptions', 'Archive or permanently delete this session')}
>
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
</button>