Files
multica/packages/views/issues/surface/issue-surface.tsx
Naiyuan Qing ade6b34e5f MUL-3903: Extract shared issue surfaces (#4774)
* MUL-3903 refactor project issue surface state

Co-authored-by: multica-agent <github@multica.ai>

* Refactor project issue surface ownership

Co-authored-by: multica-agent <github@multica.ai>

* Extract shared issue surface entrypoints

Co-authored-by: multica-agent <github@multica.ai>

* Fix issue surface create defaults and selection reset

Co-authored-by: multica-agent <github@multica.ai>

* test(editor): add missing AbortSignal to suggestion items() calls

The suggestion items() contract gained a required signal param; the
mention/slash test call sites were never updated, breaking pnpm typecheck
for @multica/views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): server-side assignee_types filter on ListIssues

ListGroupedIssues has taken assignee_types since squads shipped, but
ListIssues never did — so the workspace Members/Agents tabs had to fetch
the unfiltered workspace list and post-filter loaded pages client-side,
which made column totals and load-more pagination reflect the unfiltered
counts.

Add the same parse + WHERE clause to ListIssues (count query shares the
WHERE, so totals agree), thread the param through the TS client, and
widen MyIssuesFilter so scoped list caches can carry it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(issues): route issue cache writes through a membership-aware coordinator

useUpdateIssue, useBatchUpdateIssues, and the WS issue:updated handler
each maintained their own similar-but-diverging patch/invalidate rules.
Consolidate them into cache-coordinator.ts (applyIssueChange /
rollbackIssueChange / invalidateIssueDerivatives) so local writes and
remote echoes follow one rules table by construction.

The coordinator is membership-aware via surface/membership.ts
(true | false | unknown against each list cache's own filter contract):

- a change that moves an issue off a filtered surface removes the card
  surgically (bucket total decremented) — fixes assignee changes leaving
  stale cards on My Assigned with no local safety net (previously only
  the WS echo recovered it), and replaces the blanket invalidate-myAll
  net for project moves (MUL-3669) with per-key precision
- possible entry into a loaded list marks that key stale — never
  hard-insert; page/slot is server knowledge
- stale keys flush on settle for mutations (a mid-flight refetch would
  stomp the optimistic state) and immediately for WS
- batch updates now patch detail + inbox like single updates; the
  off-screen bucket-count recovery previously exclusive to the WS path
  now covers local mutations too

Preserved invariants: synchronous optimistic patches (dnd-kit), MUL-3375
control-field stripping, and no refetch of surgically reconciled lists
(the drag-flicker fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(issues): resolve surfaces via core query plan/repository with window-keyed remount

Read-path convergence and the loading/empty semantics that fall out of
it:

- scope -> API params moves from scope.ts helpers into
  surface/query-plan.ts; workspace members/agents become server-filtered
  scoped plans (assignee_types) and the client postFilter machinery is
  deleted — tab counts and load-more are now exact
- query selection moves behind surface/repository.ts; the views data
  hook no longer branches on workspace-vs-scoped plumbing
- IssueSurfaceContent remounts on data-window change (wsId + scope):
  keepPreviousData placeholders keep sort/filter changes flicker-free
  within one window but must never let project A's (or workspace A's)
  cards impersonate B's with no loading state — cold window shows the
  skeleton, warm window hits cache instantly
- isEmpty is only asserted from full-window data; the gantt
  scheduled-only projection can't prove the window is empty, so GanttView's
  own "no scheduled issues" empty state renders instead of the generic
  create-issue one
- per-card project lookups hoist into a surface-level projectMap (drops
  a per-card useQuery), create-defaults typing tightens to
  IssueCreateDefaults

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(issues): count-only arithmetic for off-window status/membership changes

An issue beyond a list's loaded page window used to force a full
first-page refetch just to fix two column counts. When the change is
CERTAIN (base entity known, membership definitive) the coordinator now
does the arithmetic locally:

- stayed a member + status changed: move one unit of total between the
  two buckets (loaded arrays untouched; hasMore stays consistent)
- left the list (reassigned / re-projected): old status bucket total -1
- member-to-member reassignment: counts unaffected, not even a stale key

Entering a list and any uncertainty (no base, unknown membership) still
refetch — the right page/slot is server knowledge. Branches on membership
OUTCOMES, not on which field changed, so future dimensions (team) join
automatically. Biggest win is the WS path: agents flipping off-screen
statuses no longer trigger refetch storms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): deferred view-refresh indicator during placeholder revalidation

Sort/date changes (and any grouped-board filter change) revalidate behind
the previous snapshot — correct, but on a slow network the click felt
dead: content stays put and isLoading never fires. Surface the state as
isRefreshing (isPlaceholderData of the active query) and render a shared
ViewRefreshIndicator in every issues header: a fixed-width slot (zero
layout shift) whose spinner fades in after 300ms, so sub-second responses
show nothing (NN/g) while slow ones get a working signal.

Bound to the revalidation STATE, not to any particular control — any
current or future server-side view change lights it automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:11:10 +08:00

270 lines
9.9 KiB
TypeScript

"use client";
import { useCallback, useMemo, 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 { useT } from "../../i18n";
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[];
}
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,
renderHeader,
renderEmpty,
renderLoading,
clientFilter,
showClientEmpty,
batchToolbar = "always",
contentClassName,
}: IssueSurfaceComponentProps) {
const wsId = useWorkspaceId();
const resolvedSurfaceKey = surfaceKey ?? issueScopeKey(scope);
const store = useMemo(
() => getIssueSurfaceViewStore(resolvedSurfaceKey),
[resolvedSurfaceKey],
);
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={`${wsId}:${issueScopeKey(scope)}`}
scope={scope}
modes={modes}
createDefaults={createDefaults}
renderHeader={renderHeader}
renderEmpty={renderEmpty}
renderLoading={renderLoading}
clientFilter={clientFilter}
showClientEmpty={showClientEmpty}
batchToolbar={batchToolbar}
contentClassName={contentClassName}
/>
</ViewStoreProvider>
);
}
function IssueSurfaceContent({
scope,
modes,
createDefaults,
renderHeader,
renderEmpty,
renderLoading,
clientFilter,
showClientEmpty,
batchToolbar,
contentClassName,
}: Omit<IssueSurfaceComponentProps, "surfaceKey">) {
const { t } = useT("projects");
const controller = useIssueSurfaceController({
scope,
modes,
createDefaults,
});
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],
);
const renderContext = useMemo(
() => ({ controller, issues }),
[controller, issues],
);
const openCreateIssue = useCallback(
(defaults?: IssueCreateDefaults) => {
controller.openCreateIssue(defaults);
},
[controller],
);
const shouldShowClientEmpty =
!!clientFilter &&
issues.length === 0 &&
(showClientEmpty ? showClientEmpty(renderContext) : true);
const shouldShowBatchToolbar =
batchToolbar !== "never" &&
(batchToolbar === "always" || controller.viewMode === "list");
return (
<IssueSurfaceActionsProvider actions={controller.actions}>
<IssueSurfaceSelectionProvider selection={controller.selection}>
{renderHeader ? (
renderHeader(renderContext)
) : (
<IssuesHeader
scopedIssues={controller.surfaceIssues}
allowGantt={controller.allowGantt}
isRefreshing={controller.isRefreshing}
/>
)}
{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={
controller.assigneeGroups
? controller.assigneeGroups.flatMap((group) => group.issues)
: issues
}
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}
/>
)}
{controller.viewMode === "list" && (
<ListView
issues={issues}
visibleStatuses={controller.visibleStatuses}
childProgressMap={controller.childProgressMap}
projectMap={controller.projectMap}
myIssuesScope={controller.loadMoreScope}
myIssuesFilter={controller.loadMoreFilter}
sort={controller.sort}
projectId={controller.projectId}
onMoveIssue={controller.moveIssue}
onCreateIssue={openCreateIssue}
/>
)}
{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}
activityByIssueId={controller.activity.activityByIssueId}
onCreateIssue={openCreateIssue}
/>
)}
</div>
)}
{shouldShowBatchToolbar && <BatchActionToolbar issues={issues} />}
</IssueSurfaceSelectionProvider>
</IssueSurfaceActionsProvider>
);
}
function IssueSurfaceSkeleton({ mode }: { mode: string }) {
if (mode === "list") {
return (
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full 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>
);
}