nexus/ui/src/components/IssueProperties.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

620 lines
22 KiB
TypeScript

import { useMemo, useState } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link } from "@/lib/router";
import type { Issue } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
function defaultProjectWorkspaceIdForProject(project: {
workspaces?: Array<{ id: string; isPrimary: boolean }>;
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
} | null | undefined) {
if (!project) return null;
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
?? project.workspaces?.[0]?.id
?? null;
}
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode;
if (defaultMode === "adapter_default") return "agent_default";
return "shared_workspace";
}
interface IssuePropertiesProps {
issue: Issue;
onUpdate: (data: Record<string, unknown>) => void;
inline?: boolean;
}
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 py-1.5">
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
<div className="flex items-center gap-1.5 min-w-0 flex-1">{children}</div>
</div>
);
}
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
function PropertyPicker({
inline,
label,
open,
onOpenChange,
triggerContent,
triggerClassName,
popoverClassName,
popoverAlign = "end",
extra,
children,
}: {
inline?: boolean;
label: string;
open: boolean;
onOpenChange: (open: boolean) => void;
triggerContent: React.ReactNode;
triggerClassName?: string;
popoverClassName?: string;
popoverAlign?: "start" | "center" | "end";
extra?: React.ReactNode;
children: React.ReactNode;
}) {
const btnCn = cn(
"inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors",
triggerClassName,
);
if (inline) {
return (
<div>
<PropertyRow label={label}>
<button className={btnCn} onClick={() => onOpenChange(!open)}>
{triggerContent}
</button>
{extra}
</PropertyRow>
{open && (
<div className={cn("rounded-md border border-border bg-popover p-1 mb-2", popoverClassName)}>
{children}
</div>
)}
</div>
);
}
return (
<PropertyRow label={label}>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button className={btnCn}>{triggerContent}</button>
</PopoverTrigger>
<PopoverContent className={cn("p-1", popoverClassName)} align={popoverAlign} collisionPadding={16}>
{children}
</PopoverContent>
</Popover>
{extra}
</PropertyRow>
);
}
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const companyId = issue.companyId ?? selectedCompanyId;
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectOpen, setProjectOpen] = useState(false);
const [projectSearch, setProjectSearch] = useState("");
const [labelsOpen, setLabelsOpen] = useState(false);
const [labelSearch, setLabelSearch] = useState("");
const [newLabelName, setNewLabelName] = useState("");
const [newLabelColor, setNewLabelColor] = useState("var(--primary)");
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId;
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(companyId!),
queryFn: () => agentsApi.list(companyId!),
enabled: !!companyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(companyId!),
queryFn: () => projectsApi.list(companyId!),
enabled: !!companyId,
});
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId),
[projects, issue.projectId],
);
const { orderedProjects } = useProjectOrder({
projects: activeProjects,
companyId,
userId: currentUserId,
});
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(companyId!),
queryFn: () => issuesApi.listLabels(companyId!),
enabled: !!companyId,
});
const createLabel = useMutation({
mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data),
onSuccess: async (created) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
onUpdate({ labelIds: [...(issue.labelIds ?? []), created.id] });
setNewLabelName("");
},
});
const deleteLabel = useMutation({
mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
},
});
const toggleLabel = (labelId: string) => {
const ids = issue.labelIds ?? [];
const next = ids.includes(labelId)
? ids.filter((id) => id !== labelId)
: [...ids, labelId];
onUpdate({ labelIds: next });
};
const agentName = (id: string | null) => {
if (!id || !agents) return null;
const agent = agents.find((a) => a.id === id);
return agent?.name ?? id.slice(0, 8);
};
const projectName = (id: string | null) => {
if (!id) return id?.slice(0, 8) ?? "None";
const project = orderedProjects.find((p) => p.id === id);
return project?.name ?? id.slice(0, 8);
};
const currentProject = issue.projectId
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
: null;
const projectLink = (id: string | null) => {
if (!id) return null;
const project = projects?.find((p) => p.id === id) ?? null;
return project ? projectUrl(project) : `/projects/${id}`;
};
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
const sortedAgents = useMemo(
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
[agents, recentAssigneeIds],
);
const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId)
: null;
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId);
const labelsTrigger = (issue.labels ?? []).length > 0 ? (
<div className="flex items-center gap-1 flex-wrap">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border"
style={{
borderColor: label.color,
backgroundColor: `${label.color}22`,
color: pickTextColorForPillBg(label.color, 0.13),
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-xs text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
)}
</div>
) : (
<>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">No labels</span>
</>
);
const labelsContent = (
<>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search labels..."
value={labelSearch}
onChange={(e) => setLabelSearch(e.target.value)}
autoFocus={!inline}
/>
<div className="max-h-44 overflow-y-auto overscroll-contain space-y-0.5">
{(labels ?? [])
.filter((label) => {
if (!labelSearch.trim()) return true;
return label.name.toLowerCase().includes(labelSearch.toLowerCase());
})
.map((label) => {
const selected = (issue.labelIds ?? []).includes(label.id);
return (
<div key={label.id} className="flex items-center gap-1">
<button
className={cn(
"flex items-center gap-2 flex-1 px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
selected && "bg-accent"
)}
onClick={() => toggleLabel(label.id)}
>
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
<span className="truncate">{label.name}</span>
</button>
<button
type="button"
className="p-1 text-muted-foreground hover:text-destructive rounded"
onClick={() => deleteLabel.mutate(label.id)}
title={`Delete ${label.name}`}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
);
})}
</div>
<div className="mt-2 border-t border-border pt-2 space-y-1">
<div className="flex items-center gap-1">
<input
className="h-7 w-7 p-0 rounded bg-transparent"
type="color"
value={newLabelColor}
onChange={(e) => setNewLabelColor(e.target.value)}
/>
<input
className="flex-1 px-2 py-1.5 text-xs bg-transparent outline-none rounded placeholder:text-muted-foreground/50"
placeholder="New label"
value={newLabelName}
onChange={(e) => setNewLabelName(e.target.value)}
/>
</div>
<button
className="flex items-center justify-center gap-1.5 w-full px-2 py-1.5 text-xs rounded border border-border hover:bg-accent/50 disabled:opacity-50"
disabled={!newLabelName.trim() || createLabel.isPending}
onClick={() =>
createLabel.mutate({
name: newLabelName.trim(),
color: newLabelColor,
})
}
>
<Plus className="h-3 w-3" />
{createLabel.isPending ? "Creating…" : "Create label"}
</button>
</div>
</>
);
const assigneeTrigger = assignee ? (
<Identity name={assignee.name} size="sm" />
) : assigneeUserLabel ? (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">{assigneeUserLabel}</span>
</>
) : (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Unassigned</span>
</>
);
const assigneeContent = (
<>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent"
)}
onClick={() => { onUpdate({ assigneeAgentId: null, assigneeUserId: null }); setAssigneeOpen(false); }}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
Assign to me
</button>
)}
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === issue.createdByUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: issue.createdByUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button>
)}
{sortedAgents
.filter((a) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return a.name.toLowerCase().includes(q);
})
.map((a) => (
<button
key={a.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
a.id === issue.assigneeAgentId && "bg-accent"
)}
onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
</button>
))}
</div>
</>
);
const projectTrigger = issue.projectId ? (
<>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "var(--primary)" }}
/>
<span className="text-sm truncate">{projectName(issue.projectId)}</span>
</>
) : (
<>
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">No project</span>
</>
);
const projectContent = (
<>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
!issue.projectId && "bg-accent"
)}
onClick={() => {
onUpdate({
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
setProjectOpen(false);
}}
>
No project
</button>
{orderedProjects
.filter((p) => {
if (!projectSearch.trim()) return true;
const q = projectSearch.toLowerCase();
return p.name.toLowerCase().includes(q);
})
.map((p) => (
<button
key={p.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
p.id === issue.projectId && "bg-accent"
)}
onClick={() => {
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
onUpdate({
projectId: p.id,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(p),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
setProjectOpen(false);
}}
>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: p.color ?? "var(--primary)" }}
/>
{p.name}
</button>
))}
</div>
</>
);
return (
<div className="space-y-4">
<div className="space-y-1">
<PropertyRow label="Status">
<StatusIcon
status={issue.status}
onChange={(status) => onUpdate({ status })}
showLabel
/>
</PropertyRow>
<PropertyRow label="Priority">
<PriorityIcon
priority={issue.priority}
onChange={(priority) => onUpdate({ priority })}
showLabel
/>
</PropertyRow>
<PropertyPicker
inline={inline}
label="Labels"
open={labelsOpen}
onOpenChange={(open) => { setLabelsOpen(open); if (!open) setLabelSearch(""); }}
triggerContent={labelsTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-64"
>
{labelsContent}
</PropertyPicker>
<PropertyPicker
inline={inline}
label="Assignee"
open={assigneeOpen}
onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}
triggerContent={assigneeTrigger}
popoverClassName="w-52"
extra={issue.assigneeAgentId ? (
<Link
to={`/agents/${issue.assigneeAgentId}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined}
>
{assigneeContent}
</PropertyPicker>
<PropertyPicker
inline={inline}
label="Project"
open={projectOpen}
onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}
triggerContent={projectTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-fit min-w-[11rem]"
extra={issue.projectId ? (
<Link
to={projectLink(issue.projectId)!}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined}
>
{projectContent}
</PropertyPicker>
{issue.parentId && (
<PropertyRow label="Parent">
<Link
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
className="text-sm hover:underline"
>
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
</Link>
</PropertyRow>
)}
{issue.requestDepth > 0 && (
<PropertyRow label="Depth">
<span className="text-sm font-mono">{issue.requestDepth}</span>
</PropertyRow>
)}
</div>
<Separator />
<div className="space-y-1">
{(issue.createdByAgentId || issue.createdByUserId) && (
<PropertyRow label="Created by">
{issue.createdByAgentId ? (
<Link
to={`/agents/${issue.createdByAgentId}`}
className="hover:underline"
>
<Identity name={agentName(issue.createdByAgentId) ?? issue.createdByAgentId.slice(0, 8)} size="sm" />
</Link>
) : (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">{creatorUserLabel ?? "User"}</span>
</>
)}
</PropertyRow>
)}
{issue.startedAt && (
<PropertyRow label="Started">
<span className="text-sm">{formatDate(issue.startedAt)}</span>
</PropertyRow>
)}
{issue.completedAt && (
<PropertyRow label="Completed">
<span className="text-sm">{formatDate(issue.completedAt)}</span>
</PropertyRow>
)}
<PropertyRow label="Created">
<span className="text-sm">{formatDate(issue.createdAt)}</span>
</PropertyRow>
<PropertyRow label="Updated">
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
</PropertyRow>
</div>
</div>
);
}