From 7a783d07145443b941f31a767ba0f8abb8fb304f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 16:57:30 -0400 Subject: [PATCH] fix(settings): restore image tab scrolling and dev websocket routing - route dashboard WebSocket traffic through /ws to avoid Vite HMR collisions - run the dev entrypoint with node so bun run dev stays stable in this repo - restore the Image settings scroll container contract and add regression coverage --- package.json | 2 +- src/web-server/index.ts | 7 +- .../unit/web-server/start-server-host.test.ts | 27 +- ui/src/hooks/use-websocket.ts | 2 +- .../sections/image-analysis/index.tsx | 273 ++++++------------ .../ui/pages/image-analysis-section.test.tsx | 9 +- 6 files changed, 132 insertions(+), 188 deletions(-) diff --git a/package.json b/package.json index e92eab14..4cd95f30 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "test:native": "bash tests/native/unix/edge-cases.sh", "test:e2e": "bun test tests/e2e/ --bail --timeout 60000", "report:hardening": "node scripts/hardening-inventory.js", - "dev": "bun run build:server && bun dist/ccs.js config --dev", + "dev": "bun run build:server && node dist/ccs.js config --dev", "dev:symlink": "bash scripts/dev-symlink.sh", "dev:unlink": "bash scripts/dev-symlink.sh --restore", "ui:build": "cd ui && bun run build", diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 9aa1ae0d..7053b231 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -36,6 +36,7 @@ export async function startServer(options: ServerOptions): Promise { instance.cleanup(); await new Promise((resolve) => instance.server.close(() => resolve())); } + + mock.restore(); }); describe('startServer host binding', () => { @@ -41,4 +43,27 @@ describe('startServer host binding', () => { const address = instance.server.address() as AddressInfo; expect(['0.0.0.0', '::']).toContain(address.address); }); + + it('attaches Vite HMR to the existing HTTP server in dev mode', async () => { + let viteConfig: Record | undefined; + + mock.module('vite', () => ({ + createServer: async (config: Record) => { + viteConfig = config; + return { + middlewares: (_req: unknown, _res: unknown, next: () => void) => next(), + }; + }, + })); + + const instance = await startServer({ port: 0, dev: true }); + instances.push(instance); + + expect(viteConfig).toBeDefined(); + const serverConfig = viteConfig?.server as + | { middlewareMode?: boolean; hmr?: { server?: unknown } } + | undefined; + expect(serverConfig?.middlewareMode).toBe(true); + expect(serverConfig?.hmr?.server).toBe(instance.server); + }); }); diff --git a/ui/src/hooks/use-websocket.ts b/ui/src/hooks/use-websocket.ts index 81164214..aab8a6e1 100644 --- a/ui/src/hooks/use-websocket.ts +++ b/ui/src/hooks/use-websocket.ts @@ -71,7 +71,7 @@ export function useWebSocket() { setStatus('connecting'); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${protocol}//${window.location.host}`); + const ws = new WebSocket(`${protocol}//${window.location.host}/ws`); wsRef.current = ws; ws.onopen = () => { diff --git a/ui/src/pages/settings/sections/image-analysis/index.tsx b/ui/src/pages/settings/sections/image-analysis/index.tsx index 11915b6e..a644dea9 100644 --- a/ui/src/pages/settings/sections/image-analysis/index.tsx +++ b/ui/src/pages/settings/sections/image-analysis/index.tsx @@ -210,79 +210,8 @@ function routeSourceLabel(source: ImageProfile['resolutionSource']): string { type SectionTone = 'sky' | 'amber' | 'emerald' | 'cyan' | 'slate'; -function getSectionShellClass(tone: SectionTone): string { - switch (tone) { - case 'sky': - return 'border-sky-500/20 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_45%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(248,250,252,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.88))]'; - case 'amber': - return 'border-amber-500/20 bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_44%),linear-gradient(180deg,rgba(255,251,235,0.96),rgba(255,247,237,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.16),transparent_44%),linear-gradient(180deg,rgba(35,26,17,0.96),rgba(28,22,17,0.92))]'; - case 'emerald': - return 'border-emerald-500/20 bg-[radial-gradient(circle_at_top_left,rgba(16,185,129,0.14),transparent_46%),linear-gradient(180deg,rgba(236,253,245,0.96),rgba(240,253,250,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(16,185,129,0.18),transparent_46%),linear-gradient(180deg,rgba(16,32,28,0.96),rgba(15,28,25,0.92))]'; - case 'cyan': - return 'border-cyan-500/20 bg-[radial-gradient(circle_at_top_left,rgba(6,182,212,0.14),transparent_44%),linear-gradient(180deg,rgba(236,254,255,0.96),rgba(240,249,255,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.16),transparent_44%),linear-gradient(180deg,rgba(13,28,34,0.96),rgba(15,25,32,0.92))]'; - case 'slate': - return 'border-slate-400/20 bg-[radial-gradient(circle_at_top_right,rgba(148,163,184,0.14),transparent_44%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.96))] dark:bg-[radial-gradient(circle_at_top_right,rgba(148,163,184,0.16),transparent_44%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(17,24,39,0.96))]'; - } -} - -function getSectionRuleClass(tone: SectionTone): string { - switch (tone) { - case 'sky': - return 'from-sky-500/60 via-sky-500/15'; - case 'amber': - return 'from-amber-500/60 via-amber-500/15'; - case 'emerald': - return 'from-emerald-500/60 via-emerald-500/15'; - case 'cyan': - return 'from-cyan-500/60 via-cyan-500/15'; - case 'slate': - return 'from-slate-400/55 via-slate-400/15'; - } -} - -function getSectionIconClass(tone: SectionTone): string { - switch (tone) { - case 'sky': - return 'border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300'; - case 'amber': - return 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300'; - case 'emerald': - return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; - case 'cyan': - return 'border-cyan-500/20 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300'; - case 'slate': - return 'border-slate-400/25 bg-slate-500/10 text-slate-700 dark:text-slate-300'; - } -} - -function getSectionEyebrowClass(tone: SectionTone): string { - switch (tone) { - case 'sky': - return 'text-sky-700/80 dark:text-sky-300/80'; - case 'amber': - return 'text-amber-700/80 dark:text-amber-300/80'; - case 'emerald': - return 'text-emerald-700/80 dark:text-emerald-300/80'; - case 'cyan': - return 'text-cyan-700/80 dark:text-cyan-300/80'; - case 'slate': - return 'text-slate-600/80 dark:text-slate-300/80'; - } -} - -function getInsetPanelClass(tone: SectionTone): string { - switch (tone) { - case 'sky': - return 'border-sky-500/15 bg-background/75'; - case 'amber': - return 'border-amber-500/15 bg-background/80'; - case 'emerald': - return 'border-emerald-500/15 bg-background/78'; - case 'cyan': - return 'border-cyan-500/15 bg-background/78'; - case 'slate': - return 'border-slate-400/15 bg-background/78'; - } +function getInsetPanelClass(_tone?: SectionTone): string { + return 'border-border/50 bg-background/40'; } function getBackendRowClass(state: ImageBackend['state'] | undefined): string { @@ -381,8 +310,8 @@ function normalizeTimeoutDraft(rawValue: string, fallbackValue: string): string } interface ImageSectionPanelProps { - tone: SectionTone; - eyebrow: string; + tone?: SectionTone; + eyebrow?: string; title: string; description: string; icon: ReactNode; @@ -393,8 +322,6 @@ interface ImageSectionPanelProps { } function ImageSectionPanel({ - tone, - eyebrow, title, description, icon, @@ -404,55 +331,32 @@ function ImageSectionPanel({ className, }: ImageSectionPanelProps) { return ( -
-
-
- -
-
-
-
+
+
+
+
+
{icon}
-
-
- {eyebrow} -
-
-

{title}

+
+
+

{title}

{meta}
-

{description}

+

{description}

+ {action &&
{action}
}
- - {action &&
{action}
} +
{children}
- -
{children}
-
+ ); } @@ -758,86 +662,89 @@ export default function ImageAnalysisSection() { } return ( - -
-
-
-
+
+
+ {error && ( + + + {error} + + )} + {success && ( +
+ + {success} +
+ )} +
-
- {(error || success) && ( -
- {error && ( - - - {error} - - )} - {success && ( -
- - {success} -
- )} -
- )} - -
-
-
-
-
-
- -
-
-
- Image control plane + +
+
+
+
+
+
+
+
+
+ +
+

Image

-
-

Image

+
{data.summary.title} + {summaryCompactDetail(data.summary)}
-

- {summaryCompactDetail(data.summary)} -

+
- -
-
-
-
- Active routes +
+
+
+ Active routes +
+
+ {data.summary.activeProfileCount} +
+

Current target path

-
- {data.summary.activeProfileCount} +
+
+ Native path +
+
+ {data.summary.nativeProfileCount} +
+

Skip transformer

-

Current target path

-
-
-
- Native path -
-
- {data.summary.nativeProfileCount} -
-

Skip transformer

-
+
-
- + +
); } diff --git a/ui/tests/unit/ui/pages/image-analysis-section.test.tsx b/ui/tests/unit/ui/pages/image-analysis-section.test.tsx index 92854150..d09dfa73 100644 --- a/ui/tests/unit/ui/pages/image-analysis-section.test.tsx +++ b/ui/tests/unit/ui/pages/image-analysis-section.test.tsx @@ -276,11 +276,18 @@ describe('ImageAnalysisSection', () => { }); it('auto-saves edits without rendering a dedicated save button', async () => { - render(, { withSettingsProvider: true }); + const { container } = render(, { withSettingsProvider: true }); expect(await screen.findByText('Image')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument(); + expect(container.firstElementChild).toHaveClass( + 'relative', + 'flex', + 'min-h-0', + 'flex-1', + 'flex-col' + ); const timeoutInput = screen.getByDisplayValue('60'); await userEvent.clear(timeoutInput);