diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index 680c58b70..26ac03726 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { memo, useState } from "react"; import type { DashboardSession, AttentionLevel } from "@/lib/types"; import { SessionCard } from "./SessionCard"; @@ -54,7 +54,7 @@ const zoneConfig: Record< }, }; -export function AttentionZone({ +function AttentionZoneView({ level, sessions, variant = "grid", @@ -76,10 +76,7 @@ export function AttentionZone({ className="mb-2.5 flex items-center gap-2 py-0.5 text-left" onClick={() => setCollapsed(!collapsed)} > -
+
{config.label} @@ -128,10 +125,7 @@ export function AttentionZone({ onClick={() => setCollapsed(!collapsed)} > {/* Semantic dot — only zone-colored element */} -
+
{/* Label — neutral, not zone-colored */} {config.label} @@ -172,3 +166,18 @@ export function AttentionZone({
); } + +function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZoneProps): boolean { + return ( + prev.level === next.level && + prev.variant === next.variant && + prev.onSend === next.onSend && + prev.onKill === next.onKill && + prev.onMerge === next.onMerge && + prev.onRestore === next.onRestore && + prev.sessions.length === next.sessions.length && + prev.sessions.every((session, index) => session === next.sessions[index]) + ); +} + +export const AttentionZone = memo(AttentionZoneView, areAttentionZonePropsEqual); diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index f3c740b42..80cd66835 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { type DashboardSession, type DashboardStats, @@ -86,6 +86,19 @@ export function Dashboard({ return zones; }, [sessions]); + const sessionsByProject = useMemo(() => { + const groupedSessions = new Map(); + for (const session of sessions) { + const projectSessions = groupedSessions.get(session.projectId); + if (projectSessions) { + projectSessions.push(session); + continue; + } + groupedSessions.set(session.projectId, [session]); + } + return groupedSessions; + }, [sessions]); + const openPRs = useMemo(() => { return sessions .filter( @@ -100,7 +113,7 @@ export function Dashboard({ if (!allProjectsView) return []; return projects.map((project) => { - const projectSessions = sessions.filter((session) => session.projectId === project.id); + const projectSessions = sessionsByProject.get(project.id) ?? []; const counts: Record = { merge: 0, respond: 0, @@ -123,9 +136,9 @@ export function Dashboard({ counts, }; }); - }, [activeOrchestrators, allProjectsView, projects, sessions]); + }, [activeOrchestrators, allProjectsView, projects, sessionsByProject]); - const handleSend = async (sessionId: string, message: string) => { + const handleSend = useCallback(async (sessionId: string, message: string) => { const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -134,9 +147,9 @@ export function Dashboard({ if (!res.ok) { console.error(`Failed to send message to ${sessionId}:`, await res.text()); } - }; + }, []); - const handleKill = async (sessionId: string) => { + const handleKill = useCallback(async (sessionId: string) => { if (!confirm(`Kill session ${sessionId}?`)) return; const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/kill`, { method: "POST", @@ -144,16 +157,16 @@ export function Dashboard({ if (!res.ok) { console.error(`Failed to kill ${sessionId}:`, await res.text()); } - }; + }, []); - const handleMerge = async (prNumber: number) => { + const handleMerge = useCallback(async (prNumber: number) => { const res = await fetch(`/api/prs/${prNumber}/merge`, { method: "POST" }); if (!res.ok) { console.error(`Failed to merge PR #${prNumber}:`, await res.text()); } - }; + }, []); - const handleRestore = async (sessionId: string) => { + const handleRestore = useCallback(async (sessionId: string) => { if (!confirm(`Restore session ${sessionId}?`)) return; const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/restore`, { method: "POST", @@ -161,7 +174,7 @@ export function Dashboard({ if (!res.ok) { console.error(`Failed to restore ${sessionId}:`, await res.text()); } - }; + }, []); const handleSpawnOrchestrator = async (project: ProjectInfo) => { setSpawningProjectIds((current) => diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index 6c8d17e9e..08d4511d6 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { memo, useState, useEffect, useRef } from "react"; import { type DashboardSession, type AttentionLevel, @@ -25,15 +25,15 @@ interface SessionCardProps { } const borderColorByLevel: Record = { - merge: "border-l-[var(--color-status-ready)]", + merge: "border-l-[var(--color-status-ready)]", respond: "border-l-[var(--color-status-error)]", - review: "border-l-[var(--color-accent-orange)]", + review: "border-l-[var(--color-accent-orange)]", pending: "border-l-[var(--color-status-attention)]", working: "border-l-[var(--color-status-working)]", - done: "border-l-[var(--color-border-default)]", + done: "border-l-[var(--color-border-default)]", }; -export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) { +function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) { const [expanded, setExpanded] = useState(false); const [sendingAction, setSendingAction] = useState(null); const timerRef = useRef | null>(null); @@ -41,7 +41,9 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses const pr = session.pr; useEffect(() => { - return () => { if (timerRef.current) clearTimeout(timerRef.current); }; + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; }, []); const handleAction = async (action: string, message: string) => { @@ -75,9 +77,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses )} style={{ borderRadius: 7, - background: (expanded && !isReadyToMerge) - ? "linear-gradient(175deg, rgba(32,41,53,1) 0%, rgba(22,28,37,1) 100%)" - : undefined, + background: + expanded && !isReadyToMerge + ? "linear-gradient(175deg, rgba(32,41,53,1) 0%, rgba(22,28,37,1) 100%)" + : undefined, }} onClick={(e) => { if ((e.target as HTMLElement).closest("a, button, textarea")) return; @@ -93,7 +96,10 @@ export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: Ses
{isRestorable && (