From 45c6ed19b8469094453983bf96c67d1fdd92601d Mon Sep 17 00:00:00 2001 From: theshwal <273857753+theshwal@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:00:42 +0200 Subject: [PATCH] feat(web): show passive dev-server indicator on session rows Add a small server/rack icon to each session row in the left sidebar when the workdir's dev server is in a surfacable state (running, warning, error). The indicator reuses the existing useDevServerStore and is positioned in the top-right area so the row stays a single click target. Initial accuracy after a page reload is provided by calling fetchStatus once per unique workdir on mount. Closes theshwal/openfox#6 --- .../layout/Sidebar.devserver.test.tsx | 380 ++++++++++++++++++ web/src/components/layout/Sidebar.tsx | 72 +++- .../components/shared/icons/ServerIcon.tsx | 23 ++ web/src/components/shared/icons/index.ts | 1 + 4 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 web/src/components/layout/Sidebar.devserver.test.tsx create mode 100644 web/src/components/shared/icons/ServerIcon.tsx diff --git a/web/src/components/layout/Sidebar.devserver.test.tsx b/web/src/components/layout/Sidebar.devserver.test.tsx new file mode 100644 index 00000000..024d8de3 --- /dev/null +++ b/web/src/components/layout/Sidebar.devserver.test.tsx @@ -0,0 +1,380 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRoot } from 'react-dom/client' +import { act } from 'react' +import { Sidebar } from './Sidebar' +import { useDevServerStore } from '../../stores/dev-server' + +vi.mock('../../lib/api', () => ({ + authFetch: vi.fn(), +})) + +vi.mock('wouter', () => ({ + useLocation: () => [undefined, vi.fn()], + Link: ({ children, className }: { href: string; children: ReactNode; className?: string }) => ( + {children} + ), +})) + +interface SessionFixture { + id: string + projectId: string + workdir: string + workspace?: string + title?: string + isRunning: boolean + isFavorite: boolean + mode: 'planner' + phase: 'plan' | 'build' + createdAt: string + updatedAt: string + criteriaCount: number + criteriaCompleted: number + messageCount: number +} + +const baseSessions: SessionFixture[] = [ + { + id: 'session-running', + projectId: 'project-1', + workdir: '/tmp/running', + title: 'Running workdir', + isRunning: false, + isFavorite: false, + mode: 'planner', + phase: 'build', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + criteriaCount: 0, + criteriaCompleted: 0, + messageCount: 0, + }, + { + id: 'session-warning', + projectId: 'project-1', + workdir: '/tmp/warning', + title: 'Warning workdir', + isRunning: false, + isFavorite: false, + mode: 'planner', + phase: 'plan', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + criteriaCount: 0, + criteriaCompleted: 0, + messageCount: 0, + }, + { + id: 'session-off', + projectId: 'project-1', + workdir: '/tmp/off', + title: 'Off workdir', + isRunning: false, + isFavorite: false, + mode: 'planner', + phase: 'plan', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + criteriaCount: 0, + criteriaCompleted: 0, + messageCount: 0, + }, + { + id: 'session-shared', + projectId: 'project-1', + workdir: '/tmp/shared', + title: 'Shared workdir alpha', + isRunning: false, + isFavorite: false, + mode: 'planner', + phase: 'plan', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + criteriaCount: 0, + criteriaCompleted: 0, + messageCount: 0, + }, + { + id: 'session-shared-2', + projectId: 'project-1', + workdir: '/tmp/shared', + workspace: '/tmp/shared', + title: 'Shared workdir beta', + isRunning: false, + isFavorite: false, + mode: 'planner', + phase: 'plan', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + criteriaCount: 0, + criteriaCompleted: 0, + messageCount: 0, + }, +] + +const sessionStoreStateRef: { current: Record } = { + current: { + sessions: baseSessions, + currentSession: null, + unreadSessionIds: [], + sessionsWithPendingConfirmations: [], + pendingPathConfirmations: [], + createSession: vi.fn(), + deleteSession: vi.fn(), + listSessions: vi.fn(), + loadMoreSessions: vi.fn(), + sessionsHasMore: false, + sessionsPaginationLoading: false, + toggleFavorite: vi.fn(), + }, +} + +const projectStoreState = { + currentProject: { id: 'project-1', name: 'Project', workdir: '/tmp/project' }, +} + +vi.mock('../../stores/session', () => ({ + useSessionStore: (selector: (state: Record) => unknown) => selector(sessionStoreStateRef.current), +})) + +vi.mock('../../stores/project', () => ({ + useProjectStore: (selector: (state: typeof projectStoreState) => unknown) => selector(projectStoreState), +})) + +vi.mock('../shared/Button', () => ({ + Button: ({ children, onClick, className }: { children: ReactNode; onClick?: () => void; className?: string }) => ( + + ), +})) + +vi.mock('../settings/ProjectSettingsModal', () => ({ + ProjectSettingsModal: () => null, +})) + +const renderSidebar = async (): Promise => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render() + }) + await act(async () => { + await Promise.resolve() + }) + return Object.assign(container, { _root: root }) as HTMLDivElement & { _root: ReturnType } +} + +const cleanupSidebar = (container: HTMLDivElement & { _root?: ReturnType }) => { + container._root?.unmount() + container.remove() +} + +const setByWorkdir = (byWorkdir: Record) => { + useDevServerStore.setState({ byWorkdir } as never) +} + +beforeEach(() => { + sessionStoreStateRef.current = { + ...sessionStoreStateRef.current, + sessions: baseSessions, + } + setByWorkdir({}) + useDevServerStore.setState((state) => ({ ...state, fetchStatus: vi.fn() })) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('Sidebar dev-server indicator', () => { + it('shows no indicator when no dev-server status is known', async () => { + const container = await renderSidebar() + expect(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')).toHaveLength(0) + cleanupSidebar(container) + }) + + it('renders a green running indicator and hides off sessions', async () => { + setByWorkdir({ + '/tmp/running': { + status: { + state: 'running', + url: null, + hotReload: false, + config: null, + errorMessage: undefined, + inspectProxyPort: null, + }, + logs: [], + config: null, + }, + '/tmp/off': { + status: { + state: 'off', + url: null, + hotReload: false, + config: null, + errorMessage: undefined, + inspectProxyPort: null, + }, + logs: [], + config: null, + }, + }) + const container = await renderSidebar() + const indicators = Array.from(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')) + expect(indicators).toHaveLength(1) + const node = indicators[0] as HTMLElement + expect(node.getAttribute('data-state')).toBe('running') + expect(node.getAttribute('title')).toBe('Dev server running') + expect(node.className).toContain('text-accent-success') + cleanupSidebar(container) + }) + + it('distinguishes warning state visually from running', async () => { + setByWorkdir({ + '/tmp/warning': { + status: { + state: 'warning', + url: null, + hotReload: false, + config: null, + errorMessage: 'boom', + inspectProxyPort: null, + }, + logs: [], + config: null, + }, + }) + const container = await renderSidebar() + const indicators = Array.from(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')) + expect(indicators).toHaveLength(1) + const node = indicators[0] as HTMLElement + expect(node.getAttribute('data-state')).toBe('warning') + expect(node.getAttribute('title')).toBe('Dev server warning: boom') + expect(node.className).toContain('text-accent-warning') + expect(node.className).not.toContain('text-accent-success') + cleanupSidebar(container) + }) + + it('renders the error state in red and surfaces the error message', async () => { + setByWorkdir({ + '/tmp/running': { + status: { + state: 'error', + url: null, + hotReload: false, + config: null, + errorMessage: 'build failed', + inspectProxyPort: null, + }, + logs: [], + config: null, + }, + }) + const container = await renderSidebar() + const indicators = Array.from(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')) + expect(indicators).toHaveLength(1) + const node = indicators[0] as HTMLElement + expect(node.getAttribute('data-state')).toBe('error') + expect(node.getAttribute('title')).toBe('Dev server error: build failed') + expect(node.className).toContain('text-accent-error') + cleanupSidebar(container) + }) + + it('includes the dev-server URL in the running tooltip', async () => { + setByWorkdir({ + '/tmp/running': { + status: { + state: 'running', + url: 'http://localhost:5173', + hotReload: false, + config: null, + errorMessage: undefined, + inspectProxyPort: null, + }, + logs: [], + config: null, + }, + }) + const container = await renderSidebar() + const indicators = Array.from(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')) + const node = indicators[0] as HTMLElement + expect(node.getAttribute('title')).toBe('Dev server running at http://localhost:5173') + cleanupSidebar(container) + }) + + it('renders the indicator for every session that shares the same workdir', async () => { + setByWorkdir({ + '/tmp/shared': { + status: { + state: 'running', + url: null, + hotReload: false, + config: null, + errorMessage: undefined, + inspectProxyPort: null, + }, + logs: [], + config: null, + }, + }) + const container = await renderSidebar() + expect(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')).toHaveLength(2) + cleanupSidebar(container) + }) + + it('reflects devServer.state updates without a page reload', async () => { + const container = await renderSidebar() + expect(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')).toHaveLength(0) + + await act(async () => { + useDevServerStore.getState().handleMessage({ + type: 'devServer.state', + payload: { workdir: '/tmp/running', state: 'running', errorMessage: undefined }, + }) + }) + expect(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')).toHaveLength(1) + + await act(async () => { + useDevServerStore.getState().handleMessage({ + type: 'devServer.state', + payload: { workdir: '/tmp/running', state: 'warning', errorMessage: 'oops' }, + }) + }) + const indicators = Array.from(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')) + expect(indicators).toHaveLength(1) + expect((indicators[0] as HTMLElement).getAttribute('data-state')).toBe('warning') + + await act(async () => { + useDevServerStore.getState().handleMessage({ + type: 'devServer.state', + payload: { workdir: '/tmp/running', state: 'off', errorMessage: undefined }, + }) + }) + expect(container.querySelectorAll('[data-testid="sidebar-devserver-indicator"]')).toHaveLength(0) + + cleanupSidebar(container) + }) + + it('fetches dev-server status once per unique workdir on mount', async () => { + const authFetch = (await import('../../lib/api')).authFetch as unknown as ReturnType + authFetch.mockClear() + + const fetchStatusSpy = vi.fn() + const originalFetchStatus = useDevServerStore.getState().fetchStatus + useDevServerStore.setState((state) => ({ ...state, fetchStatus: fetchStatusSpy })) + + const container = await renderSidebar() + await act(async () => { + await Promise.resolve() + }) + const calledWorkdirs = fetchStatusSpy.mock.calls.map((call) => call[0]).sort() + expect(calledWorkdirs).toEqual(['/tmp/off', '/tmp/running', '/tmp/shared', '/tmp/warning']) + + useDevServerStore.setState((state) => ({ ...state, fetchStatus: originalFetchStatus })) + cleanupSidebar(container) + }) +}) diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index fbe78492..ab2c7e6d 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -11,13 +11,24 @@ import { CloseButton } from '../shared/CloseButton' import { ConfirmModal } from '../shared/ConfirmModal' import { Modal } from '../shared/Modal' import { ModalFooter } from '../shared/ModalFooter' -import { EllipsisIcon, SpinIcon, StopIcon, SearchIcon, XCloseIcon, StarIcon, StarFilledIcon } from '../shared/icons' +import { + EllipsisIcon, + SpinIcon, + StopIcon, + SearchIcon, + XCloseIcon, + StarIcon, + StarFilledIcon, + ServerIcon, +} from '../shared/icons' import { groupSessionsByDate, formatDateHeader, formatTime } from '../../lib/format-date.js' import { fuzzyMatch, highlightMatches } from '../../lib/modal-utils.js' import { shouldAutofocus } from '../../lib/device' import { useBinding, useKeybindings } from '../../hooks/useKeybindings.js' import { useResizable } from '../../hooks/useResizable' import { ResizeHandle } from '../shared/ResizeHandle' +import { useDevServerStore, useDevServerEntry } from '../../stores/dev-server' +import type { DevServerState } from '@shared/dev-server.js' interface SidebarProps { projectId: string @@ -25,6 +36,42 @@ interface SidebarProps { onClose?: () => void } +const DEV_SERVER_STATE_TOOLTIP: Record, string> = { + running: 'Dev server running', + warning: 'Dev server warning', + error: 'Dev server error', +} + +const DEV_SERVER_STATE_COLOR: Record, string> = { + running: 'text-accent-success', + warning: 'text-accent-warning', + error: 'text-accent-error', +} + +function DevServerRowIndicator({ workdir }: { workdir: string }) { + const { status } = useDevServerEntry(workdir) + const state = status?.state + if (!state || state === 'off') return null + const colorClass = DEV_SERVER_STATE_COLOR[state] + const baseTooltip = DEV_SERVER_STATE_TOOLTIP[state] + const detail = state === 'running' && status?.url ? ` at ${status.url}` : '' + const errorSuffix = + state === 'warning' || state === 'error' ? (status?.errorMessage ? `: ${status.errorMessage}` : '') : '' + const tooltip = `${baseTooltip}${detail}${errorSuffix}` + return ( + + + + ) +} + export function Sidebar({ projectId, isOpen = true, onClose }: SidebarProps) { const [, navigate] = useLocation() const [showSettings, setShowSettings] = useState(false) @@ -102,6 +149,26 @@ export function Sidebar({ projectId, isOpen = true, onClose }: SidebarProps) { // Filter sessions to those belonging to the current project by ID const projectSessions = sessions.filter((session) => session.projectId === currentProject?.id) + const fetchDevServerStatus = useDevServerStore((state) => state.fetchStatus) + + // Stable signature of unique workdirs so hydration re-runs on semantic change, + // not merely on session count change. + const uniqueWorkdirSignature = useMemo(() => { + const set = new Set() + for (const session of projectSessions) { + if (session.workdir) set.add(session.workspace ?? session.workdir) + } + return Array.from(set).sort().join('|') + }, [projectSessions]) + + // Hydrate dev-server status once per unique workdir referenced by visible sessions. + useEffect(() => { + if (!uniqueWorkdirSignature) return + for (const workdir of uniqueWorkdirSignature.split('|')) { + void fetchDevServerStatus(workdir) + } + }, [currentProject?.id, uniqueWorkdirSignature, fetchDevServerStatus]) + const [favoriteSessions, otherSessions] = useMemo(() => { const favs: SessionSummary[] = [] const others: SessionSummary[] = [] @@ -452,7 +519,7 @@ function renderSessionList(
@@ -525,6 +592,7 @@ function renderSessionList( {session.messageCount} messages
+ {session.workdir && } ) } diff --git a/web/src/components/shared/icons/ServerIcon.tsx b/web/src/components/shared/icons/ServerIcon.tsx new file mode 100644 index 00000000..7b25b359 --- /dev/null +++ b/web/src/components/shared/icons/ServerIcon.tsx @@ -0,0 +1,23 @@ +interface ServerIconProps { + className?: string +} + +export function ServerIcon({ className = 'w-3.5 h-3.5' }: ServerIconProps) { + return ( + + ) +} diff --git a/web/src/components/shared/icons/index.ts b/web/src/components/shared/icons/index.ts index c3fc3e23..42fe5cfa 100644 --- a/web/src/components/shared/icons/index.ts +++ b/web/src/components/shared/icons/index.ts @@ -36,6 +36,7 @@ export { PauseIcon } from './PauseIcon' export { TerminalIcon } from './TerminalIcon' export { TrashIcon } from './TrashIcon' export { SearchIcon } from './SearchIcon' +export { ServerIcon } from './ServerIcon' export { WarningIcon } from './WarningIcon' export { WarningSmallIcon } from './WarningSmallIcon' export { XCloseIcon, XCloseSmallIcon } from './XCloseIcon'