refactor(views): centralize project icon rendering and fix nav active state (#1738)

Extract <ProjectIcon> with sm/md/lg sizes and a single 📁 fallback,
replacing 9 inline render sites that had drifted into 6 different
sizes and a mixed FolderKanban/emoji fallback.

Two visible fixes fall out of the centralization:
- ProjectPicker trigger now shows the selected project's icon (most
  visibly in the issue detail right Properties panel, where it had
  always been a generic FolderKanban).
- Sidebar parent nav (Projects, Issues, Settings, ...) now stays
  highlighted on child detail routes via a small isNavActive helper.
  Pinned items keep strict equality.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-27 14:42:56 +08:00
committed by GitHub
parent e9d04ecfc1
commit e268ee3e71
9 changed files with 64 additions and 24 deletions

View File

@@ -14,6 +14,7 @@ import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { projectListOptions } from "@multica/core/projects/queries";
import { ProjectIcon } from "../../projects/components/project-icon";
import { PriorityIcon } from "./priority-icon";
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@multica/core/issues/config";
@@ -101,7 +102,7 @@ export const BoardCardContent = memo(function BoardCardContent({
)}
{showProject && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5 text-[11px] text-muted-foreground max-w-[160px]">
<span aria-hidden="true" className="shrink-0">{project!.icon || "📁"}</span>
<ProjectIcon project={project} size="sm" />
<span className="truncate">{project!.title}</span>
</span>
)}

View File

@@ -49,6 +49,7 @@ import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { projectListOptions } from "@multica/core/projects/queries";
import { ProjectIcon } from "../../projects/components/project-icon";
import { ActorAvatar } from "../../common/actor-avatar";
import {
SORT_OPTIONS,
@@ -353,9 +354,7 @@ function ProjectSubContent({
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<span className="size-3.5 flex items-center justify-center shrink-0">
{p.icon || <FolderKanban className="size-3.5 text-muted-foreground" />}
</span>
<ProjectIcon project={p} size="sm" />
<span className="truncate">{p.title}</span>
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">

View File

@@ -10,6 +10,7 @@ import { useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { projectListOptions } from "@multica/core/projects/queries";
import { ProjectIcon } from "../../projects/components/project-icon";
import { PriorityIcon } from "./priority-icon";
import { ProgressRing } from "./progress-ring";
import { IssueActionsContextMenu } from "../actions";
@@ -90,7 +91,7 @@ export const ListRow = memo(function ListRow({
</span>
{showProject && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground max-w-[140px]">
<span aria-hidden="true" className="shrink-0">{project!.icon || "📁"}</span>
<ProjectIcon project={project} size="sm" />
<span className="truncate">{project!.title}</span>
</span>
)}

View File

@@ -78,6 +78,15 @@ import { issueDetailOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import type { PinnedItem } from "@multica/core/types";
import { useLogout } from "../auth";
import { ProjectIcon } from "../projects/components/project-icon";
// Top-level nav items stay active when the user is on a child route
// (e.g. "Projects" stays lit on /:slug/projects/:id). Pinned items keep
// strict equality elsewhere — a pinned project shouldn't highlight on
// sub-pages of itself.
function isNavActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(href + "/");
}
// Stable empty arrays for query defaults. Using an inline `= []` default on
// `useQuery` creates a new array reference on every render when `data` is
@@ -269,11 +278,7 @@ function PinRow({
if (projectQuery.isPending) return <PinSkeleton />;
if (projectQuery.isError || !projectQuery.data) return null;
const project = projectQuery.data;
const iconNode = (
<span className="flex size-3.5 shrink-0 items-center justify-center text-xs leading-none">
{project.icon || "📁"}
</span>
);
const iconNode = <ProjectIcon project={project} size="sm" />;
return (
<SortablePinItem
pin={pin}
@@ -588,7 +593,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenu className="gap-0.5">
{personalNav.map((item) => {
const href = p[item.key]();
const isActive = pathname === href;
const isActive = isNavActive(pathname, href);
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
@@ -658,7 +663,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenu className="gap-0.5">
{workspaceNav.map((item) => {
const href = p[item.key]();
const isActive = pathname === href;
const isActive = isNavActive(pathname, href);
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
@@ -682,7 +687,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenu className="gap-0.5">
{configureNav.map((item) => {
const href = p[item.key]();
const isActive = pathname === href;
const isActive = isNavActive(pathname, href);
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton

View File

@@ -3,6 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import { projectListOptions, projectDetailOptions } from "@multica/core/projects/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { ProjectIcon } from "./project-icon";
/**
* Compact presentational representation of a project —
@@ -10,9 +11,6 @@ import { useWorkspaceId } from "@multica/core/hooks";
*
* Not a link / button: callers wrap it in whatever interactive shell they
* need. Pure UI — data is queried internally so callers can pass just an id.
*
* `📁` matches the fallback used elsewhere (project-picker, projects-page,
* project-detail) so project affordances feel consistent across the app.
*/
export interface ProjectChipProps {
projectId: string;
@@ -45,7 +43,7 @@ export function ProjectChip({
if (!project) {
return (
<span className={cls}>
<span className="shrink-0">📁</span>
<ProjectIcon size="md" />
<span className="text-muted-foreground truncate">
{fallbackLabel ?? "Project"}
</span>
@@ -55,7 +53,7 @@ export function ProjectChip({
return (
<span className={cls}>
<span className="shrink-0">{project.icon || "📁"}</span>
<ProjectIcon project={project} size="md" />
<span className="text-foreground truncate">{project.title}</span>
</span>
);

View File

@@ -0,0 +1,31 @@
import type { Project } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
export type ProjectIconSize = "sm" | "md" | "lg";
export interface ProjectIconProps {
project?: Pick<Project, "icon"> | null;
size?: ProjectIconSize;
className?: string;
}
const SIZE_CLASS: Record<ProjectIconSize, string> = {
sm: "size-3.5 text-xs leading-none",
md: "size-4 text-sm leading-none",
lg: "size-6 text-2xl leading-none",
};
export function ProjectIcon({ project, size = "sm", className }: ProjectIconProps) {
return (
<span
aria-hidden="true"
className={cn(
"inline-flex shrink-0 items-center justify-center",
SIZE_CLASS[size],
className,
)}
>
{project?.icon || "📁"}
</span>
);
}

View File

@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@multica/ui/components/ui/dropdown-menu";
import { ProjectIcon } from "./project-icon";
export function ProjectPicker({
projectId,
@@ -34,13 +35,17 @@ export function ProjectPicker({
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
render={triggerRender}
>
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{current ? (
<ProjectIcon project={current} size="sm" />
) : (
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{current ? current.title : "No project"}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-52">
{projects.map((p) => (
<DropdownMenuItem key={p.id} onClick={() => onUpdate({ project_id: p.id })}>
<span className="mr-1">{p.icon || "📁"}</span>
<ProjectIcon project={p} size="md" className="mr-1" />
<span className="truncate">{p.title}</span>
{p.id === projectId && <Check className="ml-auto h-3.5 w-3.5 shrink-0" />}
</DropdownMenuItem>

View File

@@ -36,6 +36,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types";
import { PageHeader } from "../../layout/page-header";
import { PriorityIcon } from "../../issues/components/priority-icon";
import { ProjectIcon } from "./project-icon";
function formatRelativeDate(date: string): string {
const diff = Date.now() - new Date(date).getTime();
@@ -77,7 +78,7 @@ function ProjectRow({ project }: { project: Project }) {
href={wsPaths.projectDetail(project.id)}
className="flex min-w-0 flex-1 items-center gap-2"
>
<span className="shrink-0 w-[24px] text-center text-base">{project.icon || "📁"}</span>
<ProjectIcon project={project} size="md" />
<span className="min-w-0 flex-1 truncate font-medium">{project.title}</span>
</AppLink>

View File

@@ -36,6 +36,7 @@ import type { WorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { StatusIcon } from "../issues/components";
import { ProjectIcon } from "../projects/components/project-icon";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
import type { ProjectStatus } from "@multica/core/types";
@@ -573,9 +574,7 @@ export function SearchCommand() {
className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
>
<div className="flex items-center gap-2.5">
<span className="size-4 shrink-0 text-center text-sm leading-4">
{project.icon || <FolderKanban className="size-4 text-muted-foreground" />}
</span>
<ProjectIcon project={project} size="md" />
<span className="truncate">
<HighlightText text={project.title} query={query} />
</span>