Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
53d6abc7a4 fix(issues): handle project / hidden-column / lookup regressions from paginated list cache
After bucketing the issue list cache by status, three consumers that
treated `issueListOptions()` as a complete local index broke:

- `project-detail.tsx` filtered the workspace list by `project_id`
  client-side, so projects whose issues sat past the first 50-per-status
  page rendered empty. Switch to `myIssueListOptions(wsId,
  'project:<id>', { project_id })` so the server returns only this
  project's issues; add `project_id` to `ListIssuesParams` /
  `MyIssuesFilter` / api client.
- `board-view.tsx` HiddenColumnsPanel read counts from the in-memory
  `issues` array — a paginated fragment. Pass `myIssuesOpts` through to
  a per-row subcomponent that reads the real per-status total from the
  cache.
- `tasks-tab.tsx` and `search-command.tsx` used the list as a global
  lookup for task titles / Recent items / current-issue chrome. Switch
  both to per-id `issueDetailOptions` via `useQueries` so they're
  independent of which page the issue lands on.

Drop the now-redundant `doneTotal` override prop on BoardView/ListView
and the `allIssues` prop on BoardView (only HiddenColumnsPanel consumed
it).

Tests updated: tasks-tab now mocks `api.getIssue`; search-command mocks
`issueDetailOptions` + `useQueries`; project-issue-metrics drops the
`doneColumnCount` assertion.
2026-04-21 14:10:08 +08:00
Jiang Bohan
66a65eb65f feat(issues): paginate every status column, not just done
Previously the workspace issues list fetched all non-done/cancelled
issues in a single unbounded `open_only=true` request and only
paginated the done column. In workspaces with many open issues this
ballooned the initial payload and skipped pagination entirely.

Restructure the issue list cache into per-status buckets
(`{ byStatus: { [status]: { issues, total } } }`) fetched in parallel,
generalize `useLoadMoreDoneIssues` into `useLoadMoreByStatus(status,
myIssuesOpts?)`, and render an infinite-scroll sentinel inside every
accordion group and kanban column. Sort and filter stay client-side,
matching the done column's existing behavior.

Backend `ListIssues` already supports per-status pagination, so no
API changes are required.
2026-04-21 12:58:10 +08:00
20 changed files with 587 additions and 441 deletions

View File

@@ -238,6 +238,7 @@ export class ApiClient {
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params?.creator_id) search.set("creator_id", params.creator_id);
if (params?.project_id) search.set("project_id", params.project_id);
if (params?.open_only) search.set("open_only", "true");
return this.fetch(`/api/issues?${search}`);
}

View File

@@ -0,0 +1,100 @@
import type {
Issue,
IssueStatus,
IssueStatusBucket,
ListIssuesCache,
} from "../types";
import { PAGINATED_STATUSES } from "./queries";
const EMPTY_BUCKET: IssueStatusBucket = { issues: [], total: 0 };
export function getBucket(
resp: ListIssuesCache,
status: IssueStatus,
): IssueStatusBucket {
return resp.byStatus[status] ?? EMPTY_BUCKET;
}
export function setBucket(
resp: ListIssuesCache,
status: IssueStatus,
bucket: IssueStatusBucket,
): ListIssuesCache {
return { ...resp, byStatus: { ...resp.byStatus, [status]: bucket } };
}
/** Locate which status bucket holds `id`, if any. */
export function findIssueLocation(
resp: ListIssuesCache,
id: string,
): { status: IssueStatus; issue: Issue } | null {
for (const status of PAGINATED_STATUSES) {
const bucket = resp.byStatus[status];
const found = bucket?.issues.find((i) => i.id === id);
if (found) return { status, issue: found };
}
return null;
}
/** Add an issue to its status bucket (no-op if already present). */
export function addIssueToBuckets(
resp: ListIssuesCache,
issue: Issue,
): ListIssuesCache {
const bucket = getBucket(resp, issue.status);
if (bucket.issues.some((i) => i.id === issue.id)) return resp;
return setBucket(resp, issue.status, {
issues: [...bucket.issues, issue],
total: bucket.total + 1,
});
}
/** Remove an issue from whichever bucket contains it. */
export function removeIssueFromBuckets(
resp: ListIssuesCache,
id: string,
): ListIssuesCache {
const loc = findIssueLocation(resp, id);
if (!loc) return resp;
const bucket = getBucket(resp, loc.status);
return setBucket(resp, loc.status, {
issues: bucket.issues.filter((i) => i.id !== id),
total: Math.max(0, bucket.total - 1),
});
}
/**
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
* current bucket, the issue moves to the new bucket and both buckets' totals
* are adjusted.
*/
export function patchIssueInBuckets(
resp: ListIssuesCache,
id: string,
patch: Partial<Issue>,
): ListIssuesCache {
const loc = findIssueLocation(resp, id);
if (!loc) return resp;
const merged: Issue = { ...loc.issue, ...patch };
const nextStatus = patch.status ?? loc.status;
if (nextStatus === loc.status) {
const bucket = getBucket(resp, loc.status);
return setBucket(resp, loc.status, {
...bucket,
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
});
}
const fromBucket = getBucket(resp, loc.status);
const toBucket = getBucket(resp, nextStatus);
let next = setBucket(resp, loc.status, {
issues: fromBucket.issues.filter((i) => i.id !== id),
total: Math.max(0, fromBucket.total - 1),
});
next = setBucket(next, nextStatus, {
issues: [...toBucket.issues, merged],
total: toBucket.total + 1,
});
return next;
}

View File

@@ -1,14 +1,26 @@
import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
import {
issueKeys,
ISSUE_PAGE_SIZE,
type MyIssuesFilter,
} from "./queries";
import {
addIssueToBuckets,
findIssueLocation,
getBucket,
patchIssueInBuckets,
removeIssueFromBuckets,
setBucket,
} from "./cache-helpers";
import { useWorkspaceId } from "../hooks";
import { useRecentIssuesStore } from "./stores";
import type { Issue, IssueReaction } from "../types";
import type { Issue, IssueReaction, IssueStatus } from "../types";
import type {
CreateIssueRequest,
UpdateIssueRequest,
ListIssuesResponse,
ListIssuesCache,
} from "../types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
@@ -29,10 +41,18 @@ export type ToggleIssueReactionVars = {
};
// ---------------------------------------------------------------------------
// Done issue pagination
// Per-status pagination
// ---------------------------------------------------------------------------
export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssuesFilter }) {
/**
* Paginate one status column into the cache. Works for both the workspace
* issue list and per-scope My Issues lists (pass `myIssues` to target the
* latter).
*/
export function useLoadMoreByStatus(
status: IssueStatus,
myIssues?: { scope: string; filter: MyIssuesFilter },
) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
@@ -40,39 +60,38 @@ export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssu
const queryKey = myIssues
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
: issueKeys.list(wsId);
const cache = qc.getQueryData<ListIssuesResponse>(queryKey);
const doneLoaded = cache
? cache.issues.filter((i) => i.status === "done").length
: 0;
const doneTotal = cache?.doneTotal ?? 0;
const hasMore = doneLoaded < doneTotal;
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
const bucket = cache?.byStatus[status];
const loaded = bucket?.issues.length ?? 0;
const total = bucket?.total ?? 0;
const hasMore = loaded < total;
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const res = await api.listIssues({
status: "done",
limit: CLOSED_PAGE_SIZE,
offset: doneLoaded,
status,
limit: ISSUE_PAGE_SIZE,
offset: loaded,
...myIssues?.filter,
});
qc.setQueryData<ListIssuesResponse>(queryKey, (old) => {
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
if (!old) return old;
const existingIds = new Set(old.issues.map((i) => i.id));
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
return {
...old,
issues: [...old.issues, ...newIssues],
doneTotal: res.total,
};
const prev = getBucket(old, status);
const existingIds = new Set(prev.issues.map((i) => i.id));
const appended = res.issues.filter((i) => !existingIds.has(i.id));
return setBucket(old, status, {
issues: [...prev.issues, ...appended],
total: res.total,
});
});
} finally {
setIsLoading(false);
}
}, [qc, queryKey, doneLoaded, hasMore, isLoading, myIssues?.filter]);
}, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]);
return { loadMore, hasMore, isLoading, doneTotal };
return { loadMore, hasMore, isLoading, total };
}
// ---------------------------------------------------------------------------
@@ -85,15 +104,8 @@ export function useCreateIssue() {
return useMutation({
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
onSuccess: (newIssue) => {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old && !old.issues.some((i) => i.id === newIssue.id)
? {
...old,
issues: [...old.issues, newIssue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
}
: old,
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? addIssueToBuckets(old, newIssue) : old,
);
// Surface the just-created issue in cmd+k's Recent list without
// requiring the user to open it first.
@@ -122,7 +134,7 @@ export function useUpdateIssue() {
// yield to the event loop, letting @dnd-kit reset its visual state
// before the optimistic update lands.
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
// Resolve parent_issue_id from the freshest source so we can keep the
@@ -130,21 +142,14 @@ export function useUpdateIssue() {
// sub-issues list).
const parentId =
prevDetail?.parent_issue_id ??
prevList?.issues.find((i) => i.id === id)?.parent_issue_id ??
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
null;
const prevChildren = parentId
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
: undefined;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old
? {
...old,
issues: old.issues.map((i) =>
i.id === id ? { ...i, ...data } : i,
),
}
: old,
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, id, data) : old,
);
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
old ? { ...old, ...data } : old,
@@ -198,18 +203,11 @@ export function useDeleteIssue() {
mutationFn: (id: string) => api.deleteIssue(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const deleted = prevList?.issues.find((i) => i.id === id);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const d = old.issues.find((i) => i.id === id);
return {
...old,
issues: old.issues.filter((i) => i.id !== id),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (d?.status === "done" ? 1 : 0),
};
});
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? removeIssueFromBuckets(old, id) : old,
);
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { prevList, parentIssueId: deleted?.parent_issue_id };
},
@@ -239,17 +237,13 @@ export function useBatchUpdateIssues() {
}) => api.batchUpdateIssues(ids, updates),
onMutate: async ({ ids, updates }) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old
? {
...old,
issues: old.issues.map((i) =>
ids.includes(i.id) ? { ...i, ...updates } : i,
),
}
: old,
);
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
if (!old) return old;
let next = old;
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
return next;
});
return { prevList };
},
onError: (_err, _vars, ctx) => {
@@ -268,24 +262,19 @@ export function useBatchDeleteIssues() {
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
onMutate: async (ids) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const idSet = new Set(ids);
const parentIssueIds = new Set(
prevList?.issues
.filter((i) => idSet.has(i.id) && i.parent_issue_id)
.map((i) => i.parent_issue_id!) ?? [],
);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const parentIssueIds = new Set<string>();
if (prevList) {
for (const id of ids) {
const loc = findIssueLocation(prevList, id);
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
}
}
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const doneDeleted = old.issues.filter(
(i) => idSet.has(i.id) && i.status === "done",
).length;
return {
...old,
issues: old.issues.filter((i) => !idSet.has(i.id)),
total: old.total - ids.length,
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
};
let next = old;
for (const id of ids) next = removeIssueFromBuckets(next, id);
return next;
});
return { prevList, parentIssueIds };
},

View File

@@ -1,6 +1,7 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type { ListIssuesParams } from "../types";
import type { IssueStatus, ListIssuesParams, ListIssuesCache } from "../types";
import { BOARD_STATUSES } from "./config";
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
@@ -23,33 +24,55 @@ export const issueKeys = {
usage: (issueId: string) => ["issues", "usage", issueId] as const,
};
export type MyIssuesFilter = Pick<ListIssuesParams, "assignee_id" | "assignee_ids" | "creator_id">;
export type MyIssuesFilter = Pick<
ListIssuesParams,
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
>;
export const CLOSED_PAGE_SIZE = 50;
/** Page size per status column. */
export const ISSUE_PAGE_SIZE = 50;
/** Statuses the issues/my-issues pages paginate. Cancelled is intentionally excluded — it has never been surfaced in the list/board views. */
export const PAGINATED_STATUSES: readonly IssueStatus[] = BOARD_STATUSES;
/** Flatten a bucketed response to a single Issue[] for consumers that want the whole list. */
export function flattenIssueBuckets(data: ListIssuesCache) {
const out = [];
for (const status of PAGINATED_STATUSES) {
const bucket = data.byStatus[status];
if (bucket) out.push(...bucket.issues);
}
return out;
}
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_STATUSES.map((status) =>
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }),
),
);
const byStatus: ListIssuesCache["byStatus"] = {};
PAGINATED_STATUSES.forEach((status, i) => {
const res = responses[i]!;
byStatus[status] = { issues: res.issues, total: res.total };
});
return { byStatus };
}
/**
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
* CACHE SHAPE NOTE: The raw cache stores {@link ListIssuesCache} (buckets keyed
* by status, each with `{ issues, total }`), and `select` flattens it to
* `Issue[]` for consumers. Mutations and ws-updaters must use
* `setQueryData<ListIssuesCache>(...)` and preserve the byStatus shape.
*
* Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
* to paginate additional done items into the cache.
* Fetches the first page of each paginated status in parallel. Use
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
*/
export function issueListOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.list(wsId),
queryFn: async () => {
const [openRes, closedRes] = await Promise.all([
api.listIssues({ open_only: true }),
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
]);
return {
issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total,
doneTotal: closedRes.total,
};
},
select: (data) => data.issues,
queryFn: () => fetchFirstPages(),
select: flattenIssueBuckets,
});
}
@@ -64,23 +87,8 @@ export function myIssueListOptions(
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),
queryFn: async () => {
const [openRes, closedRes] = await Promise.all([
api.listIssues({ open_only: true, ...filter }),
api.listIssues({
status: "done",
limit: CLOSED_PAGE_SIZE,
offset: 0,
...filter,
}),
]);
return {
issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total,
doneTotal: closedRes.total,
};
},
select: (data) => data.issues,
queryFn: () => fetchFirstPages(filter),
select: flattenIssueBuckets,
});
}

View File

@@ -1,22 +1,22 @@
import type { QueryClient } from "@tanstack/react-query";
import { issueKeys } from "./queries";
import {
addIssueToBuckets,
findIssueLocation,
patchIssueInBuckets,
removeIssueFromBuckets,
} from "./cache-helpers";
import type { Issue } from "../types";
import type { ListIssuesResponse } from "../types";
import type { ListIssuesCache } from "../types";
export function onIssueCreated(
qc: QueryClient,
wsId: string,
issue: Issue,
) {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old || old.issues.some((i) => i.id === issue.id)) return old;
return {
...old,
issues: [...old.issues, issue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
};
});
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? addIssueToBuckets(old, issue) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
@@ -32,36 +32,20 @@ export function onIssueUpdated(
// Look up the OLD parent before mutating list state, so we can keep
// the parent's children cache in sync (powers the sub-issues list
// shown on the parent issue page).
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
const oldParentId =
detailData?.parent_issue_id ??
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
(listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ??
null;
// The NEW parent comes from the WS payload when parent_issue_id changed
const newParentId = issue.parent_issue_id ?? null;
const parentChanged =
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const prev = old.issues.find((i) => i.id === issue.id);
const wasDone = prev?.status === "done";
const isDone = issue.status === "done";
// Only adjust doneTotal when status field is present and actually changed
let doneDelta = 0;
if (issue.status !== undefined) {
if (!wasDone && isDone) doneDelta = 1;
else if (wasDone && !isDone) doneDelta = -1;
}
return {
...old,
issues: old.issues.map((i) =>
i.id === issue.id ? { ...i, ...issue } : i,
),
doneTotal: (old.doneTotal ?? 0) + doneDelta,
};
});
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, issue.id, issue) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
@@ -94,19 +78,12 @@ export function onIssueDeleted(
issueId: string,
) {
// Look up the issue before removing it to check for parent_issue_id
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const deleted = listData?.issues.find((i) => i.id === issueId);
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const del = old.issues.find((i) => i.id === issueId);
return {
...old,
issues: old.issues.filter((i) => i.id !== issueId),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
};
});
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? removeIssueFromBuckets(old, issueId) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });

View File

@@ -38,14 +38,29 @@ export interface ListIssuesParams {
assignee_id?: string;
assignee_ids?: string[];
creator_id?: string;
project_id?: string;
open_only?: boolean;
}
/** Raw backend response shape for `GET /api/issues`. */
export interface ListIssuesResponse {
issues: Issue[];
total: number;
/** True total of done issues in the workspace (for load-more pagination). Not returned by backend API — set by the frontend query function. */
doneTotal?: number;
}
/** Per-status bucket in the paginated issue cache. `total` is the server count (all pages), not the length of `issues`. */
export interface IssueStatusBucket {
issues: Issue[];
total: number;
}
/**
* Frontend cache shape for the issue list. Data is bucketed by status so
* each column can paginate independently. Assembled from per-status
* `api.listIssues` responses by the query functions in `issues/queries.ts`.
*/
export interface ListIssuesCache {
byStatus: Partial<Record<IssueStatus, IssueStatusBucket>>;
}
export interface SearchIssueResult extends Issue {

View File

@@ -6,7 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, AgentTask, Issue } from "@multica/core/types";
const mockListAgentTasks = vi.hoisted(() => vi.fn());
const mockListIssues = vi.hoisted(() => vi.fn());
const mockGetIssue = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
@@ -25,7 +25,7 @@ vi.mock("@multica/core/paths", async () => {
vi.mock("@multica/core/api", () => ({
api: {
listAgentTasks: (...args: unknown[]) => mockListAgentTasks(...args),
listIssues: (...args: unknown[]) => mockListIssues(...args),
getIssue: (...args: unknown[]) => mockGetIssue(...args),
},
}));
@@ -66,13 +66,10 @@ const agent: Agent = {
function renderTasksTab(tasks: AgentTask[], issues: Issue[]) {
mockListAgentTasks.mockResolvedValue(tasks);
mockListIssues.mockImplementation(
({ open_only, status }: { open_only?: boolean; status?: string }) =>
Promise.resolve({
issues: open_only ? issues : status === "done" ? [] : [],
total: open_only ? issues.length : 0,
}),
);
mockGetIssue.mockImplementation((id: string) => {
const found = issues.find((i) => i.id === id);
return found ? Promise.resolve(found) : Promise.reject(new Error("not found"));
});
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -1,14 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { useMemo, useState, useEffect } from "react";
import { ListTodo } from "lucide-react";
import type { Agent, AgentTask } from "@multica/core/types";
import type { Agent, AgentTask, Issue } from "@multica/core/types";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { issueListOptions } from "@multica/core/issues/queries";
import { useQuery } from "@tanstack/react-query";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { useQueries } from "@tanstack/react-query";
import { AppLink } from "../../../navigation";
import { taskStatusConfig } from "../../config";
@@ -17,7 +17,6 @@ export function TasksTab({ agent }: { agent: Agent }) {
const [loading, setLoading] = useState(true);
const wsId = useWorkspaceId();
const paths = useWorkspacePaths();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
useEffect(() => {
setLoading(true);
@@ -28,6 +27,26 @@ export function TasksTab({ agent }: { agent: Agent }) {
.finally(() => setLoading(false));
}, [agent.id]);
// Resolve each task's issue via its own cached detail query. A task's
// issue may or may not be in the paginated issue-list cache, so going
// through `issueDetailOptions` is the reliable lookup path (and it shares
// the same cache as the issue detail page).
const issueIds = useMemo(
() => Array.from(new Set(tasks.map((t) => t.issue_id))),
[tasks],
);
const issueQueries = useQueries({
queries: issueIds.map((id) => issueDetailOptions(wsId, id)),
});
const issueMap = useMemo(() => {
const map = new Map<string, Issue>();
issueQueries.forEach((q, i) => {
const id = issueIds[i]!;
if (q.data) map.set(id, q.data);
});
return map;
}, [issueQueries, issueIds]);
if (loading) {
return (
<div className="space-y-2">
@@ -58,8 +77,6 @@ export function TasksTab({ agent }: { agent: Agent }) {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
const issueMap = new Map(issues.map((i) => [i.id, i]));
return (
<div className="space-y-4">
<div>

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { issueKeys } from "@multica/core/issues/queries";
import { issueKeys, PAGINATED_STATUSES } from "@multica/core/issues/queries";
import type { IssueStatus, ListIssuesCache } from "@multica/core/types";
import type { QueryClient } from "@tanstack/react-query";
// Mock the workspace id singleton — items() reads it imperatively.
@@ -28,10 +29,15 @@ function fakeQc(data: {
const map = new Map<string, unknown>();
map.set(JSON.stringify(workspaceKeys.members("ws-1")), data.members ?? []);
map.set(JSON.stringify(workspaceKeys.agents("ws-1")), data.agents ?? []);
map.set(JSON.stringify(issueKeys.list("ws-1")), {
issues: data.issues ?? [],
total: data.issues?.length ?? 0,
});
const byStatus: ListIssuesCache["byStatus"] = {};
for (const status of PAGINATED_STATUSES) {
const bucket = (data.issues ?? []).filter((i) => i.status === status);
byStatus[status as IssueStatus] = { issues: bucket as never, total: bucket.length };
}
map.set(
JSON.stringify(issueKeys.list("ws-1")),
{ byStatus } satisfies ListIssuesCache,
);
return {
getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)),
} as unknown as QueryClient;

View File

@@ -12,10 +12,10 @@ import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import type { QueryClient } from "@tanstack/react-query";
import { getCurrentWsId } from "@multica/core/platform";
import { issueKeys } from "@multica/core/issues/queries";
import { flattenIssueBuckets, issueKeys } from "@multica/core/issues/queries";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@multica/core/types";
import type { Issue, ListIssuesCache, MemberWithUser, Agent } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { StatusIcon } from "../../issues/components/status-icon";
import { Badge } from "@multica/ui/components/ui/badge";
@@ -254,8 +254,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? [];
const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? [];
const cachedIssues: Issue[] =
qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? [];
const cachedResponse = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : [];
const q = query.toLowerCase();

View File

@@ -18,7 +18,7 @@ import { arrayMove } from "@dnd-kit/sortable";
import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
import { useLoadMoreByStatus } from "@multica/core/issues/mutations";
import type { MyIssuesFilter } from "@multica/core/issues/queries";
import {
DropdownMenu,
@@ -99,17 +99,14 @@ const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
export function BoardView({
issues,
allIssues,
visibleStatuses,
hiddenStatuses,
onMoveIssue,
childProgressMap = EMPTY_PROGRESS_MAP,
doneTotal: doneTotalOverride,
myIssuesScope,
myIssuesFilter,
}: {
issues: Issue[];
allIssues: Issue[];
visibleStatuses: IssueStatus[];
hiddenStatuses: IssueStatus[];
onMoveIssue: (
@@ -118,18 +115,15 @@ export function BoardView({
newPosition?: number
) => void;
childProgressMap?: Map<string, ChildProgress>;
/** Override the done-column count (e.g. with a server-filtered total). */
doneTotal?: number;
/** When set, use the My Issues load-more hook instead of the workspace one. */
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
myIssuesScope?: string;
myIssuesFilter?: MyIssuesFilter;
}) {
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const myIssuesOpts = myIssuesScope ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } : undefined;
const { loadMore, hasMore, isLoading: loadingMore, doneTotal: hookDoneTotal } =
useLoadMoreDoneIssues(myIssuesOpts);
const displayDoneTotal = doneTotalOverride ?? hookDoneTotal;
const myIssuesOpts = myIssuesScope
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
: undefined;
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
@@ -287,25 +281,20 @@ export function BoardView({
>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{visibleStatuses.map((status) => (
<BoardColumn
<PaginatedBoardColumn
key={status}
status={status}
issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
totalCount={status === "done" ? displayDoneTotal : undefined}
footer={
status === "done" && hasMore ? (
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />
) : undefined
}
myIssuesOpts={myIssuesOpts}
/>
))}
{hiddenStatuses.length > 0 && (
<HiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
issues={allIssues}
myIssuesOpts={myIssuesOpts}
/>
)}
</div>
@@ -321,14 +310,46 @@ export function BoardView({
);
}
function PaginatedBoardColumn({
status,
issueIds,
issueMap,
childProgressMap,
myIssuesOpts,
}: {
status: IssueStatus;
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap?: Map<string, ChildProgress>;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
status,
myIssuesOpts,
);
return (
<BoardColumn
status={status}
issueIds={issueIds}
issueMap={issueMap}
childProgressMap={childProgressMap}
totalCount={total}
footer={
hasMore ? (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
) : undefined
}
/>
);
}
function HiddenColumnsPanel({
hiddenStatuses,
issues,
myIssuesOpts,
}: {
hiddenStatuses: IssueStatus[];
issues: Issue[];
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
const viewStoreApi = useViewStoreApi();
return (
<div className="flex w-[240px] shrink-0 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
@@ -337,47 +358,57 @@ function HiddenColumnsPanel({
</span>
</div>
<div className="flex-1 space-y-0.5">
{hiddenStatuses.map((status) => {
const cfg = STATUS_CONFIG[status];
const count = issues.filter((i) => i.status === status).length;
return (
<div
key={status}
className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm">{cfg.label}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{count}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
viewStoreApi.getState().showStatus(status)
}
>
<Eye className="size-3.5" />
Show column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
{hiddenStatuses.map((status) => (
<HiddenColumnRow
key={status}
status={status}
myIssuesOpts={myIssuesOpts}
/>
))}
</div>
</div>
);
}
function HiddenColumnRow({
status,
myIssuesOpts,
}: {
status: IssueStatus;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
const cfg = STATUS_CONFIG[status];
const viewStoreApi = useViewStoreApi();
const { total } = useLoadMoreByStatus(status, myIssuesOpts);
return (
<div className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50">
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm">{cfg.label}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{total}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => viewStoreApi.getState().showStatus(status)}
>
<Eye className="size-3.5" />
Show column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);

View File

@@ -362,11 +362,10 @@ describe("IssuesPage (shared)", () => {
it("renders issue titles after data loads", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(
params?.open_only
? { issues: mockIssues, total: mockIssues.length }
: { issues: [], total: 0 },
),
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
total: mockIssues.filter((i) => i.status === params?.status).length,
}),
);
renderWithQuery(<IssuesPage />);
@@ -378,11 +377,10 @@ describe("IssuesPage (shared)", () => {
it("renders board column headers", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(
params?.open_only
? { issues: mockIssues, total: mockIssues.length }
: { issues: [], total: 0 },
),
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
total: mockIssues.filter((i) => i.status === params?.status).length,
}),
);
renderWithQuery(<IssuesPage />);
@@ -394,11 +392,10 @@ describe("IssuesPage (shared)", () => {
it("shows workspace breadcrumb with 'Issues' label", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(
params?.open_only
? { issues: mockIssues, total: mockIssues.length }
: { issues: [], total: 0 },
),
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
total: mockIssues.filter((i) => i.status === params?.status).length,
}),
);
renderWithQuery(<IssuesPage />);

View File

@@ -164,7 +164,6 @@ export function IssuesPage() {
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={scopedIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}

View File

@@ -6,7 +6,7 @@ import { Accordion } from "@base-ui/react/accordion";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import type { Issue, IssueStatus } from "@multica/core/types";
import { useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
import { useLoadMoreByStatus } from "@multica/core/issues/mutations";
import type { MyIssuesFilter } from "@multica/core/issues/queries";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import { useModalStore } from "@multica/core/modals";
@@ -23,16 +23,13 @@ export function ListView({
issues,
visibleStatuses,
childProgressMap = EMPTY_PROGRESS_MAP,
doneTotal: doneTotalOverride,
myIssuesScope,
myIssuesFilter,
}: {
issues: Issue[];
visibleStatuses: IssueStatus[];
childProgressMap?: Map<string, ChildProgress>;
/** Override the done-group count (e.g. with a server-filtered total). */
doneTotal?: number;
/** When set, use the My Issues load-more hook instead of the workspace one. */
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
myIssuesScope?: string;
myIssuesFilter?: MyIssuesFilter;
}) {
@@ -44,13 +41,6 @@ export function ListView({
const toggleListCollapsed = useViewStore(
(s) => s.toggleListCollapsed
);
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
const select = useIssueSelectionStore((s) => s.select);
const deselect = useIssueSelectionStore((s) => s.deselect);
const myIssuesOpts = myIssuesScope ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } : undefined;
const { loadMore, hasMore, isLoading: loadingMore, doneTotal: hookDoneTotal } =
useLoadMoreDoneIssues(myIssuesOpts);
const displayDoneTotal = doneTotalOverride ?? hookDoneTotal;
const issuesByStatus = useMemo(() => {
const map = new Map<IssueStatus, Issue[]>();
@@ -69,6 +59,10 @@ export function ListView({
[visibleStatuses, listCollapsedStatuses]
);
const myIssuesOpts = myIssuesScope
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
: undefined;
return (
<div className="flex-1 min-h-0 overflow-y-auto p-2">
<Accordion.Root
@@ -85,86 +79,111 @@ export function ListView({
}
}}
>
{visibleStatuses.map((status) => {
const cfg = STATUS_CONFIG[status];
const statusIssues = issuesByStatus.get(status) ?? [];
const statusIssueIds = statusIssues.map((i) => i.id);
const selectedCount = statusIssueIds.filter((id) => selectedIds.has(id)).length;
const allSelected = statusIssues.length > 0 && selectedCount === statusIssues.length;
const someSelected = selectedCount > 0;
return (
<Accordion.Item key={status} value={status}>
<Accordion.Header className="group/header flex h-10 items-center rounded-lg bg-muted/40 transition-colors hover:bg-accent/30">
<div className="pl-3 flex items-center">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected && !allSelected;
}}
onChange={() => {
if (allSelected) {
deselect(statusIssueIds);
} else {
select(statusIssueIds);
}
}}
className="cursor-pointer accent-primary"
/>
</div>
<Accordion.Trigger className="group/trigger flex flex-1 items-center gap-2 px-2 h-full text-left outline-none">
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-aria-expanded/trigger:rotate-90" />
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
<StatusIcon status={status} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">
{status === "done" ? displayDoneTotal : statusIssues.length}
</span>
</Accordion.Trigger>
<div className="pr-2">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground opacity-0 group-hover/header:opacity-100 transition-opacity"
onClick={() =>
useModalStore
.getState()
.open("create-issue", { status })
}
/>
}
>
<Plus className="size-3.5" />
</TooltipTrigger>
<TooltipContent>Add issue</TooltipContent>
</Tooltip>
</div>
</Accordion.Header>
<Accordion.Panel className="pt-1">
{statusIssues.length > 0 ? (
<>
{statusIssues.map((issue) => (
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
))}
{status === "done" && hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />
)}
</>
) : (
<p className="py-6 text-center text-xs text-muted-foreground">
No issues
</p>
)}
</Accordion.Panel>
</Accordion.Item>
);
})}
{visibleStatuses.map((status) => (
<StatusAccordionItem
key={status}
status={status}
issues={issuesByStatus.get(status) ?? []}
childProgressMap={childProgressMap}
myIssuesOpts={myIssuesOpts}
/>
))}
</Accordion.Root>
</div>
);
}
function StatusAccordionItem({
status,
issues,
childProgressMap,
myIssuesOpts,
}: {
status: IssueStatus;
issues: Issue[];
childProgressMap: Map<string, ChildProgress>;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
const cfg = STATUS_CONFIG[status];
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
const select = useIssueSelectionStore((s) => s.select);
const deselect = useIssueSelectionStore((s) => s.deselect);
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
status,
myIssuesOpts,
);
const issueIds = issues.map((i) => i.id);
const selectedCount = issueIds.filter((id) => selectedIds.has(id)).length;
const allSelected = issues.length > 0 && selectedCount === issues.length;
const someSelected = selectedCount > 0;
return (
<Accordion.Item value={status}>
<Accordion.Header className="group/header flex h-10 items-center rounded-lg bg-muted/40 transition-colors hover:bg-accent/30">
<div className="pl-3 flex items-center">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected && !allSelected;
}}
onChange={() => {
if (allSelected) {
deselect(issueIds);
} else {
select(issueIds);
}
}}
className="cursor-pointer accent-primary"
/>
</div>
<Accordion.Trigger className="group/trigger flex flex-1 items-center gap-2 px-2 h-full text-left outline-none">
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-aria-expanded/trigger:rotate-90" />
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
<StatusIcon status={status} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">{total}</span>
</Accordion.Trigger>
<div className="pr-2">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground opacity-0 group-hover/header:opacity-100 transition-opacity"
onClick={() =>
useModalStore
.getState()
.open("create-issue", { status })
}
/>
}
>
<Plus className="size-3.5" />
</TooltipTrigger>
<TooltipContent>Add issue</TooltipContent>
</Tooltip>
</div>
</Accordion.Header>
<Accordion.Panel className="pt-1">
{issues.length > 0 ? (
<>
{issues.map((issue) => (
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
))}
{hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
)}
</>
) : (
<p className="py-6 text-center text-xs text-muted-foreground">
No issues
</p>
)}
</Accordion.Panel>
</Accordion.Item>
);
}

View File

@@ -21,7 +21,7 @@ import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar
import { useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store";
import { useWorkspaceId } from "@multica/core/hooks";
import { myIssueListOptions, childIssueProgressOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
import { useUpdateIssue, useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { myIssuesViewStore } from "@multica/core/issues/stores/my-issues-view-store";
import { PageHeader } from "../../layout/page-header";
import { MyIssuesHeader } from "./my-issues-header";
@@ -71,8 +71,6 @@ export function MyIssuesPage() {
myIssueListOptions(wsId, scope, filter),
);
const { doneTotal } = useLoadMoreDoneIssues({ scope, filter });
// Apply status/priority filters from view store
const issues = useMemo(
() =>
@@ -190,12 +188,10 @@ export function MyIssuesPage() {
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={myIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
doneTotal={doneTotal}
myIssuesScope={scope}
myIssuesFilter={filter}
/>
@@ -204,7 +200,6 @@ export function MyIssuesPage() {
issues={issues}
visibleStatuses={visibleStatuses}
childProgressMap={childProgressMap}
doneTotal={doneTotal}
myIssuesScope={scope}
myIssuesFilter={filter}
/>

View File

@@ -12,7 +12,7 @@ import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { pinListOptions } from "@multica/core/pins";
import { useCreatePin, useDeletePin } from "@multica/core/pins";
import { issueListOptions, childIssueProgressOptions } from "@multica/core/issues/queries";
import { myIssueListOptions, childIssueProgressOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -94,7 +94,15 @@ function PropRow({
const projectViewStore = createIssueViewStore("project_issues_view");
function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
function ProjectIssuesContent({
projectIssues,
scope,
filter,
}: {
projectIssues: Issue[];
scope: string;
filter: MyIssuesFilter;
}) {
const wsId = useWorkspaceId();
const viewMode = useViewStore((s) => s.viewMode);
const statusFilters = useViewStore((s) => s.statusFilters);
@@ -107,10 +115,6 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
() => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }),
[projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
);
const doneColumnCount = useMemo(
() => projectIssues.filter((issue) => issue.status === "done").length,
[projectIssues],
);
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
@@ -158,19 +162,20 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={projectIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
doneTotal={doneColumnCount}
myIssuesScope={scope}
myIssuesFilter={filter}
/>
) : (
<ListView
issues={issues}
visibleStatuses={visibleStatuses}
childProgressMap={childProgressMap}
doneTotal={doneColumnCount}
myIssuesScope={scope}
myIssuesFilter={filter}
/>
)}
</div>
@@ -189,7 +194,14 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const workspace = useCurrentWorkspace();
const workspaceName = workspace?.name;
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
const projectScope = `project:${projectId}`;
const projectFilter = useMemo<MyIssuesFilter>(
() => ({ project_id: projectId }),
[projectId],
);
const { data: projectIssues = [] } = useQuery(
myIssueListOptions(wsId, projectScope, projectFilter),
);
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
@@ -231,11 +243,6 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery));
const projectIssues = useMemo(
() => allIssues.filter((i) => i.project_id === projectId),
[allIssues, projectId],
);
const handleUpdateField = useCallback(
(data: Parameters<typeof updateProject.mutate>[0] extends { id: string } & infer R ? R : never) => {
if (!project) return;
@@ -269,7 +276,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
return <div className="flex items-center justify-center h-full text-muted-foreground">Project not found</div>;
}
const issueMetrics = getProjectIssueMetrics(project, projectIssues);
const issueMetrics = getProjectIssueMetrics(project);
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
const priorityCfg = PROJECT_PRIORITY_CONFIG[project.priority];
@@ -573,7 +580,11 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
<ViewStoreProvider store={projectViewStore}>
<IssuesHeader scopedIssues={projectIssues} />
<ProjectIssuesContent projectIssues={projectIssues} />
<ProjectIssuesContent
projectIssues={projectIssues}
scope={projectScope}
filter={projectFilter}
/>
<BatchActionToolbar />
</ViewStoreProvider>
</div>

View File

@@ -1,47 +1,13 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@multica/core/types";
import { getProjectIssueMetrics } from "./project-issue-metrics";
function makeIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Test issue",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "member-1",
parent_issue_id: null,
project_id: "project-1",
position: 0,
due_date: null,
created_at: "2026-04-10T00:00:00Z",
updated_at: "2026-04-10T00:00:00Z",
...overrides,
};
}
describe("getProjectIssueMetrics", () => {
it("uses project totals for progress and project-local done issues for the kanban done count", () => {
const metrics = getProjectIssueMetrics(
{ issue_count: 9, done_count: 5 },
[
makeIssue({ id: "issue-1", status: "done" }),
makeIssue({ id: "issue-2", status: "done" }),
makeIssue({ id: "issue-3", status: "cancelled" }),
makeIssue({ id: "issue-4", status: "todo" }),
],
);
it("surfaces project-level totals from the project record", () => {
const metrics = getProjectIssueMetrics({ issue_count: 9, done_count: 5 });
expect(metrics).toEqual({
totalCount: 9,
completedCount: 5,
doneColumnCount: 2,
});
});
});

View File

@@ -1,12 +1,10 @@
import type { Issue, Project } from "@multica/core/types";
import type { Project } from "@multica/core/types";
export function getProjectIssueMetrics(
project: Pick<Project, "issue_count" | "done_count">,
projectIssues: Issue[],
) {
return {
totalCount: project.issue_count,
completedCount: project.done_count,
doneColumnCount: projectIssues.filter((issue) => issue.status === "done").length,
};
}

View File

@@ -81,7 +81,9 @@ vi.mock("@multica/core/paths", () => ({
}));
vi.mock("@multica/core/issues/queries", () => ({
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
issueDetailOptions: (_wsId: string, id: string) => ({
queryKey: ["issues", "ws-test", "detail", id],
}),
}));
vi.mock("@multica/core/workspace/queries", () => ({
@@ -94,12 +96,24 @@ vi.mock("@multica/core/modals", () => ({
}),
}));
function resolveIssue(key: readonly unknown[]) {
// issueDetailOptions key shape: ["issues", wsId, "detail", id]
if (key[0] === "issues" && key[2] === "detail") {
const id = key[3];
return mockAllIssues.current.find((i) => i.id === id);
}
return undefined;
}
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: { queryKey: readonly unknown[] }) => {
useQuery: (opts: { queryKey: readonly unknown[]; enabled?: boolean }) => {
const key = opts.queryKey;
if (key[0] === "workspaces") return { data: mockWorkspaces.current };
return { data: mockAllIssues.current };
if (opts.enabled === false) return { data: undefined };
return { data: resolveIssue(key) };
},
useQueries: (opts: { queries: Array<{ queryKey: readonly unknown[] }> }) =>
opts.queries.map((q) => ({ data: resolveIssue(q.queryKey) })),
}));
vi.mock("../navigation", () => ({

View File

@@ -24,12 +24,12 @@ import {
type LucideIcon,
} from "lucide-react";
import { Command as CommandPrimitive } from "cmdk";
import { useQuery } from "@tanstack/react-query";
import { useQueries, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
import { api } from "@multica/core/api";
import { useRecentIssuesStore } from "@multica/core/issues/stores";
import { issueListOptions } from "@multica/core/issues/queries";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core";
import { paths, useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import type { WorkspacePaths } from "@multica/core/paths";
@@ -140,18 +140,22 @@ export function SearchCommand() {
const recentItems = useRecentIssuesStore((s) => s.items);
const wsId = useWorkspaceId();
const p: WorkspacePaths = useWorkspacePaths();
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
const { theme, setTheme } = useTheme();
const currentWorkspace = useCurrentWorkspace();
const { data: workspaces = [] } = useQuery(workspaceListOptions());
const recentIssues = useMemo(() => {
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
return recentItems.flatMap((item) => {
const issue = issueMap.get(item.id);
return issue ? [issue] : [];
});
}, [recentItems, allIssues]);
// Resolve each recent issue via its cached detail entry. Recent items are
// typically already in the detail cache because the user has opened them;
// if not, this triggers a lookup per id so Recent never depends on whether
// the issue falls inside the paginated list cache.
const recentDetailQueries = useQueries({
queries: recentItems.map((item) => issueDetailOptions(wsId, item.id)),
});
const recentIssues = useMemo(
() =>
recentDetailQueries.flatMap((q) => (q.data ? [q.data] : [])),
[recentDetailQueries],
);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
@@ -171,13 +175,15 @@ export function SearchCommand() {
// Detect if current route is an issue detail page — /{slug}/issues/{id}.
// Falls back to null on any other route; used to gate issue-specific commands.
const currentIssue = useMemo(() => {
const currentIssueId = useMemo(() => {
const match = pathname.match(/\/issues\/([^/]+)$/);
const raw = match?.[1];
if (!raw) return null;
const id = decodeURIComponent(raw);
return allIssues.find((i) => i.id === id) ?? null;
}, [pathname, allIssues]);
return raw ? decodeURIComponent(raw) : null;
}, [pathname]);
const { data: currentIssue = null } = useQuery({
...issueDetailOptions(wsId, currentIssueId ?? ""),
enabled: !!currentIssueId,
});
const commands = useMemo<CommandItem[]>(() => {
const activeThemeCheck = (value: ThemeValue) =>