diff --git a/web/src/App.tsx b/web/src/App.tsx index 3966bd8..7ef6f90 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,26 +1,27 @@ -import { useEffect } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { navigate, useHashRoute } from './useHashRoute'; import { SectionLabel } from './ui'; -import { ControlView } from './views/ControlView'; -import { TriageView } from './views/TriageView'; -import { DetailView } from './views/DetailView'; -import { ReportsView } from './views/ReportsView'; -import { MarketsView } from './views/MarketsView'; -import { QueueView } from './views/QueueView'; -import { PastebinView } from './views/PastebinView'; -import { CampaignsView } from './views/CampaignsView'; -import { IntroThreadView } from './views/IntroThreadView'; -import { HostsView } from './views/HostsView'; -import { SettingsView } from './views/SettingsView'; -import { CalendarView } from './views/CalendarView'; -import { ProspectsView } from './views/ProspectsView'; -import { StreamView } from './views/StreamView'; -import { DashboardView } from './views/DashboardView'; -import { BackfillView } from './views/BackfillView'; -import { VoiceView } from './views/VoiceView'; -import { AutopilotView } from './views/AutopilotView'; -import { ModelView } from './views/ModelView'; + +const ControlView = lazy(() => import('./views/ControlView').then((m) => ({ default: m.ControlView }))); +const TriageView = lazy(() => import('./views/TriageView').then((m) => ({ default: m.TriageView }))); +const DetailView = lazy(() => import('./views/DetailView').then((m) => ({ default: m.DetailView }))); +const ReportsView = lazy(() => import('./views/ReportsView').then((m) => ({ default: m.ReportsView }))); +const MarketsView = lazy(() => import('./views/MarketsView').then((m) => ({ default: m.MarketsView }))); +const QueueView = lazy(() => import('./views/QueueView').then((m) => ({ default: m.QueueView }))); +const PastebinView = lazy(() => import('./views/PastebinView').then((m) => ({ default: m.PastebinView }))); +const CampaignsView = lazy(() => import('./views/CampaignsView').then((m) => ({ default: m.CampaignsView }))); +const IntroThreadView = lazy(() => import('./views/IntroThreadView').then((m) => ({ default: m.IntroThreadView }))); +const HostsView = lazy(() => import('./views/HostsView').then((m) => ({ default: m.HostsView }))); +const SettingsView = lazy(() => import('./views/SettingsView').then((m) => ({ default: m.SettingsView }))); +const CalendarView = lazy(() => import('./views/CalendarView').then((m) => ({ default: m.CalendarView }))); +const ProspectsView = lazy(() => import('./views/ProspectsView').then((m) => ({ default: m.ProspectsView }))); +const StreamView = lazy(() => import('./views/StreamView').then((m) => ({ default: m.StreamView }))); +const DashboardView = lazy(() => import('./views/DashboardView').then((m) => ({ default: m.DashboardView }))); +const BackfillView = lazy(() => import('./views/BackfillView').then((m) => ({ default: m.BackfillView }))); +const VoiceView = lazy(() => import('./views/VoiceView').then((m) => ({ default: m.VoiceView }))); +const AutopilotView = lazy(() => import('./views/AutopilotView').then((m) => ({ default: m.AutopilotView }))); +const ModelView = lazy(() => import('./views/ModelView').then((m) => ({ default: m.ModelView }))); interface NavItem { readonly view: string; @@ -151,49 +152,55 @@ export function App(): JSX.Element { } function ViewRouter({ view, param }: { view: string; param: string | null }): JSX.Element { - switch (view) { - // ── built ── - case 'triage': - return ; - case 'prospect': - return param ? : ; - case 'intro': - return param ? : ; - case 'queue': - return ; - case 'campaigns': - return ; - case 'reports': - return ; - case 'markets': - return ; - case 'pastebin': - return ; - case 'services': - case 'hosts': - return ; - case 'control': - return ; - // ── planned (P-1 shell parity; built in Waves A/B) ── - case 'dashboard': - return ; - case 'prospects': - return ; - case 'stream': - return ; - case 'backfill': - return ; - case 'calendar': - return ; - case 'model': - return ; - case 'voice': - return ; - case 'settings': - return ; - case 'autopilot': - return ; - default: - return ; - } + return ( + Loading view…}> + {(() => { + switch (view) { + // ── built ── + case 'triage': + return ; + case 'prospect': + return param ? : ; + case 'intro': + return param ? : ; + case 'queue': + return ; + case 'campaigns': + return ; + case 'reports': + return ; + case 'markets': + return ; + case 'pastebin': + return ; + case 'services': + case 'hosts': + return ; + case 'control': + return ; + // ── planned (P-1 shell parity; built in Waves A/B) ── + case 'dashboard': + return ; + case 'prospects': + return ; + case 'stream': + return ; + case 'backfill': + return ; + case 'calendar': + return ; + case 'model': + return ; + case 'voice': + return ; + case 'settings': + return ; + case 'autopilot': + return ; + default: + return ; + } + })()} + + ); } diff --git a/web/src/components/ActivityFeed.tsx b/web/src/components/ActivityFeed.tsx index 715a834..898a834 100644 --- a/web/src/components/ActivityFeed.tsx +++ b/web/src/components/ActivityFeed.tsx @@ -1,7 +1,9 @@ +import { memo } from 'react'; + import { getActivity, type ActivityItem } from '../api'; import { formatTime, usePoll } from '../usePoll'; -function Row({ item }: { item: ActivityItem }): JSX.Element { +const Row = memo(function Row({ item }: { item: ActivityItem }): JSX.Element { return (
{formatTime(item.createdAt)} @@ -11,7 +13,7 @@ function Row({ item }: { item: ActivityItem }): JSX.Element { {item.holdReason ? {item.holdReason} : null}
); -} +}); export function ActivityFeed(): JSX.Element { const { data, error, loading } = usePoll(() => getActivity(50), 15_000); diff --git a/web/src/components/HeldQueue.tsx b/web/src/components/HeldQueue.tsx index 88e36ee..88ce313 100644 --- a/web/src/components/HeldQueue.tsx +++ b/web/src/components/HeldQueue.tsx @@ -1,7 +1,9 @@ +import { memo } from 'react'; + import { getHeldQueue, type ActivityItem } from '../api'; import { formatTime, usePoll } from '../usePoll'; -function HeldRow({ item }: { item: ActivityItem }): JSX.Element { +const HeldRow = memo(function HeldRow({ item }: { item: ActivityItem }): JSX.Element { return (
@@ -14,7 +16,7 @@ function HeldRow({ item }: { item: ActivityItem }): JSX.Element {
); -} +}); export function HeldQueue(): JSX.Element { const { data, error, loading } = usePoll(() => getHeldQueue(50), 30_000); diff --git a/web/src/views/CalendarView.tsx b/web/src/views/CalendarView.tsx index 5aa1df2..2b83684 100644 --- a/web/src/views/CalendarView.tsx +++ b/web/src/views/CalendarView.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; + import { usePoll } from '../usePoll'; import { getBookings, type BookingItem } from '../api'; import { navigate } from '../useHashRoute'; @@ -13,10 +15,14 @@ export function CalendarView(): JSX.Element { if (loading || !data) return Loading bookings…; // Group by day (already grouped in backend sample, but client can too) - const groups = data.items.reduce>((acc, b) => { - (acc[b.day] ||= []).push(b); - return acc; - }, {}); + const groups = useMemo( + () => + data.items.reduce>((acc, b) => { + (acc[b.day] ||= []).push(b); + return acc; + }, {}), + [data.items], + ); return ( diff --git a/web/src/views/DashboardView.tsx b/web/src/views/DashboardView.tsx index 4afec9d..4dd24e2 100644 --- a/web/src/views/DashboardView.tsx +++ b/web/src/views/DashboardView.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Button, @@ -72,22 +72,30 @@ export function DashboardView(): JSX.Element { const { kpis, density, insights, actions } = data; - const densityRows: readonly { key: string; count: number }[] = density.map((d) => ({ - key: `${d.hour}h`, - count: d.count, - })); + const densityRows: readonly { key: string; count: number }[] = useMemo( + () => density.map((d) => ({ key: `${d.hour}h`, count: d.count })), + [density], + ); - const kpiEntries = Object.entries(kpis); - const volumeRows: readonly { key: string; count: number }[] = kpiEntries - .filter(([, v]) => typeof v === 'number') - .map(([k, v]) => ({ - key: k.replace(/7d|InRange/gi, '').trim() || k, - count: Number(v), - })); + const kpiEntries = useMemo(() => Object.entries(kpis), [kpis]); + const volumeRows: readonly { key: string; count: number }[] = useMemo( + () => + kpiEntries + .filter(([, v]) => typeof v === 'number') + .map(([k, v]) => ({ + key: k.replace(/7d|InRange/gi, '').trim() || k, + count: Number(v), + })), + [kpiEntries], + ); - const peak = density.length - ? density.reduce((a, b) => (b.count > a.count ? b : a)) - : null; + const peak = useMemo( + () => + density.length + ? density.reduce((a, b) => (b.count > a.count ? b : a)) + : null, + [density], + ); return ( diff --git a/web/src/views/HostsView.tsx b/web/src/views/HostsView.tsx index 1a31a2f..3ec838e 100644 --- a/web/src/views/HostsView.tsx +++ b/web/src/views/HostsView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { getGpuStatus, @@ -333,7 +333,7 @@ export function HostsView(): JSX.Element { ); } -function FleetRow({ label, ip, highlight }: { label: string; ip: string; highlight?: boolean }): JSX.Element { +const FleetRow = memo(function FleetRow({ label, ip, highlight }: { label: string; ip: string; highlight?: boolean }): JSX.Element { return (
); -} +}); function CodeSnippet({ label, code }: { label: string; code: string }): JSX.Element { const [copied, setCopied] = useState(false); diff --git a/web/src/views/QueueView.tsx b/web/src/views/QueueView.tsx index 043c151..90710e5 100644 --- a/web/src/views/QueueView.tsx +++ b/web/src/views/QueueView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { abortTask, @@ -82,9 +82,13 @@ export function QueueView(): JSX.Element { // Handle search is client-side over the polled items (type/priority hit the API). const needle = search.trim().toLowerCase(); - const visible = needle - ? tasks.filter((t) => `${t.handle ?? ''} ${t.prospectName ?? ''}`.toLowerCase().includes(needle)) - : tasks; + const visible = useMemo( + () => + needle + ? tasks.filter((t) => `${t.handle ?? ''} ${t.prospectName ?? ''}`.toLowerCase().includes(needle)) + : tasks, + [needle, tasks], + ); const toggle = (id: string): void => setSelected((prev) => { @@ -141,29 +145,29 @@ export function QueueView(): JSX.Element { }; // ─── Row actions ──────────────────────────────────────────────────────────── - const onRun = (t: ProspectorTask): void => { + const onRun = useCallback((t: ProspectorTask): void => { if (!window.confirm(`Run ${t.id} now? A send-type task may dispatch a message.`)) return; void guard(() => runTask(t.id)); - }; - const onEscalate = (t: ProspectorTask): void => { + }, [guard]); + const onEscalate = useCallback((t: ProspectorTask): void => { if (!window.confirm(`Escalate ${t.id} to Quinn (human owner)?`)) return; void guard(() => escalateTask(t.id)); - }; - const onCancel = (t: ProspectorTask): void => { + }, [guard]); + const onCancel = useCallback((t: ProspectorTask): void => { if (!window.confirm(`Cancel ${t.id}? It is removed from the runner queue.`)) return; void guard(() => cancelTask(t.id)); - }; - const onAbort = (t: ProspectorTask): void => { + }, [guard]); + const onAbort = useCallback((t: ProspectorTask): void => { if (!window.confirm(`Abort ${t.id} mid-run?`)) return; void guard(() => abortTask(t.id)); - }; - const onRequeue = (t: ProspectorTask): void => { + }, [guard]); + const onRequeue = useCallback((t: ProspectorTask): void => { void guard(() => requeueTask(t.id)); - }; - const onViewProspect = (handle: string): void => { + }, [guard]); + const onViewProspect = useCallback((handle: string): void => { setDetailTask(null); navigate('prospect', handle); - }; + }, []); return (
diff --git a/web/src/views/ReportsView.tsx b/web/src/views/ReportsView.tsx index 6e892a3..7bac519 100644 --- a/web/src/views/ReportsView.tsx +++ b/web/src/views/ReportsView.tsx @@ -1,4 +1,4 @@ -import { useState, type CSSProperties, type ReactNode } from 'react'; +import { memo, useMemo, useState, type CSSProperties, type ReactNode } from 'react'; import { getProviderGraph, @@ -37,10 +37,14 @@ function ProviderGraphSection(): JSX.Element { const min = minMr ? Number(minMr) : 0; const text = q.trim().toLowerCase(); const nodes: GraphNode[] = data?.nodes ?? []; - const rows = nodes.filter( - (n) => - (!text || n.handle.toLowerCase().includes(text)) && - (!min || (n.compositeMrScore ?? 0) >= min), + const rows = useMemo( + () => + nodes.filter( + (n) => + (!text || n.handle.toLowerCase().includes(text)) && + (!min || (n.compositeMrScore ?? 0) >= min), + ), + [nodes, text, min], ); return ( @@ -85,7 +89,7 @@ function WarmIntrosSection(): JSX.Element { const [statusFilter, setStatusFilter] = useState(''); const items: IntroThread[] = data?.items ?? []; - const rows = items.filter((i) => !statusFilter || i.status === statusFilter); + const rows = useMemo(() => items.filter((i) => !statusFilter || i.status === statusFilter), [items, statusFilter]); return (
@@ -171,8 +175,11 @@ function MarketplaceSection(): JSX.Element { const text = q.trim().toLowerCase(); const items: MarketplaceRouting[] = data?.items ?? []; - const rows = items.filter( - (r) => (!text || r.prospectHandle.toLowerCase().includes(text)) && (!status || r.status === status), + const rows = useMemo( + () => items.filter( + (r) => (!text || r.prospectHandle.toLowerCase().includes(text)) && (!status || r.status === status), + ), + [items, text, status], ); return ( @@ -250,13 +257,13 @@ function Td({ children }: { children: ReactNode }): JSX.Element { return {children}; } -function EmptyRow({ span, text }: { span: number; text: string }): JSX.Element { +const EmptyRow = memo(function EmptyRow({ span, text }: { span: number; text: string }): JSX.Element { return ( {text} ); -} +}); const STATUS_PILL: Record = { proposed: 'pill--hold', @@ -270,18 +277,18 @@ const STATUS_PILL: Record = { rejected: 'pill--bad', }; -function StatusBadge({ status }: { status: string }): JSX.Element { +const StatusBadge = memo(function StatusBadge({ status }: { status: string }): JSX.Element { return {status}; -} +}); -function Stage({ label, value, accent }: { label: string; value: number; accent?: boolean }): JSX.Element { +const Stage = memo(function Stage({ label, value, accent }: { label: string; value: number; accent?: boolean }): JSX.Element { return (
{value}
{label}
); -} +}); function VolumeChart({ points }: { points: readonly VolumePoint[] }): JSX.Element { if (points.length === 0) return
No activity in range.
; @@ -302,7 +309,7 @@ function VolumeChart({ points }: { points: readonly VolumePoint[] }): JSX.Elemen ); } -function ClassificationBars({ rows, bilingual }: { rows: readonly CountRow[]; bilingual: boolean }): JSX.Element { +const ClassificationBars = memo(function ClassificationBars({ rows, bilingual }: { rows: readonly CountRow[]; bilingual: boolean }): JSX.Element { if (rows.length === 0) return
No classifications in range.
; const max = Math.max(1, ...rows.map((r) => r.count)); return ( @@ -318,4 +325,4 @@ function ClassificationBars({ rows, bilingual }: { rows: readonly CountRow[]; bi ))}
); -} +}); diff --git a/web/src/views/StreamView.tsx b/web/src/views/StreamView.tsx index 74f678c..7426c3f 100644 --- a/web/src/views/StreamView.tsx +++ b/web/src/views/StreamView.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { getStream, type StreamEvent } from '../api'; import { navigate } from '../useHashRoute'; @@ -57,7 +57,7 @@ function getDirGlyphAndColor(e: StreamEvent): { glyph: string; color: string } { }; } -function EventRow({ event }: { event: StreamEvent }): JSX.Element { +const EventRow = memo(function EventRow({ event }: { event: StreamEvent }): JSX.Element { const { glyph, color: dirColor } = getDirGlyphAndColor(event); const badge = getKindBadge(event); const tone = getPillTone(event); @@ -133,7 +133,7 @@ function EventRow({ event }: { event: StreamEvent }): JSX.Element { ); -} +}); export function StreamView(): JSX.Element { const { data, error, loading } = usePoll(() => getStream(100), 20000); @@ -143,11 +143,11 @@ export function StreamView(): JSX.Element { const items: StreamEvent[] = data?.items ?? []; - const filtered = items.filter((e) => { + const filtered = useMemo(() => items.filter((e) => { const kindOk = streamKind === 'all' || e.kind === streamKind; const dirOk = streamDir === 'all' || e.dir === streamDir; return kindOk && dirOk; - }); + }), [items, streamKind, streamDir]); const showLoading = loading && items.length === 0; const showError = !!error; diff --git a/web/src/views/queue/QueueTable.tsx b/web/src/views/queue/QueueTable.tsx index 06844e1..69544a6 100644 --- a/web/src/views/queue/QueueTable.tsx +++ b/web/src/views/queue/QueueTable.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from 'react'; +import { memo, type CSSProperties } from 'react'; import type { ProspectorTask, TaskPriority, TaskStatus, TaskType } from '../../api'; @@ -25,7 +25,7 @@ export interface QueueTableProps { * Empty states are real: the table head always renders and an in-table row * states the clear/idle condition rather than hiding the surface. */ -export function QueueTable(props: QueueTableProps): JSX.Element { +export const QueueTable = memo(function QueueTable(props: QueueTableProps): JSX.Element { const { tab, tasks } = props; const colCount = tab === 'queued' ? 8 : tab === 'processing' ? 6 : 6; @@ -145,7 +145,7 @@ export function QueueTable(props: QueueTableProps): JSX.Element { ); -} +}); // ─── Shared badges + time helpers (imported by TaskDetailModal too — DRY) ────── @@ -171,7 +171,7 @@ const STATUS_COLORS: Record = { aborted: { bg: 'rgba(248, 113, 113, 0.18)', fg: '#f87171' }, }; -export function Badge({ bg, fg, label }: { bg: string; fg: string; label: string }): JSX.Element { +export const Badge = memo(function Badge({ bg, fg, label }: { bg: string; fg: string; label: string }): JSX.Element { return ( ); -} +}); -export function TypeBadge({ type }: { type: TaskType }): JSX.Element { +export const TypeBadge = memo(function TypeBadge({ type }: { type: TaskType }): JSX.Element { const c = TYPE_COLORS[type]; return ; -} +}); -export function PriorityBadge({ priority }: { priority: TaskPriority }): JSX.Element { +export const PriorityBadge = memo(function PriorityBadge({ priority }: { priority: TaskPriority }): JSX.Element { const c = PRIORITY_COLORS[priority]; return ; -} +}); -export function StatusBadge({ status }: { status: TaskStatus }): JSX.Element { +export const StatusBadge = memo(function StatusBadge({ status }: { status: TaskStatus }): JSX.Element { const c = STATUS_COLORS[status]; return ; -} +}); export function shortId(id: string): string { return id.length > 8 ? `${id.slice(0, 8)}` : id; diff --git a/web/src/views/queue/RunnerLog.tsx b/web/src/views/queue/RunnerLog.tsx index 4429fa0..3f62882 100644 --- a/web/src/views/queue/RunnerLog.tsx +++ b/web/src/views/queue/RunnerLog.tsx @@ -1,7 +1,16 @@ -import { useEffect, useRef } from 'react'; +import { memo, useEffect, useRef } from 'react'; import type { TaskLogEntry } from '../../api'; +const LogRow = memo(function LogRow({ e }: { e: TaskLogEntry }): JSX.Element { + return ( +
+ {clock(e.createdAt)} + {e.message} +
+ ); +}); + /** * Terminal-style runner activity panel. Renders the auto-runner poll-loop log * (TaskLogEntry[]) newest-at-bottom and auto-scrolls to the tail on each update, @@ -25,10 +34,7 @@ export function RunnerLog({ entries }: { entries: TaskLogEntry[] }): JSX.Element
Runner idle — no recent activity.
) : ( entries.map((e) => ( -
- {clock(e.createdAt)} - {e.message} -
+ )) )}