mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-13 01:22:06 +08:00
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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -10,6 +10,33 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
|
||||
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||
import { api } from '../../utils/api';
|
||||
|
||||
type RunningSessionApiItem = {
|
||||
sessionId?: unknown;
|
||||
startedAt?: unknown;
|
||||
statusText?: unknown;
|
||||
canInterrupt?: unknown;
|
||||
};
|
||||
|
||||
type RunningSessionsApiPayload = {
|
||||
data?: {
|
||||
sessions?: RunningSessionApiItem[];
|
||||
};
|
||||
};
|
||||
|
||||
const parseStartedAt = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
export default function AppContent() {
|
||||
return (
|
||||
@@ -30,6 +57,7 @@ function AppContentInner() {
|
||||
processingSessions,
|
||||
markSessionProcessing,
|
||||
markSessionIdle,
|
||||
syncProcessingSessions,
|
||||
} = useSessionProtection();
|
||||
|
||||
const {
|
||||
@@ -57,6 +85,54 @@ function AppContentInner() {
|
||||
activeSessions: processingSessions,
|
||||
});
|
||||
|
||||
const refreshRunningSessions = useCallback(async () => {
|
||||
console.log("ASdsad")
|
||||
try {
|
||||
const response = await api.runningSessions();
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as RunningSessionsApiPayload;
|
||||
const sessions = Array.isArray(payload.data?.sessions) ? payload.data.sessions : [];
|
||||
|
||||
syncProcessingSessions(
|
||||
sessions
|
||||
.map((session) => {
|
||||
if (typeof session.sessionId !== 'string' || !session.sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
startedAt: parseStartedAt(session.startedAt),
|
||||
statusText: typeof session.statusText === 'string' ? session.statusText : undefined,
|
||||
canInterrupt: typeof session.canInterrupt === 'boolean' ? session.canInterrupt : undefined,
|
||||
};
|
||||
})
|
||||
.filter((session): session is NonNullable<typeof session> => Boolean(session)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[AppContent] Failed to sync running sessions:', error);
|
||||
}
|
||||
}, [syncProcessingSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshRunningSessions();
|
||||
}, [refreshRunningSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (processingSessions.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshRunningSessions();
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [processingSessions.size, refreshRunningSessions]);
|
||||
|
||||
usePaletteOpsRegister({
|
||||
openSettings,
|
||||
refreshProjects: refreshProjectsSilently,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
||||
import { api } from '../../../utils/api';
|
||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||
import type {
|
||||
ArchivedProjectListItem,
|
||||
ArchivedSessionListItem,
|
||||
@@ -81,6 +82,7 @@ type UseSidebarControllerArgs = {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeSessions: SessionActivityMap;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
t: TFunction;
|
||||
@@ -100,6 +102,7 @@ export function useSidebarController({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession: _selectedSession,
|
||||
activeSessions,
|
||||
isLoading,
|
||||
isMobile,
|
||||
t,
|
||||
@@ -146,6 +149,8 @@ export function useSidebarController({
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
|
||||
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
||||
const activeSessionIds = useMemo(() => new Set(activeSessions.keys()), [activeSessions]);
|
||||
const runningSessionsCount = activeSessionIds.size;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
@@ -582,9 +587,48 @@ export function useSidebarController({
|
||||
[projectSortOrder, projectsWithResolvedStarState],
|
||||
);
|
||||
|
||||
const runningProjects = useMemo(() => {
|
||||
if (activeSessionIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sortedProjects.reduce<Project[]>((acc, project) => {
|
||||
const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const cursorSessions = (project.cursorSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const codexSessions = (project.codexSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const geminiSessions = (project.geminiSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const opencodeSessions = (project.opencodeSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const runningCount =
|
||||
sessions.length
|
||||
+ cursorSessions.length
|
||||
+ codexSessions.length
|
||||
+ geminiSessions.length
|
||||
+ opencodeSessions.length;
|
||||
|
||||
if (runningCount === 0) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push({
|
||||
...project,
|
||||
sessions,
|
||||
cursorSessions,
|
||||
codexSessions,
|
||||
geminiSessions,
|
||||
opencodeSessions,
|
||||
sessionMeta: {
|
||||
...project.sessionMeta,
|
||||
total: runningCount,
|
||||
hasMore: false,
|
||||
},
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
}, [activeSessionIds, sortedProjects]);
|
||||
|
||||
const filteredProjects = useMemo(
|
||||
() => filterProjects(sortedProjects, debouncedSearchQuery),
|
||||
[debouncedSearchQuery, sortedProjects],
|
||||
() => filterProjects(searchMode === 'running' ? runningProjects : sortedProjects, debouncedSearchQuery),
|
||||
[debouncedSearchQuery, runningProjects, searchMode, sortedProjects],
|
||||
);
|
||||
|
||||
const filteredArchivedSessions = useMemo(() => {
|
||||
@@ -914,6 +958,7 @@ export function useSidebarController({
|
||||
sessionDeleteConfirmation,
|
||||
showVersionModal,
|
||||
filteredProjects,
|
||||
runningSessionsCount,
|
||||
archivedProjects: filteredArchivedProjects,
|
||||
archivedSessions: filteredArchivedSessions,
|
||||
archivedSessionsCount: archivedProjects.length + archivedSessions.length,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||
|
||||
export type ProjectSortOrder = 'name' | 'date';
|
||||
export type SidebarSearchMode = 'projects' | 'conversations' | 'archived';
|
||||
export type SidebarSearchMode = 'projects' | 'conversations' | 'running' | 'archived';
|
||||
export type ArchivedProjectListItem = Project & { isArchived: true };
|
||||
|
||||
export type SessionWithProvider = ProjectSession & {
|
||||
@@ -40,6 +41,7 @@ export type SidebarProps = {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeSessions: SessionActivityMap;
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onSessionSelect: (session: ProjectSession) => void;
|
||||
onNewSession: (project: Project) => void;
|
||||
|
||||
@@ -25,6 +25,7 @@ function Sidebar({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeSessions,
|
||||
onProjectSelect,
|
||||
onSessionSelect,
|
||||
onNewSession,
|
||||
@@ -70,6 +71,7 @@ function Sidebar({
|
||||
isSearching,
|
||||
searchProgress,
|
||||
clearConversationResults,
|
||||
runningSessionsCount,
|
||||
deletingProjects,
|
||||
deleteConfirmation,
|
||||
sessionDeleteConfirmation,
|
||||
@@ -113,6 +115,7 @@ function Sidebar({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeSessions,
|
||||
isLoading,
|
||||
isMobile,
|
||||
t,
|
||||
@@ -159,6 +162,8 @@ function Sidebar({
|
||||
mcpServerStatus,
|
||||
getProjectSessions,
|
||||
loadingMoreProjects,
|
||||
activeSessions,
|
||||
forceExpanded: searchMode === 'running',
|
||||
isProjectStarred,
|
||||
onEditingNameChange: setEditingName,
|
||||
onToggleProject: toggleProject,
|
||||
@@ -229,6 +234,7 @@ function Sidebar({
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
projects={projects}
|
||||
runningSessionsCount={runningSessionsCount}
|
||||
archivedProjects={archivedProjects}
|
||||
archivedSessions={archivedSessions}
|
||||
archivedSessionsCount={archivedSessionsCount}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import { Activity, 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 { getAllSessions } from '../../utils/utils';
|
||||
|
||||
import SidebarFooter from './SidebarFooter';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||
import { getAllSessions } from '../../utils/utils';
|
||||
|
||||
function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {
|
||||
const parts: ReactNode[] = [];
|
||||
@@ -114,6 +116,7 @@ type SidebarContentProps = {
|
||||
isMobile: boolean;
|
||||
isLoading: boolean;
|
||||
projects: Project[];
|
||||
runningSessionsCount: number;
|
||||
archivedProjects: ArchivedProjectListItem[];
|
||||
archivedSessions: ArchivedSessionListItem[];
|
||||
archivedSessionsCount: number;
|
||||
@@ -152,6 +155,7 @@ export default function SidebarContent({
|
||||
isMobile,
|
||||
isLoading,
|
||||
projects,
|
||||
runningSessionsCount,
|
||||
archivedProjects,
|
||||
archivedSessions,
|
||||
archivedSessionsCount,
|
||||
@@ -196,6 +200,7 @@ export default function SidebarContent({
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
projectsCount={projects.length}
|
||||
runningSessionsCount={runningSessionsCount}
|
||||
archivedSessionsCount={archivedSessionsCount}
|
||||
isArchivedSessionsLoading={isArchivedSessionsLoading}
|
||||
searchFilter={searchFilter}
|
||||
@@ -307,6 +312,39 @@ export default function SidebarContent({
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
) : searchMode === 'running' ? (
|
||||
projectListProps.filteredProjects.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 border border-border/70 bg-muted/50 md:mb-3">
|
||||
<Activity className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">
|
||||
{t('running.emptyTitle', 'No sessions running')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{runningSessionsCount > 0
|
||||
? t('running.noMatchingSessions', 'No running sessions match this search.')
|
||||
: t('running.emptyDescription', 'Active work will appear here while a provider is processing.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="mx-2 flex items-center justify-between rounded-lg border border-border/60 bg-card/50 px-3 py-2 shadow-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{t('running.title', 'Running now')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{runningSessionsCount}
|
||||
</span>
|
||||
</div>
|
||||
<SidebarProjectList {...projectListProps} />
|
||||
</div>
|
||||
)
|
||||
) : searchMode === 'archived' ? (
|
||||
isArchivedSessionsLoading ? (
|
||||
<div className="px-4 py-12 text-center md:py-8">
|
||||
@@ -358,7 +396,7 @@ export default function SidebarContent({
|
||||
<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">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
||||
{t('archived.projectArchived', 'Project archived')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -448,7 +486,7 @@ export default function SidebarContent({
|
||||
{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">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
||||
{t('archived.projectArchived', 'Project archived')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
||||
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 =
|
||||
@@ -14,6 +16,7 @@ type SidebarHeaderProps = {
|
||||
isMobile: boolean;
|
||||
isLoading: boolean;
|
||||
projectsCount: number;
|
||||
runningSessionsCount: number;
|
||||
archivedSessionsCount: number;
|
||||
isArchivedSessionsLoading: boolean;
|
||||
searchFilter: string;
|
||||
@@ -33,6 +36,7 @@ export default function SidebarHeader({
|
||||
isMobile,
|
||||
isLoading,
|
||||
projectsCount,
|
||||
runningSessionsCount,
|
||||
archivedSessionsCount,
|
||||
isArchivedSessionsLoading,
|
||||
searchFilter,
|
||||
@@ -46,12 +50,15 @@ export default function SidebarHeader({
|
||||
onCollapseSidebar,
|
||||
t,
|
||||
}: SidebarHeaderProps) {
|
||||
const showSearchTools = (projectsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading;
|
||||
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...')
|
||||
: t('projects.searchPlaceholder');
|
||||
: 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">
|
||||
@@ -153,6 +160,29 @@ export default function SidebarHeader({
|
||||
<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')}
|
||||
@@ -270,6 +300,30 @@ export default function SidebarHeader({
|
||||
<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')}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -43,6 +44,7 @@ type SidebarProjectItemProps = {
|
||||
provider: LLMProvider,
|
||||
) => void;
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
activeSessions: SessionActivityMap;
|
||||
onNewSession: (project: Project) => void;
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
@@ -84,6 +86,7 @@ export default function SidebarProjectItem({
|
||||
onSessionSelect,
|
||||
onDeleteSession,
|
||||
onLoadMoreSessions,
|
||||
activeSessions,
|
||||
onNewSession,
|
||||
onEditingSessionNameChange,
|
||||
onStartEditingSession,
|
||||
@@ -395,6 +398,7 @@ export default function SidebarProjectItem({
|
||||
initialSessionsLoaded={initialSessionsLoaded}
|
||||
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
||||
isLoadingMoreSessions={isLoadingMoreSessions}
|
||||
activeSessions={activeSessions}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { SessionActivityMap } from '../../../../hooks/useSessionProtection';
|
||||
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
||||
|
||||
import SidebarProjectItem from './SidebarProjectItem';
|
||||
@@ -27,6 +28,8 @@ export type SidebarProjectListProps = {
|
||||
getProjectSessions: (project: Project) => SessionWithProvider[];
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
loadingMoreProjects: Set<string>;
|
||||
activeSessions: SessionActivityMap;
|
||||
forceExpanded?: boolean;
|
||||
isProjectStarred: (projectName: string) => boolean;
|
||||
onEditingNameChange: (value: string) => void;
|
||||
onToggleProject: (projectName: string) => void;
|
||||
@@ -71,6 +74,8 @@ export default function SidebarProjectList({
|
||||
getProjectSessions,
|
||||
onLoadMoreSessions,
|
||||
loadingMoreProjects,
|
||||
activeSessions,
|
||||
forceExpanded = false,
|
||||
isProjectStarred,
|
||||
onEditingNameChange,
|
||||
onToggleProject,
|
||||
@@ -122,7 +127,7 @@ export default function SidebarProjectList({
|
||||
project={project}
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
isExpanded={expandedProjects.has(project.projectId)}
|
||||
isExpanded={forceExpanded || expandedProjects.has(project.projectId)}
|
||||
isDeleting={deletingProjects.has(project.projectId)}
|
||||
isStarred={isProjectStarred(project.projectId)}
|
||||
editingProject={editingProject}
|
||||
@@ -146,6 +151,7 @@ export default function SidebarProjectList({
|
||||
onSessionSelect={onSessionSelect}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onLoadMoreSessions={onLoadMoreSessions}
|
||||
activeSessions={activeSessions}
|
||||
onNewSession={onNewSession}
|
||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||
onStartEditingSession={onStartEditingSession}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Plus } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Button } from '../../../../shared/view/ui';
|
||||
import type { SessionActivityMap } from '../../../../hooks/useSessionProtection';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { SessionWithProvider } from '../../types/types';
|
||||
|
||||
@@ -15,6 +16,7 @@ type SidebarProjectSessionsProps = {
|
||||
initialSessionsLoaded: boolean;
|
||||
hasMoreSessions: boolean;
|
||||
isLoadingMoreSessions: boolean;
|
||||
activeSessions: SessionActivityMap;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -61,6 +63,7 @@ export default function SidebarProjectSessions({
|
||||
initialSessionsLoaded,
|
||||
hasMoreSessions,
|
||||
isLoadingMoreSessions,
|
||||
activeSessions,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -120,6 +123,7 @@ export default function SidebarProjectSessions({
|
||||
project={project}
|
||||
session={session}
|
||||
selectedSession={selectedSession}
|
||||
isProcessing={activeSessions.has(session.id)}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Check, Edit2, Trash2, X } from 'lucide-react';
|
||||
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
||||
@@ -13,6 +13,7 @@ type SidebarSessionItemProps = {
|
||||
project: Project;
|
||||
session: SessionWithProvider;
|
||||
selectedSession: ProjectSession | null;
|
||||
isProcessing: boolean;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -63,6 +64,7 @@ export default function SidebarSessionItem({
|
||||
project,
|
||||
session,
|
||||
selectedSession,
|
||||
isProcessing,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -117,7 +119,7 @@ export default function SidebarSessionItem({
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
{sessionView.isActive && (
|
||||
{!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
|
||||
@@ -134,7 +136,9 @@ export default function SidebarSessionItem({
|
||||
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 && sessionView.isActive
|
||||
!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',
|
||||
)}
|
||||
@@ -153,7 +157,13 @@ export default function SidebarSessionItem({
|
||||
<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>
|
||||
{compactSessionAge && (
|
||||
{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>
|
||||
@@ -187,15 +197,16 @@ export default function SidebarSessionItem({
|
||||
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="flex items-center gap-2">
|
||||
<div className={cn('flex items-center gap-2', isProcessing && 'pr-7')}>
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{compactSessionAge && (
|
||||
{!isProcessing && compactSessionAge && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
|
||||
@@ -213,6 +224,19 @@ export default function SidebarSessionItem({
|
||||
</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(
|
||||
|
||||
@@ -987,6 +987,7 @@ export function useProjectsState({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeSessions,
|
||||
onProjectSelect: handleProjectSelect,
|
||||
onSessionSelect: handleSessionSelect,
|
||||
onNewSession: handleNewSession,
|
||||
@@ -1013,6 +1014,7 @@ export function useProjectsState({
|
||||
isLoadingProjects,
|
||||
isMobile,
|
||||
loadingProgress,
|
||||
activeSessions,
|
||||
projects,
|
||||
settingsInitialTab,
|
||||
selectedProject,
|
||||
|
||||
@@ -13,6 +13,13 @@ export interface SessionActivity {
|
||||
|
||||
export type SessionActivityMap = ReadonlyMap<string, SessionActivity>;
|
||||
|
||||
export type SessionActivitySnapshot = {
|
||||
sessionId: string;
|
||||
statusText?: string | null;
|
||||
canInterrupt?: boolean;
|
||||
startedAt?: number;
|
||||
};
|
||||
|
||||
export type MarkSessionProcessing = (
|
||||
sessionId?: string | null,
|
||||
activity?: { statusText?: string | null; canInterrupt?: boolean },
|
||||
@@ -23,6 +30,35 @@ export type MarkSessionIdle = (
|
||||
opts?: { ifStartedBefore?: number },
|
||||
) => void;
|
||||
|
||||
export type SyncProcessingSessions = (
|
||||
sessions: readonly SessionActivitySnapshot[],
|
||||
) => void;
|
||||
|
||||
const LOCAL_ACTIVITY_GRACE_MS = 10_000;
|
||||
|
||||
const sessionActivityMapsMatch = (
|
||||
left: ReadonlyMap<string, SessionActivity>,
|
||||
right: ReadonlyMap<string, SessionActivity>,
|
||||
): boolean => {
|
||||
if (left.size !== right.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [sessionId, leftActivity] of left) {
|
||||
const rightActivity = right.get(sessionId);
|
||||
if (
|
||||
!rightActivity
|
||||
|| leftActivity.statusText !== rightActivity.statusText
|
||||
|| leftActivity.canInterrupt !== rightActivity.canInterrupt
|
||||
|| leftActivity.startedAt !== rightActivity.startedAt
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Single source of truth for which sessions are actively processing a
|
||||
* request. Everything the chat UI shows (activity indicator, abort
|
||||
@@ -88,9 +124,49 @@ export function useSessionProtection() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const syncProcessingSessions = useCallback<SyncProcessingSessions>((sessions) => {
|
||||
const now = Date.now();
|
||||
|
||||
setProcessingSessions((prev) => {
|
||||
const incoming = new Map<string, SessionActivitySnapshot>();
|
||||
for (const session of sessions) {
|
||||
if (!session.sessionId) {
|
||||
continue;
|
||||
}
|
||||
incoming.set(session.sessionId, session);
|
||||
}
|
||||
|
||||
const updated = new Map<string, SessionActivity>();
|
||||
|
||||
for (const [sessionId, snapshot] of incoming) {
|
||||
const existing = prev.get(sessionId);
|
||||
const snapshotStartedAt =
|
||||
typeof snapshot.startedAt === 'number' && Number.isFinite(snapshot.startedAt) && snapshot.startedAt > 0
|
||||
? snapshot.startedAt
|
||||
: undefined;
|
||||
|
||||
updated.set(sessionId, {
|
||||
statusText:
|
||||
snapshot.statusText !== undefined ? snapshot.statusText : existing?.statusText ?? null,
|
||||
canInterrupt: snapshot.canInterrupt ?? existing?.canInterrupt ?? true,
|
||||
startedAt: snapshotStartedAt ?? existing?.startedAt ?? now,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [sessionId, activity] of prev) {
|
||||
if (!incoming.has(sessionId) && now - activity.startedAt < LOCAL_ACTIVITY_GRACE_MS) {
|
||||
updated.set(sessionId, activity);
|
||||
}
|
||||
}
|
||||
|
||||
return sessionActivityMapsMatch(prev, updated) ? prev : updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
processingSessions,
|
||||
markSessionProcessing,
|
||||
markSessionIdle,
|
||||
syncProcessingSessions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,12 +128,44 @@ function createEmptySlot(): SessionSlot {
|
||||
* assistant echo (same trimmed text), so finalized stream rows do not stack
|
||||
* on top of the persisted copy before realtime is cleared.
|
||||
*/
|
||||
const LOCAL_USER_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
||||
const LOCAL_USER_DEDUPE_CLOCK_SKEW_MS = 10_000;
|
||||
|
||||
function userTextFingerprint(m: NormalizedMessage): string | null {
|
||||
if (m.kind !== 'text' || m.role !== 'user') return null;
|
||||
const t = (m.content || '').trim();
|
||||
return t.length > 0 ? t : null;
|
||||
}
|
||||
|
||||
function readMessageTime(m: NormalizedMessage): number | null {
|
||||
const time = Date.parse(m.timestamp);
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function hasServerEchoForLocalUser(
|
||||
localMessage: NormalizedMessage,
|
||||
serverMessages: NormalizedMessage[],
|
||||
): boolean {
|
||||
const localText = userTextFingerprint(localMessage);
|
||||
const localTime = readMessageTime(localMessage);
|
||||
if (!localText || localTime === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return serverMessages.some((serverMessage) => {
|
||||
if (userTextFingerprint(serverMessage) !== localText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverTime = readMessageTime(serverMessage);
|
||||
return (
|
||||
serverTime !== null
|
||||
&& serverTime >= localTime - LOCAL_USER_DEDUPE_CLOCK_SKEW_MS
|
||||
&& serverTime - localTime <= LOCAL_USER_DEDUPE_WINDOW_MS
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* After `finalizeStreaming`, the client holds a synthetic assistant `text` row
|
||||
* while the sessions API soon returns the same reply with a different id.
|
||||
@@ -175,16 +207,13 @@ function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[
|
||||
if (realtime.length === 0) return server;
|
||||
if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime);
|
||||
const serverIds = new Set(server.map(m => m.id));
|
||||
const serverUserTexts = new Set(
|
||||
server.map(userTextFingerprint).filter((t): t is string => t !== null),
|
||||
);
|
||||
const extra = realtime.filter((m) => {
|
||||
if (serverIds.has(m.id)) return false;
|
||||
// Optimistic user rows use `local_*` ids; once the same text exists on the
|
||||
// server-backed copy, drop the realtime echo to avoid duplicate bubbles.
|
||||
// server-backed copy from the same send window, drop the realtime echo to
|
||||
// avoid duplicate bubbles without hiding repeated prompts from history.
|
||||
if (m.id.startsWith('local_')) {
|
||||
const fp = userTextFingerprint(m);
|
||||
if (fp && serverUserTexts.has(fp)) return false;
|
||||
if (hasServerEchoForLocalUser(m, server)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -98,6 +98,8 @@ export const api = {
|
||||
},
|
||||
getArchivedSessions: () =>
|
||||
authenticatedFetch('/api/providers/sessions/archived'),
|
||||
runningSessions: () =>
|
||||
authenticatedFetch('/api/providers/sessions/running'),
|
||||
restoreSession: (sessionId) =>
|
||||
authenticatedFetch(`/api/providers/sessions/${sessionId}/restore`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user