diff --git a/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts index 786d783a..10834c5c 100644 --- a/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts @@ -1,5 +1,5 @@ -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { useNotifications } from './useNotifications'; import type { Node } from '@/context/NodeContext'; import type { NotificationItem } from '../../dashboard/types'; @@ -34,11 +34,30 @@ class MockWS { beforeEach(() => { MockWS.reset(); vi.stubGlobal('WebSocket', MockWS); - (apiFetch as ReturnType).mockResolvedValue({ ok: false }); + (apiFetch as ReturnType).mockResolvedValue({ ok: false, json: async () => [] }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); }); -afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); describe('useNotifications', () => { + let originalError: typeof console.error; + + beforeAll(() => { + originalError = console.error; + console.error = (...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].includes('was not wrapped in act')) { + return; + } + originalError.call(console, ...args); + }; + }); + + afterAll(() => { + console.error = originalError; + }); + it('starts with empty notifications and disconnected state', () => { const { result } = renderHook(() => useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }), @@ -78,7 +97,6 @@ describe('useNotifications', () => { }); it('clearAllNotifications empties the local state', async () => { - (apiFetch as ReturnType).mockResolvedValue({ ok: true }); const { result } = renderHook(() => useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }), ); @@ -89,12 +107,11 @@ describe('useNotifications', () => { }); }); expect(result.current.notifications).toHaveLength(1); - await act(async () => { await result.current.clearAllNotifications(); }); - expect(result.current.notifications).toHaveLength(0); + act(() => { result.current.clearAllNotifications(); }); + await waitFor(() => expect(result.current.notifications).toHaveLength(0)); }); it('deleteNotification removes the matching item', async () => { - (apiFetch as ReturnType).mockResolvedValue({ ok: true }); const { result } = renderHook(() => useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn() }), ); @@ -105,7 +122,7 @@ describe('useNotifications', () => { data: JSON.stringify({ type: 'notification', payload: notif }), }); }); - await act(async () => { await result.current.deleteNotification({ ...notif, nodeId: localNode.id }); }); - expect(result.current.notifications).toHaveLength(0); + act(() => { result.current.deleteNotification({ ...notif, nodeId: localNode.id }); }); + await waitFor(() => expect(result.current.notifications).toHaveLength(0)); }); }); diff --git a/frontend/src/components/sidebar/SidebarActions.tsx b/frontend/src/components/sidebar/SidebarActions.tsx index fffb5217..478767ed 100644 --- a/frontend/src/components/sidebar/SidebarActions.tsx +++ b/frontend/src/components/sidebar/SidebarActions.tsx @@ -15,7 +15,7 @@ interface SidebarActionsProps { export function SidebarActions({ createStackSlot, onScan, isScanning, bulkMode, onToggleBulkMode }: SidebarActionsProps) { return (
-
{createStackSlot}
+
{createStackSlot}
diff --git a/frontend/src/components/sidebar/SidebarActivityTicker.tsx b/frontend/src/components/sidebar/SidebarActivityTicker.tsx index b6d40766..60b071a2 100644 --- a/frontend/src/components/sidebar/SidebarActivityTicker.tsx +++ b/frontend/src/components/sidebar/SidebarActivityTicker.tsx @@ -12,16 +12,17 @@ interface SidebarActivityTickerProps { } export function SidebarActivityTicker({ notifications, connected, onNavigate }: SidebarActivityTickerProps) { - const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + const [tick, forceUpdate] = useReducer((x: number) => x + 1, 0); useEffect(() => { const id = setInterval(forceUpdate, NOW_TICK_MS); return () => clearInterval(id); }, []); const latest = useMemo(() => { + const nowSecs = Math.floor(Date.now() / 1000); return notifications - .filter(n => n.stack_name) + .filter(n => n.stack_name && (nowSecs - n.timestamp) <= 3600) .sort((a, b) => b.timestamp - a.timestamp)[0] ?? null; - }, [notifications]); + }, [notifications, tick]); const idle = latest === null; const dotClass = connected diff --git a/frontend/src/components/sidebar/SidebarFilterChips.tsx b/frontend/src/components/sidebar/SidebarFilterChips.tsx index 09db8644..16f47e6b 100644 --- a/frontend/src/components/sidebar/SidebarFilterChips.tsx +++ b/frontend/src/components/sidebar/SidebarFilterChips.tsx @@ -1,3 +1,4 @@ +import { Minus, Plus } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { FilterChip } from './sidebar-types'; @@ -12,6 +13,8 @@ interface SidebarFilterChipsProps { active: FilterChip; counts: FilterCounts; onChange: (chip: FilterChip) => void; + visible: boolean; + onToggle: () => void; } const chips: { id: FilterChip; label: string }[] = [ @@ -21,42 +24,59 @@ const chips: { id: FilterChip; label: string }[] = [ { id: 'updates', label: 'Updates' }, ]; -export function SidebarFilterChips({ active, counts, onChange }: SidebarFilterChipsProps) { +export function SidebarFilterChips({ active, counts, onChange, visible, onToggle }: SidebarFilterChipsProps) { return ( -
- {chips.map(({ id, label }) => { - const count = counts[id]; - const isActive = active === id; - const isUpdates = id === 'updates'; - const hasUpdates = isUpdates && count > 0; +
+ {visible ? ( +
+ {chips.map(({ id, label }) => { + const count = counts[id]; + const displayCount = count > 99 ? '99+' : count; + const isActive = active === id; + const isUpdates = id === 'updates'; + const hasUpdates = isUpdates && count > 0; - return ( - - ); - })} + return ( + + ); + })} +
+ ) : ( +
+ )} +
); } diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index c1a60f4a..46572846 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { useState, useCallback, type ReactNode } from 'react'; import { Command } from '@/components/ui/command'; import { ScrollArea } from '@/components/ui/scroll-area'; import type { NotificationItem } from '@/components/dashboard/types'; @@ -45,6 +45,21 @@ export function StackSidebar(props: StackSidebarProps) { bulkMode, selectedFiles, isPaid, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction, } = props; + const [filtersVisible, setFiltersVisible] = useState(() => { + try { + const v = window.localStorage.getItem('sencho:sidebar:filters-visible'); + return v === null ? true : v !== 'false'; + } catch { return true; } + }); + + const handleToggleFilters = useCallback(() => { + setFiltersVisible(prev => { + const next = !prev; + try { window.localStorage.setItem('sencho:sidebar:filters-visible', String(next)); } catch { /* localStorage unavailable */ } + return next; + }); + }, []); + return (
@@ -64,6 +79,8 @@ export function StackSidebar(props: StackSidebarProps) { active={filterChip} counts={filterCounts} onChange={onFilterChipChange} + visible={filtersVisible} + onToggle={handleToggleFilters} /> {selectedFiles.size > 0 && ( {
child
); - expect(screen.getByText('PINNED')).toHaveClass('text-brand/90'); + expect(screen.getByText(/PINNED/)).toHaveClass('text-brand/90'); }); });