diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index f717ebdd..6853042e 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -235,8 +235,8 @@ SEED 팔레트 블록에는 `prefers-color-scheme` 미디어쿼리가 없어서, ## 10. 실시간 이벤트 -- **SSE 단일화** — 양방향 WebSocket 미사용. 모든 서버 → 클라이언트 푸시는 SSE로 처리. -- 구현: `shared/hooks/useEventStream.ts` (자동 재연결 + 폴링 fallback) +- **SSE + WebSocket 병행** — 작업 상태 푸시(분석·피드백)는 SSE, 라이브 면접 메시지는 WS(`features/interview/model/useInterviewSocket.ts`). (루트 CLAUDE.md §8 과 동일) +- 구현: `shared/hooks/useEventStream.ts` — 자동 재연결(지수 백오프) + 연결 상태 반환. 워크스페이스는 단절(closed) 시 배너 표시 + 목록 쿼리 5s 폴백 폴링(`useAnalysisFallbackPolling`) - 미디어 스트림(음성/영상)만 WebRTC: `features/interview/lib/media/` - 이벤트 스펙: [`/docs/event-stream.md`](../docs/event-stream.md) diff --git a/frontend/src/features/cover-letter/model/useCoverLetters.ts b/frontend/src/features/cover-letter/model/useCoverLetters.ts index cc99c56f..c4202e8b 100644 --- a/frontend/src/features/cover-letter/model/useCoverLetters.ts +++ b/frontend/src/features/cover-letter/model/useCoverLetters.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useAnalysisFallbackPolling } from '@/shared/hooks' import { isApiError } from '@/shared/api' import { toast } from '@/shared/ui' import { @@ -16,9 +17,12 @@ export const coverLetterKeys = { } export function useCoverLetters() { + // 분석 SSE 가 죽은 동안만 5s 폴링 (useResumes 와 동일한 이유). + const fallbackPolling = useAnalysisFallbackPolling() return useQuery({ queryKey: coverLetterKeys.all, queryFn: fetchCoverLetters, + refetchInterval: fallbackPolling, }) } diff --git a/frontend/src/features/interview/ui/live/ConnectionBanner.tsx b/frontend/src/features/interview/ui/live/ConnectionBanner.tsx deleted file mode 100644 index 3116730b..00000000 --- a/frontend/src/features/interview/ui/live/ConnectionBanner.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ConnectionStatus } from '../../model/useLiveInterview' - -export function ConnectionBanner({ connection }: { connection: ConnectionStatus }) { - if (connection === 'open') return null - return ( -
- {connection === 'connecting' ? '면접 서버에 연결 중입니다…' : '연결이 끊겨 재연결 중입니다…'} -
- ) -} diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index 37799488..ad932927 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -7,7 +7,7 @@ import { isQuestion, isTranscribing, sessionProgress } from '@/domain/session' import type { Session } from '@/domain/session' import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview' import type { DeliveryMode } from '../../model/useDeliveryMode' -import { ConnectionBanner } from './ConnectionBanner' +import { ConnectionBanner } from '@/shared/ui' import { SmallScreenNotice } from './SmallScreenNotice' import { AnswerComposer } from './AnswerComposer' import { StageQuestion } from './StageQuestion' @@ -150,7 +150,11 @@ export function InterviewStage({ - +
({ queryKey: repoKeys.registered, queryFn: fetchRegisteredRepositories, + refetchInterval: fallbackPolling, }) } diff --git a/frontend/src/features/resume/model/useResumes.ts b/frontend/src/features/resume/model/useResumes.ts index 98627c09..e04e7126 100644 --- a/frontend/src/features/resume/model/useResumes.ts +++ b/frontend/src/features/resume/model/useResumes.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useAnalysisFallbackPolling } from '@/shared/hooks' import { isApiError } from '@/shared/api' import { toast } from '@/shared/ui' import { @@ -17,9 +18,13 @@ export const resumeKeys = { } export function useResumes() { + // 분석 SSE 가 죽은 동안만 5s 폴링 — 상태 변화(ANALYZING→ANALYZED)가 SSE 로만 오기 때문에 + // 폴백이 없으면 카드가 "분석 중"으로 영구 고착된다. + const fallbackPolling = useAnalysisFallbackPolling() return useQuery({ queryKey: resumeKeys.all, queryFn: fetchResumes, + refetchInterval: fallbackPolling, }) } diff --git a/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts b/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts index 1d328754..d6e076a8 100644 --- a/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts +++ b/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts @@ -1,10 +1,11 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { useQueryClient } from '@tanstack/react-query' import { createStreamToken } from '@/features/auth' import { resumeKeys } from '@/features/resume' import { repoKeys } from '@/features/repo' import { documentKeys } from '@/features/analysis' -import { analysisProgress, useEventStream } from '@/shared/hooks' +import { analysisProgress, useEventStream, workspaceStreamHealth } from '@/shared/hooks' +import type { StreamConnectionStatus } from '@/shared/hooks' // RealTime SSE data 봉투: { data: , traceId }. type StreamData = { data?: T; traceId?: string | null } @@ -23,7 +24,8 @@ function unwrap(raw: unknown): T | undefined { // 이력서·레포·문서 쿼리를 무효화 → 화면이 자동으로 최신 상태로 갱신된다. // 추가로 ANALYSIS_PROGRESS(단계별 진행)는 쿼리 무효화 없이 진행 store 만 갱신한다. -export function useWorkspaceAnalysisStream() { +// 반환값: 연결 상태 — WorkspacePage 가 단절 배너를 그리는 데 사용. +export function useWorkspaceAnalysisStream(): StreamConnectionStatus { const queryClient = useQueryClient() const getToken = useCallback(() => createStreamToken(), []) @@ -52,7 +54,7 @@ export function useWorkspaceAnalysisStream() { }) }, []) - useEventStream({ + const status = useEventStream({ path: '/realtime/stream/me', getToken, handlers: { @@ -61,4 +63,14 @@ export function useWorkspaceAnalysisStream() { ANALYSIS_PROGRESS: onProgress, }, }) + + // 목록 쿼리의 폴백 폴링 스위치. 'closed' 에서만 down — 최초 'connecting' 구간에 + // 폴링을 켜면 정상 부팅마다 불필요한 요청이 나간다. 언마운트 시 idle 로 되돌려 + // 워크스페이스 밖에서 폴링이 돌지 않게 한다. + useEffect(() => { + workspaceStreamHealth.set(status === 'closed' ? 'down' : 'up') + }, [status]) + useEffect(() => () => workspaceStreamHealth.set('idle'), []) + + return status } diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx index 227ffde2..1560ecbc 100644 --- a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -1,6 +1,6 @@ import { useLocation } from 'react-router-dom' import { useAuth } from '@/features/auth' -import { PageHeader } from '@/shared/ui' +import { ConnectionBanner, PageHeader } from '@/shared/ui' import { WorkspaceSidebar } from '@/widgets/workspace-sidebar' import { useWorkspaceAnalysisStream } from '../model/useWorkspaceAnalysisStream' import { HomeView } from './HomeView' @@ -25,7 +25,7 @@ function resolveView(pathname: string): View { export default function WorkspacePage() { // 분석 상태 실시간 구독 (SSE) — 어떤 뷰에 있든 완료 시 목록이 자동 갱신된다. - useWorkspaceAnalysisStream() + const streamStatus = useWorkspaceAnalysisStream() const { user } = useAuth() const { pathname } = useLocation() @@ -73,6 +73,14 @@ export default function WorkspacePage() {
+ {/* 최초 연결(connecting)은 정상 부팅 구간이라 조용히 두고, 끊김(closed)만 알린다. + 이 동안 목록은 5s 폴백 폴링으로 갱신된다 (useAnalysisFallbackPolling). */} + {streamStatus === 'closed' && ( + + )}
`${targetType}:${targetId}` -const store = new Map() +// 진행 이벤트는 분석 중 수 초 간격으로 온다. 이 시간 동안 갱신이 없으면 종료 이벤트 +// (DOC_STATE/REPO_STATE)가 유실된 것으로 보고 문구를 걷어낸다 — TTL 이 없으면 +// "임베딩하는 중…" 이 영구 고착되고, 재분석 시 이전 세션 문구가 되살아난다. +const PROGRESS_TTL_MS = 90_000 +const SWEEP_INTERVAL_MS = 15_000 + +type Entry = AnalysisProgress & { updatedAt: number } + +const store = new Map() const listeners = new Set<() => void>() +let sweepTimer: ReturnType | null = null function emitChange(): void { for (const listener of listeners) listener() } +// 스토어가 비어 있지 않은 동안에만 만료 스위퍼를 돌린다. +function sweep(): void { + const cutoff = Date.now() - PROGRESS_TTL_MS + let changed = false + for (const [key, entry] of store) { + if (entry.updatedAt < cutoff) { + store.delete(key) + changed = true + } + } + if (store.size === 0 && sweepTimer !== null) { + clearInterval(sweepTimer) + sweepTimer = null + } + if (changed) emitChange() +} + export const analysisProgress = { set(targetType: string, targetId: number, value: AnalysisProgress): void { - store.set(keyOf(targetType, targetId), value) + store.set(keyOf(targetType, targetId), { ...value, updatedAt: Date.now() }) + if (sweepTimer === null) sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS) emitChange() }, clear(targetType: string, targetId: number): void { - if (store.delete(keyOf(targetType, targetId))) emitChange() + if (store.delete(keyOf(targetType, targetId))) { + if (store.size === 0 && sweepTimer !== null) { + clearInterval(sweepTimer) + sweepTimer = null + } + emitChange() + } }, get(targetType: string, targetId: number): AnalysisProgress | undefined { return store.get(keyOf(targetType, targetId)) diff --git a/frontend/src/shared/hooks/useAnalysisProgress.ttl.test.ts b/frontend/src/shared/hooks/useAnalysisProgress.ttl.test.ts new file mode 100644 index 00000000..dd7964c3 --- /dev/null +++ b/frontend/src/shared/hooks/useAnalysisProgress.ttl.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { analysisProgress } from './useAnalysisProgress' + +afterEach(() => { + analysisProgress.clear('RESUME', 1) + analysisProgress.clear('RESUME', 2) + vi.useRealTimers() +}) + +describe('analysisProgress TTL', () => { + it('갱신이 끊긴 항목은 TTL(90s) 후 걷어낸다 — 종료 이벤트 유실 시 영구 고착 방지', () => { + vi.useFakeTimers() + analysisProgress.set('RESUME', 1, { phase: 'EMBEDDING', message: '임베딩 중…' }) + expect(analysisProgress.get('RESUME', 1)?.message).toBe('임베딩 중…') + + vi.advanceTimersByTime(91_000 + 15_000) + expect(analysisProgress.get('RESUME', 1)).toBeUndefined() + }) + + it('계속 갱신되는 항목은 살아있고, 끊긴 항목만 만료된다', () => { + vi.useFakeTimers() + analysisProgress.set('RESUME', 1, { phase: 'EXTRACTING', message: 'A' }) + analysisProgress.set('RESUME', 2, { phase: 'EXTRACTING', message: 'B' }) + + // 1번만 60초마다 갱신 — 2번은 방치. + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(60_000) + analysisProgress.set('RESUME', 1, { phase: 'SUMMARIZING', message: `A${i}` }) + } + expect(analysisProgress.get('RESUME', 1)).toBeDefined() + expect(analysisProgress.get('RESUME', 2)).toBeUndefined() + }) + + it('구독자는 만료 시점에 알림을 받는다', () => { + vi.useFakeTimers() + const listener = vi.fn() + const unsubscribe = analysisProgress.subscribe(listener) + analysisProgress.set('RESUME', 1, { phase: 'EMBEDDING', message: '임베딩 중…' }) + listener.mockClear() + + vi.advanceTimersByTime(91_000 + 15_000) + expect(listener).toHaveBeenCalled() + unsubscribe() + }) +}) diff --git a/frontend/src/shared/hooks/useEventStream.status.test.tsx b/frontend/src/shared/hooks/useEventStream.status.test.tsx new file mode 100644 index 00000000..cae380e4 --- /dev/null +++ b/frontend/src/shared/hooks/useEventStream.status.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useEventStream } from './useEventStream' + +// jsdom 에 없는 EventSource — 테스트가 open/error 를 직접 발화한다. +class FakeES { + static last: FakeES | null = null + onopen: (() => void) | null = null + onerror: (() => void) | null = null + url: string + constructor(url: string) { + this.url = url + FakeES.last = this + } + addEventListener() {} + close() {} +} + +vi.stubGlobal('EventSource', FakeES) + +function setup(getToken: () => Promise = async () => 'tok') { + return renderHook(() => + useEventStream({ + path: '/realtime/stream/me', + getToken, + handlers: { DOC_STATE: () => {} }, + }), + ) +} + +beforeEach(() => { + FakeES.last = null +}) + +describe('useEventStream 연결 상태', () => { + it('최초엔 connecting, 열리면 open', async () => { + const { result } = setup() + expect(result.current).toBe('connecting') + await waitFor(() => expect(FakeES.last).not.toBeNull()) + act(() => FakeES.last?.onopen?.()) + expect(result.current).toBe('open') + }) + + it('끊기면 closed 로 승격되고, 재연결이 성사되기 전까지 유지된다', async () => { + vi.useFakeTimers() + try { + const { result } = setup() + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + act(() => FakeES.last?.onopen?.()) + expect(result.current).toBe('open') + + act(() => FakeES.last?.onerror?.()) + expect(result.current).toBe('closed') + + // 백오프 재시도 대기 중에도 connecting 으로 되돌아가지 않는다 (배너 깜빡임 방지). + await act(async () => { + await vi.advanceTimersByTimeAsync(1_100) + }) + expect(result.current).toBe('closed') + + // 재연결 성사 시에만 open 복귀. + act(() => FakeES.last?.onopen?.()) + expect(result.current).toBe('open') + } finally { + vi.useRealTimers() + } + }) + + it('언마운트 후 늦게 실패한 토큰 발급은 재연결을 스케줄하지 않는다', async () => { + vi.useFakeTimers() + try { + let reject: ((e: Error) => void) | undefined + const { unmount } = setup( + () => new Promise((_res, rej) => (reject = rej)), + ) + unmount() + + await act(async () => { + reject?.(new Error('late failure')) + await vi.advanceTimersByTimeAsync(60_000) + }) + // 버려진 effect 가 재연결 타이머를 만들었다면 새 EventSource 가 생겼을 것. + expect(FakeES.last).toBeNull() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/frontend/src/shared/hooks/useEventStream.ts b/frontend/src/shared/hooks/useEventStream.ts index db2217e0..257c8b50 100644 --- a/frontend/src/shared/hooks/useEventStream.ts +++ b/frontend/src/shared/hooks/useEventStream.ts @@ -1,9 +1,15 @@ -import { useEffect, useLayoutEffect, useRef } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { env } from '@/shared/config/env' type EventHandler = (data: unknown) => void type EventHandlers = Record +// 'connecting' = 최초 연결 시도 중(정상 부팅 구간 — 배너 등 경고 UI 로 다루지 말 것), +// 'open' = 수신 중, 'closed' = 끊겨서 백오프 재연결 중(이벤트 유실 가능 구간). +// 한 번 끊긴 뒤의 재시도는 'connecting' 으로 되돌리지 않는다 — 재연결이 성사될 때까지 +// 'closed' 를 유지해 소비자가 "지연될 수 있음" 상태를 깜빡임 없이 표시할 수 있게 한다. +export type StreamConnectionStatus = 'connecting' | 'open' | 'closed' + type UseEventStreamOptions = { /** SSE_BASE_URL 에 붙일 경로. null 이면 연결하지 않음. */ path: string | null @@ -23,9 +29,10 @@ export function useEventStream({ getToken, handlers, enabled = true, -}: UseEventStreamOptions): void { +}: UseEventStreamOptions): StreamConnectionStatus { const handlersRef = useRef(handlers) const getTokenRef = useRef(getToken) + const [status, setStatus] = useState('connecting') // 렌더 중 ref 쓰기는 금지 → effect 에서 최신 값으로 동기화. useLayoutEffect(() => { @@ -36,6 +43,8 @@ export function useEventStream({ useEffect(() => { if (!enabled || !path) return + // 초기 state 가 'connecting' 이라 마운트 시 리셋이 필요 없다. path/enabled 는 현재 + // 사용처에서 상수라 재연결로 인한 상태 리셋 경로는 두지 않는다(effect 내 동기 setState 금지). let source: EventSource | null = null let reconnectTimer: ReturnType | null = null let attempt = 0 @@ -55,6 +64,11 @@ export function useEventStream({ try { token = await getTokenRef.current() } catch { + // StrictMode 재마운트에서 버려진 effect 의 늦은 reject 가 새 effect 의 상태를 + // 덮어쓰지 않도록, 다른 분기와 동일하게 cancelled 를 먼저 확인한다. + if (cancelled) return + // 첫 시도 실패도 '유실 가능 구간'이므로 closed 로 승격한다. + setStatus('closed') scheduleReconnect() return } @@ -68,11 +82,15 @@ export function useEventStream({ es.onopen = () => { attempt = 0 + setStatus('open') } es.onerror = () => { es.close() if (source === es) source = null - if (!cancelled) scheduleReconnect() + if (!cancelled) { + setStatus('closed') + scheduleReconnect() + } } for (const name of Object.keys(handlersRef.current)) { @@ -98,4 +116,6 @@ export function useEventStream({ source = null } }, [path, enabled]) + + return status } diff --git a/frontend/src/shared/hooks/workspaceStreamHealth.test.tsx b/frontend/src/shared/hooks/workspaceStreamHealth.test.tsx new file mode 100644 index 00000000..bb536c02 --- /dev/null +++ b/frontend/src/shared/hooks/workspaceStreamHealth.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { + workspaceStreamHealth, + useAnalysisFallbackPolling, + useWorkspaceStreamHealth, +} from './workspaceStreamHealth' + +afterEach(() => { + workspaceStreamHealth.set('idle') +}) + +describe('workspaceStreamHealth', () => { + it('스트림이 죽었을 때만 폴백 폴링(5s)을 켠다', () => { + const { result } = renderHook(() => useAnalysisFallbackPolling()) + expect(result.current).toBe(false) + + act(() => workspaceStreamHealth.set('down')) + expect(result.current).toBe(5_000) + + act(() => workspaceStreamHealth.set('up')) + expect(result.current).toBe(false) + }) + + it('idle(워크스페이스 밖)에서는 폴링하지 않는다', () => { + const { result } = renderHook(() => useWorkspaceStreamHealth()) + expect(result.current).toBe('idle') + const polling = renderHook(() => useAnalysisFallbackPolling()) + expect(polling.result.current).toBe(false) + }) +}) diff --git a/frontend/src/shared/hooks/workspaceStreamHealth.ts b/frontend/src/shared/hooks/workspaceStreamHealth.ts new file mode 100644 index 00000000..c0e382d2 --- /dev/null +++ b/frontend/src/shared/hooks/workspaceStreamHealth.ts @@ -0,0 +1,38 @@ +import { useSyncExternalStore } from 'react' + +// 워크스페이스 분석 SSE(/realtime/stream/me)의 건강 상태. +// 'down' 이면 SSE 푸시가 유실될 수 있으므로 목록 쿼리들이 폴백 폴링을 켠다 +// (api-conventions §9: SSE 우선, 끊기면 폴링 fallback). +// 스트림을 소유한 훅(useWorkspaceAnalysisStream)만 set 하고, 언마운트 시 'idle' 로 +// 되돌린다 — 워크스페이스 밖에서는 폴링이 돌지 않아야 한다. +export type WorkspaceStreamHealth = 'idle' | 'up' | 'down' + +let health: WorkspaceStreamHealth = 'idle' +const listeners = new Set<() => void>() + +export const workspaceStreamHealth = { + set(next: WorkspaceStreamHealth): void { + if (health === next) return + health = next + for (const listener of listeners) listener() + }, + get(): WorkspaceStreamHealth { + return health + }, + subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, +} + +export function useWorkspaceStreamHealth(): WorkspaceStreamHealth { + return useSyncExternalStore(workspaceStreamHealth.subscribe, workspaceStreamHealth.get) +} + +// 목록 쿼리용 폴백 폴링 간격 — 스트림이 죽었을 때만 5s (api-conventions §9). +export function useAnalysisFallbackPolling(): number | false { + const health = useWorkspaceStreamHealth() + return health === 'down' ? 5_000 : false +} diff --git a/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.test.tsx b/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.test.tsx new file mode 100644 index 00000000..5c82790e --- /dev/null +++ b/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ConnectionBanner } from './ConnectionBanner' + +describe('ConnectionBanner', () => { + it('open 이면 그리지 않는다', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('closed 면 재연결 문구를 alert 로 알린다', () => { + render() + expect(screen.getByRole('alert')).toHaveTextContent('연결이 끊겨 재연결 중입니다…') + }) + + it('문구를 맥락에 맞게 바꿀 수 있다', () => { + render( + , + ) + expect(screen.getByRole('alert')).toHaveTextContent('실시간 연결이 끊겼습니다.') + }) +}) diff --git a/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.tsx b/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.tsx new file mode 100644 index 00000000..6b3deca1 --- /dev/null +++ b/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.tsx @@ -0,0 +1,19 @@ +import type { StreamConnectionStatus } from '@/shared/hooks' + +// 실시간 채널(WS/SSE)의 단절을 알리는 경고 배너. 'open' 이면 그리지 않는다. +export function ConnectionBanner({ + connection, + connectingText = '서버에 연결 중입니다…', + reconnectingText = '연결이 끊겨 재연결 중입니다…', +}: { + connection: StreamConnectionStatus + connectingText?: string + reconnectingText?: string +}) { + if (connection === 'open') return null + return ( +
+ {connection === 'connecting' ? connectingText : reconnectingText} +
+ ) +} diff --git a/frontend/src/shared/ui/ConnectionBanner/index.ts b/frontend/src/shared/ui/ConnectionBanner/index.ts new file mode 100644 index 00000000..90e5983c --- /dev/null +++ b/frontend/src/shared/ui/ConnectionBanner/index.ts @@ -0,0 +1 @@ +export { ConnectionBanner } from './ConnectionBanner' diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 33b106d5..abf602a3 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -22,3 +22,4 @@ export { Reveal } from './Reveal' export type { RevealProps } from './Reveal' export { QueryError } from './QueryError' export type { QueryErrorProps } from './QueryError' +export { ConnectionBanner } from './ConnectionBanner'