nexus/ui/src/components/CompanyRail.tsx
Nexus Dev 3a41ec7b9c feat(nexus): design system phase 3 raw utility sweep
Third phase of the DESIGN.md migration. Removes every raw Tailwind
color palette utility (bg-red-*, text-amber-*, border-blue-*, etc.)
from component source and replaces them with the semantic tokens
introduced in phases 1 and 2.

Scope:
  - 84 files touched under ui/src/
  - ~420 raw palette utility instances replaced
  - 23 hardcoded hex fallbacks replaced with var(--token) refs
  - Zero raw palette utilities remain in component source
    (verified with rg '(bg|text|border|ring)-(red|blue|green|amber|
    yellow|cyan|violet|purple|pink|slate|zinc|neutral|sky|teal|
    emerald|indigo|rose|orange|fuchsia)-[0-9]+' ui/src)

Mapping rules applied:
  - red-* -> destructive
  - amber-/yellow-/orange-* -> warning
  - green-/emerald-* -> success
  - blue-/cyan-/sky-* -> primary (info/in-progress) or muted-foreground
  - slate-/gray-/zinc-/neutral-* -> muted / muted-foreground / border
  - violet-/purple-/pink-/indigo-/rose-/teal-* -> collapsed to
    primary or muted (most were one-off decorative choices, not
    role-bearing). Role-bearing uses go through lib/agent-role-colors
    which was rewritten in phase 2.
  - Opacity modifiers preserved (/10, /15, /20, etc.)
  - dark: variant duplicates removed (theme tokens auto-switch)

Hardcoded hex fallbacks fixed:
  - #6366f1 (indigo) -> var(--primary) / var(--volt)
  - #64748b (slate) -> var(--muted-foreground) / var(--silver)
  - #4f46e5 (indigo) -> var(--primary)
  - #89b4fa (old Catppuccin blue) -> var(--primary) / #faff69
  - OrgChart status dots (#22d3ee/#4ade80/#facc15/#f87171/#a3a3a3)
    -> var(--primary) / var(--success) / var(--warning) /
    var(--destructive) / var(--muted-foreground) per status
  - VoiceWaveform fallback #89b4fa -> #faff69 (volt)

Legitimate hex values left untouched (12 total):
  - lib/color-contrast.ts WCAG reference constants
  - lib/worktree-branding.ts contrast fallback references
  - lib/mention-chips.ts runtime-generated SVG fills
  - context/ThemeContext.tsx theme metadata brand hexes
  - components/ThemeSeedInput.tsx user-facing hex picker

Ambiguous decisions (flagged for visual QA):
  - AgentDetail.tsx invocation-source badges (timer/assignment/
    on_demand) collapsed to primary/muted — visual distinction
    is reduced, labels still differ. Consider chart-role slots
    if differentiation matters.
  - AgentDetail.tsx mixed-opacity amber banners: bg-warning/60
    against new warning base reads heavier than original amber-50
    base.
  - Live-state dots in KanbanBoard/AgentDetail: bg-blue-* ->
    bg-primary — will glow volt in dark mode, probably desirable.

Verification:
  - npx tsc --noEmit in ui/ — zero errors introduced. Pre-existing
    errors in AgentConfigForm, command.tsx, useKeyboardShortcuts,
    usePiperTts, useVadRecorder, PersonalAssistant remain, all
    unrelated to color work.
  - Dev server on :6100 returns 200.

Not changed in this commit:
  - ui/src/lib/company-routes.ts — separate routing fix for broken
    Assistant/ContentStudio/Convert links, committed next.
  - Test files — a few will need assertion updates but are out of
    phase 3 scope.

Phase 4 follow-ups (rounded-xl/2xl collapse, soft shadow removal,
gradient removal) noted in .planning/AUDIT-RADIUS-SHADOWS.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:40:32 +00:00

329 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Plus } from "lucide-react";
import { useQueries } from "@tanstack/react-query";
import {
DndContext,
closestCenter,
MouseSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { useLocation, useNavigate } from "@/lib/router";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { Company } from "@paperclipai/shared";
import { CompanyPatternIcon } from "./CompanyPatternIcon";
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
function getStoredOrder(): string[] {
try {
const raw = localStorage.getItem(ORDER_STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
return [];
}
function saveOrder(ids: string[]) {
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids));
}
/** Sort companies by stored order, appending any new ones at the end. */
function sortByStoredOrder(companies: Company[]): Company[] {
const order = getStoredOrder();
if (order.length === 0) return companies;
const byId = new Map(companies.map((c) => [c.id, c]));
const sorted: Company[] = [];
for (const id of order) {
const c = byId.get(id);
if (c) {
sorted.push(c);
byId.delete(id);
}
}
// Append any companies not in stored order
for (const c of byId.values()) {
sorted.push(c);
}
return sorted;
}
function SortableCompanyItem({
company,
isSelected,
hasLiveAgents,
hasUnreadInbox,
onSelect,
}: {
company: Company;
isSelected: boolean;
hasLiveAgents: boolean;
hasUnreadInbox: boolean;
onSelect: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: company.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.8 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="overflow-visible">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<a
href={`/${company.issuePrefix}/dashboard`}
onClick={(e) => {
e.preventDefault();
onSelect();
}}
className="relative flex items-center justify-center group overflow-visible"
>
{/* Selection indicator pill */}
<div
className={cn(
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-[height] duration-150",
isSelected
? "h-5"
: "h-0 group-hover:h-2"
)}
/>
<div
className={cn("relative overflow-visible transition-transform duration-150", isDragging && "scale-105")}
>
<CompanyPatternIcon
companyName={company.name}
logoUrl={company.logoUrl}
brandColor={company.brandColor}
className={cn(
isSelected
? "rounded-[14px]"
: "rounded-[22px] group-hover:rounded-[14px]",
isDragging && "shadow-lg",
)}
/>
{hasLiveAgents && (
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-primary opacity-80" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-background" />
</span>
</span>
)}
{hasUnreadInbox && (
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-background" />
)}
</div>
</a>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{company.name}</p>
</TooltipContent>
</Tooltip>
</div>
);
}
export function CompanyRail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openOnboarding } = useDialog();
const navigate = useNavigate();
const location = useLocation();
const isInstanceRoute = location.pathname.startsWith("/instance/");
const highlightedCompanyId = isInstanceRoute ? null : selectedCompanyId;
const sidebarCompanies = useMemo(
() => companies.filter((company) => company.status !== "archived"),
[companies],
);
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
const liveRunsQueries = useQueries({
queries: companyIds.map((companyId) => ({
queryKey: queryKeys.liveRuns(companyId),
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
refetchInterval: 10_000,
})),
});
const sidebarBadgeQueries = useQueries({
queries: companyIds.map((companyId) => ({
queryKey: queryKeys.sidebarBadges(companyId),
queryFn: () => sidebarBadgesApi.get(companyId),
refetchInterval: 15_000,
})),
});
const hasLiveAgentsByCompanyId = useMemo(() => {
const result = new Map<string, boolean>();
companyIds.forEach((companyId, index) => {
result.set(companyId, (liveRunsQueries[index]?.data?.length ?? 0) > 0);
});
return result;
}, [companyIds, liveRunsQueries]);
const hasUnreadInboxByCompanyId = useMemo(() => {
const result = new Map<string, boolean>();
companyIds.forEach((companyId, index) => {
result.set(companyId, (sidebarBadgeQueries[index]?.data?.inbox ?? 0) > 0);
});
return result;
}, [companyIds, sidebarBadgeQueries]);
// Maintain sorted order in local state, synced from companies + localStorage
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
sortByStoredOrder(sidebarCompanies).map((c) => c.id)
);
// Re-sync orderedIds from localStorage whenever companies changes.
// Handles initial data load (companies starts as [] before query resolves)
// and subsequent refetches triggered by live updates.
useEffect(() => {
if (sidebarCompanies.length === 0) {
setOrderedIds([]);
return;
}
setOrderedIds(sortByStoredOrder(sidebarCompanies).map((c) => c.id));
}, [sidebarCompanies]);
// Sync order across tabs via the native storage event
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key !== ORDER_STORAGE_KEY) return;
try {
const ids: string[] = e.newValue ? JSON.parse(e.newValue) : [];
setOrderedIds(ids);
} catch { /* ignore malformed data */ }
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
// Re-derive when companies change (new company added/removed)
const orderedCompanies = useMemo(() => {
const byId = new Map(sidebarCompanies.map((c) => [c.id, c]));
const result: Company[] = [];
for (const id of orderedIds) {
const c = byId.get(id);
if (c) {
result.push(c);
byId.delete(id);
}
}
// Append any new companies not yet in our order
for (const c of byId.values()) {
result.push(c);
}
return result;
}, [sidebarCompanies, orderedIds]);
// Require 8px of movement before starting a drag to avoid interfering with clicks
const sensors = useSensors(
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = orderedCompanies.map((c) => c.id);
const oldIndex = ids.indexOf(active.id as string);
const newIndex = ids.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const newIds = arrayMove(ids, oldIndex, newIndex);
setOrderedIds(newIds);
saveOrder(newIds);
},
[orderedCompanies]
);
return (
<div className="flex flex-col items-center w-[72px] shrink-0 h-full bg-background border-r border-border">
{/* Nexus icon */}
<div className="flex items-center justify-center h-12 w-full shrink-0">
<Box className="h-5 w-5 text-foreground" />
</div>
{/* Company list */}
<div className="flex-1 flex flex-col items-center gap-2 py-3 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedCompanies.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{orderedCompanies.map((company) => (
<SortableCompanyItem
key={company.id}
company={company}
isSelected={company.id === highlightedCompanyId}
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
hasUnreadInbox={hasUnreadInboxByCompanyId.get(company.id) ?? false}
onSelect={() => {
setSelectedCompanyId(company.id);
if (isInstanceRoute) {
navigate(`/${company.issuePrefix}/dashboard`);
}
}}
/>
))}
</SortableContext>
</DndContext>
</div>
{/* Separator before add button */}
<div className="w-8 h-px bg-border mx-auto shrink-0" />
{/* Add company button */}
<div className="flex items-center justify-center py-2 shrink-0">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
onClick={() => openOnboarding()}
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-[14px] border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-[border-color,color,border-radius] duration-150"
aria-label="Add company"
>
<Plus className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>Add company</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
}