mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
* fix(issues): query workspace working agents independently Co-authored-by: multica-agent <github@multica.ai> * feat(agents): filter working agents by source type Co-authored-by: multica-agent <github@multica.ai> * feat(issues): scope working agents to My Issues Co-authored-by: multica-agent <github@multica.ai> * test(agents): cover My Issues squad relations Co-authored-by: multica-agent <github@multica.ai> * fix(issues): unify working-agent filters across views Co-authored-by: multica-agent <github@multica.ai> * fix(issues): preserve empty working-agent filters Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
"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 (
|
|
<ViewStoreProvider store={store}>
|
|
{/* 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). */}
|
|
<IssueSurfaceContent
|
|
key={contentKey}
|
|
scope={scope}
|
|
modes={modes}
|
|
createDefaults={createDefaults}
|
|
search={search}
|
|
renderHeader={renderHeader}
|
|
renderEmpty={renderEmpty}
|
|
renderLoading={renderLoading}
|
|
clientFilter={clientFilter}
|
|
showClientEmpty={showClientEmpty}
|
|
batchToolbar={batchToolbar}
|
|
contentClassName={contentClassName}
|
|
/>
|
|
</ViewStoreProvider>
|
|
);
|
|
}
|
|
|
|
function IssueSurfaceContent({
|
|
scope,
|
|
modes,
|
|
createDefaults,
|
|
search,
|
|
renderHeader,
|
|
renderEmpty,
|
|
renderLoading,
|
|
clientFilter,
|
|
showClientEmpty,
|
|
batchToolbar,
|
|
contentClassName,
|
|
}: Omit<IssueSurfaceComponentProps, "surfaceKey">) {
|
|
const { t } = useT("projects");
|
|
const controller = useIssueSurfaceController({
|
|
scope,
|
|
modes,
|
|
createDefaults,
|
|
search,
|
|
});
|
|
const [tableLoadedIssues, setTableLoadedIssues] = useState<Issue[]>([]);
|
|
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 (
|
|
<IssueSurfaceActionsProvider actions={controller.actions}>
|
|
{/* 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. */}
|
|
<IssueContextMenuProvider>
|
|
<IssueSurfaceSelectionProvider selection={controller.selection}>
|
|
{renderHeader ? (
|
|
renderHeader(renderContext)
|
|
) : (
|
|
<IssuesHeader
|
|
scopedIssues={controller.surfaceIssues}
|
|
allowGantt={controller.allowGantt}
|
|
isRefreshing={controller.isRefreshing}
|
|
facetCountsExact={
|
|
controller.facetCountsExact
|
|
}
|
|
tableFacetCounts={controller.tableFacetCounts}
|
|
onTableFacetChange={controller.setActiveTableFacet}
|
|
/>
|
|
)}
|
|
{controller.isLoading ? (
|
|
renderLoading ? (
|
|
renderLoading(renderContext)
|
|
) : (
|
|
<IssueSurfaceSkeleton mode={controller.viewMode} />
|
|
)
|
|
) : controller.isEmpty || shouldShowClientEmpty ? (
|
|
renderEmpty ? (
|
|
renderEmpty(renderContext)
|
|
) : (
|
|
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
|
<p className="text-sm">{t(($) => $.detail.empty_issues_title)}</p>
|
|
<p className="text-xs">{t(($) => $.detail.empty_issues_hint)}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-1"
|
|
onClick={() => controller.openCreateIssue()}
|
|
>
|
|
<Plus className="size-3.5 mr-1.5" />
|
|
{t(($) => $.detail.empty_issues_new_button)}
|
|
</Button>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className={cn("flex flex-col flex-1 min-h-0", contentClassName)}>
|
|
{controller.viewMode === "board" && (
|
|
<BoardView
|
|
issues={boardIssues}
|
|
assigneeGroups={controller.assigneeGroups}
|
|
assigneeGroupQueryKey={controller.assigneeGroupQueryKey}
|
|
assigneeGroupFilter={controller.assigneeGroupFilter}
|
|
visibleStatuses={controller.visibleStatuses}
|
|
hiddenStatuses={controller.hiddenStatuses}
|
|
onMoveIssue={controller.moveIssue}
|
|
childProgressMap={controller.childProgressMap}
|
|
projectMap={controller.projectMap}
|
|
myIssuesScope={controller.loadMoreScope}
|
|
myIssuesFilter={controller.loadMoreFilter}
|
|
sort={controller.sort}
|
|
projectId={controller.projectId}
|
|
onCreateIssue={openCreateIssue}
|
|
statusPagination={controller.statusPagination}
|
|
groupBranches={controller.groupBranches}
|
|
/>
|
|
)}
|
|
{controller.viewMode === "list" && (
|
|
<ListView
|
|
issues={issues}
|
|
visibleStatuses={controller.visibleStatuses}
|
|
childProgressMap={controller.childProgressMap}
|
|
projectMap={controller.projectMap}
|
|
projectId={controller.projectId}
|
|
onMoveIssue={controller.moveIssue}
|
|
onCreateIssue={openCreateIssue}
|
|
statusPagination={controller.statusPagination!}
|
|
/>
|
|
)}
|
|
{controller.viewMode === "table" && (
|
|
<TableView
|
|
serverQuery={controller.tableQuerySpec}
|
|
childProgressMap={controller.childProgressMap}
|
|
search={controller.tableSearch}
|
|
onSearchChange={controller.setTableSearch}
|
|
onLoadedIssuesChange={handleTableLoadedIssuesChange}
|
|
onCreateIssue={openCreateIssue}
|
|
exportIssues={controller.exportTableIssues}
|
|
resolveExportLookups={controller.resolveTableExportLookups}
|
|
/>
|
|
)}
|
|
{controller.viewMode === "gantt" && (
|
|
<GanttView issues={controller.filteredGanttIssues} />
|
|
)}
|
|
{controller.viewMode === "swimlane" && (
|
|
<SwimLaneView
|
|
issues={issues}
|
|
unfilteredIssues={swimlaneIssues}
|
|
activeFilters={controller.activeFilters}
|
|
visibleStatuses={controller.visibleStatuses}
|
|
hiddenStatuses={controller.hiddenStatuses}
|
|
onMoveIssue={controller.moveIssue}
|
|
childProgressMap={controller.childProgressMap}
|
|
projectMap={controller.projectMap}
|
|
myIssuesScope={controller.loadMoreScope}
|
|
myIssuesFilter={controller.loadMoreFilter}
|
|
sort={controller.sort}
|
|
projectId={controller.projectId}
|
|
onCreateIssue={openCreateIssue}
|
|
groupBranches={controller.groupBranches}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{shouldShowBatchToolbar && (
|
|
<BatchActionToolbar
|
|
issues={
|
|
controller.viewMode === "table" ? tableLoadedIssues : issues
|
|
}
|
|
/>
|
|
)}
|
|
</IssueSurfaceSelectionProvider>
|
|
</IssueContextMenuProvider>
|
|
</IssueSurfaceActionsProvider>
|
|
);
|
|
}
|
|
|
|
function IssueSurfaceSkeleton({ mode }: { mode: string }) {
|
|
if (mode === "list" || mode === "table") {
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
|
{mode === "table" && <Skeleton className="mb-1 h-8 w-full" />}
|
|
{Array.from({ length: mode === "table" ? 8 : 4 }).map((_, i) => (
|
|
<Skeleton
|
|
key={i}
|
|
className={cn(
|
|
"w-full",
|
|
mode === "table" ? "h-9 rounded-sm" : "h-10 rounded-lg",
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
|
<Skeleton className="h-4 w-20" />
|
|
<Skeleton className="h-24 w-full rounded-lg" />
|
|
<Skeleton className="h-24 w-full rounded-lg" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|