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