From 591b18e9e343fda23affe100a53911f76aaa8f57 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:54:51 +0300 Subject: [PATCH] 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. --- server/modules/providers/provider.routes.ts | 8 ++ .../providers/services/sessions.service.ts | 16 ++++ server/modules/websocket/index.ts | 1 + .../services/chat-run-registry.service.ts | 16 ++++ .../websocket/tests/chat-run-registry.test.ts | 32 ++++++++ src/components/app/AppContent.tsx | 78 ++++++++++++++++++- .../sidebar/hooks/useSidebarController.ts | 49 +++++++++++- src/components/sidebar/types/types.ts | 4 +- src/components/sidebar/view/Sidebar.tsx | 6 ++ .../view/subcomponents/SidebarContent.tsx | 46 ++++++++++- .../view/subcomponents/SidebarHeader.tsx | 60 +++++++++++++- .../view/subcomponents/SidebarProjectItem.tsx | 4 + .../view/subcomponents/SidebarProjectList.tsx | 8 +- .../subcomponents/SidebarProjectSessions.tsx | 4 + .../view/subcomponents/SidebarSessionItem.tsx | 36 +++++++-- src/hooks/useProjectsState.ts | 2 + src/hooks/useSessionProtection.ts | 76 ++++++++++++++++++ src/stores/useSessionStore.ts | 41 ++++++++-- src/utils/api.js | 2 + 19 files changed, 465 insertions(+), 24 deletions(-) diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 14f95080..ec76a7db 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -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) => { diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 7379b60b..6836e87e 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -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. */ diff --git a/server/modules/websocket/index.ts b/server/modules/websocket/index.ts index da65ee82..56bf1d00 100644 --- a/server/modules/websocket/index.ts +++ b/server/modules/websocket/index.ts @@ -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'; diff --git a/server/modules/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts index ae8852bf..c807f209 100644 --- a/server/modules/websocket/services/chat-run-registry.service.ts +++ b/server/modules/websocket/services/chat-run-registry.service.ts @@ -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. * diff --git a/server/modules/websocket/tests/chat-run-registry.test.ts b/server/modules/websocket/tests/chat-run-registry.test.ts index e9a76df0..bc33b897 100644 --- a/server/modules/websocket/tests/chat-run-registry.test.ts +++ b/server/modules/websocket/tests/chat-run-registry.test.ts @@ -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'); diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index f4776c88..27258b35 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -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 => 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, diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index ba559442..a7d539e0 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -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((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, diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index f8c31be2..3e049b3d 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -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; diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index ebc046c5..15d96990 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -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} diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 5ce63b8b..ce8554e2 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -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({ ))} ) : null + ) : searchMode === 'running' ? ( + projectListProps.filteredProjects.length === 0 ? ( +
+
+ +
+

+ {t('running.emptyTitle', 'No sessions running')} +

+

+ {runningSessionsCount > 0 + ? t('running.noMatchingSessions', 'No running sessions match this search.') + : t('running.emptyDescription', 'Active work will appear here while a provider is processing.')} +

+
+ ) : ( +
+
+
+ + + + + {t('running.title', 'Running now')} + +
+ + {runningSessionsCount} + +
+ +
+ ) ) : searchMode === 'archived' ? ( isArchivedSessionsLoading ? (
@@ -358,7 +396,7 @@ export default function SidebarContent({ {project.displayName} - + {t('archived.projectArchived', 'Project archived')}
@@ -448,7 +486,7 @@ export default function SidebarContent({ {group.projectDisplayName} {group.isProjectArchived && ( - + {t('archived.projectArchived', 'Project archived')} )} diff --git a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx index 5117b8db..8eabab2a 100644 --- a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx @@ -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 = () => (
@@ -153,6 +160,29 @@ export default function SidebarHeader({ {t('search.modeConversations')} + + + + + + + {isProcessing && ( +
+ +
+ )} +
; +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, + right: ReadonlyMap, +): 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((sessions) => { + const now = Date.now(); + + setProcessingSessions((prev) => { + const incoming = new Map(); + for (const session of sessions) { + if (!session.sessionId) { + continue; + } + incoming.set(session.sessionId, session); + } + + const updated = new Map(); + + 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, }; } diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index c88e26d5..46882464 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -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; }); diff --git a/src/utils/api.js b/src/utils/api.js index 999ee316..7502af91 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -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',