"use client"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { ListTodo, Plus } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { cn } from "@multica/ui/lib/utils"; import { useWorkspaceId } from "@multica/core/hooks"; import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context"; import { getIssueSurfaceViewStore } from "@multica/core/issues/stores/surface-view-store"; import { issueScopeKey } from "@multica/core/issues/surface/scope"; import type { Issue } from "@multica/core/types"; import { BoardView } from "../components/board-view"; import { BatchActionToolbar } from "../components/batch-action-toolbar"; import { GanttView } from "../components/gantt-view"; import { IssuesHeader } from "../components/issues-header"; import { ListView } from "../components/list-view"; import { SwimLaneView } from "../components/swimlane-view"; import { TableView } from "../components/table-view"; import { useT } from "../../i18n"; import { IssueContextMenuProvider } from "../actions"; import { IssueSurfaceActionsProvider } from "./actions-context"; import { IssueSurfaceSelectionProvider } from "./selection-context"; import type { IssueCreateDefaults, IssueSurfaceProps } from "./types"; import { useIssueSurfaceController, type IssueSurfaceController, } from "./use-issue-surface-controller"; export interface IssueSurfaceRenderContext { controller: IssueSurfaceController; issues: Issue[]; /** The rows the agents-working filter would leave on screen, with this * surface's `clientFilter` applied — headers feed it to the working chip * so the chip's count is the post-click row count (MUL-4884). Undefined * means the set is UNKNOWN (not materialized by the server-backed Table); * the chip renders an indeterminate state instead of a number. */ workingIssues: Issue[] | undefined; } interface IssueSurfaceComponentProps extends IssueSurfaceProps { renderHeader?: (context: IssueSurfaceRenderContext) => ReactNode; renderEmpty?: (context: IssueSurfaceRenderContext) => ReactNode; renderLoading?: (context: IssueSurfaceRenderContext) => ReactNode; clientFilter?: (issue: Issue) => boolean; showClientEmpty?: (context: IssueSurfaceRenderContext) => boolean; batchToolbar?: "always" | "list" | "never"; contentClassName?: string; } export function IssueSurface({ scope, modes, surfaceKey, createDefaults, search, renderHeader, renderEmpty, renderLoading, clientFilter, showClientEmpty, batchToolbar = "always", contentClassName, }: IssueSurfaceComponentProps) { const wsId = useWorkspaceId(); const resolvedSurfaceKey = surfaceKey ?? issueScopeKey(scope); const store = useMemo( () => getIssueSurfaceViewStore(resolvedSurfaceKey), [resolvedSurfaceKey], ); // Every change of this key tears down and remounts the ENTIRE surface // (providers, DnD, all columns/cards) — by design for data-window changes, // but expensive enough that unexpected flips are performance bugs. Dev-only // breadcrumb so a Performance trace showing double mounts can be tied to // the exact key transition. const contentKey = `${wsId}:${issueScopeKey(scope)}`; useEffect(() => { if (process.env.NODE_ENV !== "production") { console.warn(`[issue-surface] mount ${contentKey}`); } }, [contentKey]); return ( {/* Remount on data-window change: the list queries keep the previous key's data as a placeholder (keepPreviousData) so sort/filter changes within ONE surface never flash a skeleton — but reusing the mounted observer across windows made project A's cards impersonate project B (with isLoading=false, so no skeleton either) until B's fetch landed. A window-keyed remount gives the new window a fresh observer: cold window → skeleton, warm window → instant cache hit. The window identity is wsId + scope — wsId is required because the workspace layout does not remount on workspace switch and two workspaces share the same scope key (e.g. "workspace:all"). Keyed by data identity, not surfaceKey (view-preference identity). */} ); } function IssueSurfaceContent({ scope, modes, createDefaults, search, renderHeader, renderEmpty, renderLoading, clientFilter, showClientEmpty, batchToolbar, contentClassName, }: Omit) { const { t } = useT("projects"); const controller = useIssueSurfaceController({ scope, modes, createDefaults, search, }); const [tableLoadedIssues, setTableLoadedIssues] = useState([]); const handleTableLoadedIssuesChange = useCallback((next: Issue[]) => { setTableLoadedIssues((current) => current.length === next.length && current.every((issue, index) => issue === next[index]) ? current : next, ); }, []); useEffect(() => { if (controller.viewMode !== "table") setTableLoadedIssues([]); }, [controller.viewMode]); const issues = useMemo( () => clientFilter ? controller.issues.filter((issue) => clientFilter(issue)) : controller.issues, [clientFilter, controller.issues], ); const swimlaneIssues = useMemo( () => clientFilter ? controller.swimlaneIssues.filter((issue) => clientFilter(issue)) : controller.swimlaneIssues, [clientFilter, controller.swimlaneIssues], ); // Same clientFilter the rendered rows go through, so the chip's promise // survives on surfaces that narrow the list locally (e.g. a search box). // An UNKNOWN scope (undefined) passes through untouched — there is nothing // to filter and the chip must see it as unknown. const workingIssues = useMemo( () => clientFilter && controller.workingScopeIssues ? controller.workingScopeIssues.filter((issue) => clientFilter(issue)) : controller.workingScopeIssues, [clientFilter, controller.workingScopeIssues], ); const renderContext = useMemo( () => ({ controller, issues, workingIssues }), [controller, issues, workingIssues], ); const openCreateIssue = useCallback( (defaults?: IssueCreateDefaults) => { controller.openCreateIssue(defaults); }, [controller], ); // Stable reference for BoardView's issues: the inline flatMap allocated a // fresh array every render, defeating BoardView's memo. const boardIssues = useMemo( () => controller.assigneeGroups ? controller.assigneeGroups.flatMap((group) => group.issues) : issues, [controller.assigneeGroups, issues], ); const shouldShowClientEmpty = !!clientFilter && issues.length === 0 && (showClientEmpty ? showClientEmpty(renderContext) : true); const shouldShowBatchToolbar = batchToolbar !== "never" && (batchToolbar === "always" || controller.viewMode === "list" || controller.viewMode === "table"); return ( {/* One shared right-click menu for every card/row this surface renders — see IssueContextMenuProvider. Inside the actions provider so the singleton's useIssueActions routes updates through surface actions. */} {renderHeader ? ( renderHeader(renderContext) ) : ( )} {controller.isLoading ? ( renderLoading ? ( renderLoading(renderContext) ) : ( ) ) : controller.isEmpty || shouldShowClientEmpty ? ( renderEmpty ? ( renderEmpty(renderContext) ) : (

{t(($) => $.detail.empty_issues_title)}

{t(($) => $.detail.empty_issues_hint)}

) ) : (
{controller.viewMode === "board" && ( )} {controller.viewMode === "list" && ( )} {controller.viewMode === "table" && ( )} {controller.viewMode === "gantt" && ( )} {controller.viewMode === "swimlane" && ( )}
)} {shouldShowBatchToolbar && ( )}
); } function IssueSurfaceSkeleton({ mode }: { mode: string }) { if (mode === "list" || mode === "table") { return (
{mode === "table" && } {Array.from({ length: mode === "table" ? 8 : 4 }).map((_, i) => ( ))}
); } return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); }