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:
Haileyesus
2026-06-11 19:54:51 +03:00
parent 881e72d4a0
commit 591b18e9e3
19 changed files with 465 additions and 24 deletions

View File

@@ -420,6 +420,14 @@ router.post(
}),
);
router.get(
'/sessions/running',
asyncHandler(async (_req: Request, res: Response) => {
const sessions = sessionsService.listRunningSessions();
res.json(createApiSuccessResponse({ sessions }));
}),
);
router.get(
'/sessions/archived',
asyncHandler(async (_req: Request, res: Response) => {

View File

@@ -3,6 +3,7 @@ import fsp from 'node:fs/promises';
import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { chatRunRegistry } from '@/modules/websocket/index.js';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type {
FetchHistoryOptions,
@@ -84,6 +85,21 @@ export const sessionsService = {
return providerRegistry.listProviders().map((provider) => provider.id);
},
/**
* Returns app-facing ids for provider runs that are currently processing.
*
* This is intentionally status-only: callers that only need sidebar activity
* indicators should not attach to chat streams or request replayed messages.
*/
listRunningSessions(): Array<{
sessionId: string;
provider: LLMProvider;
startedAt: number;
lastSeq: number;
}> {
return chatRunRegistry.listRunningRuns();
},
/**
* Normalizes one provider-native event into frontend session message events.
*/

View File

@@ -1,2 +1,3 @@
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
export { createWebSocketServer } from './services/websocket-server.service.js';
export { chatRunRegistry } from './services/chat-run-registry.service.js';

View File

@@ -202,6 +202,22 @@ export const chatRunRegistry = {
return runs.get(appSessionId)?.status === 'running';
},
listRunningRuns(): Array<{
sessionId: string;
provider: LLMProvider;
startedAt: number;
lastSeq: number;
}> {
return Array.from(runs.values())
.filter((run) => run.status === 'running')
.map((run) => ({
sessionId: run.appSessionId,
provider: run.provider,
startedAt: run.startedAt,
lastSeq: run.lastSeq,
}));
},
/**
* Re-attaches a run's outbound stream to a (new) websocket connection.
*

View File

@@ -124,6 +124,38 @@ test('complete marks the run finished and duplicate completes are dropped', asyn
});
});
test('listRunningRuns returns only currently running app sessions', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
sessionsDb.createAppSession('app-run-8', 'codex', '/workspace/demo');
const connection = new FakeConnection();
const completedRun = chatRunRegistry.startRun({
appSessionId: 'app-run-7',
provider: 'claude',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(completedRun);
const runningRun = chatRunRegistry.startRun({
appSessionId: 'app-run-8',
provider: 'codex',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(runningRun);
chatRunRegistry.completeRun('app-run-7', { exitCode: 0 });
const runningSessions = chatRunRegistry.listRunningRuns();
assert.deepEqual(runningSessions.map((session) => session.sessionId), ['app-run-8']);
assert.equal(runningSessions[0]?.provider, 'codex');
});
});
test('replayEvents returns only events after the requested seq', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo');

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,
};
}

View File

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

View File

@@ -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',