mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 04:08:53 +02:00
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint Collapse the issue update/batch enqueue copies into one service predicate service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run endpoint POST /api/issues/preview-trigger so the four entry points stop drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate stays at the HTTP boundary: write paths inject allow-all, preview injects the real gate so it never leaks a private agent's readiness. Add suppress_run to issue update/batch: the change applies but no run starts. Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign / isSquadLeaderReady. service.Create and the comment trigger chain are untouched. Tests: preview behavior, preview<->write-path match, batch aggregation, member no-trigger, suppress_run skip, malformed-body 400. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(issues): inject handoff note into assigned runs via first-class task field Add an optional handoff_note carried by issue assign/promote into the run's opening prompt and issue_context.md, via a dedicated agent_task_queue column (migration 122) and a daemon assignment-handoff render branch — never a fabricated comment, never trigger_comment_id (MUL-3375 §6.1). Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff public variants and dispatchIssueRun; suppress_run or a parked write drops it (no run = nothing to inject). Soft version gate: MinHandoffCLIVersion + HandoffSupported, surfaced per-trigger as handoff_supported in the preview so the UI can gray the note box on old daemons; the assignment never hard-fails. Tests: daemon prompt + issue_context render via the assignment branch (not quick-create/comment), version helper matrix, note persists on the task, suppressed assign enqueues nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(issues): leave a display-only handoff record on the timeline When an assign/promote with a handoff note starts a run, write one type='handoff' timeline record via TaskService.RecordHandoff — a direct Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so it never reaches triggerTasksForComment and cannot start a second run (MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who handed off; body is the note. Migration 123 admits the 'handoff' comment type. Recorded only on a real run start: suppress_run or a parked write writes nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace is gated on an actual dispatch. Test: exactly one handoff record on assign-with-note, exactly one task (no re-trigger), and no record when suppressed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(issues): frontend plumbing for issue-trigger preview + handoff (core) Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback), the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the 'handoff' CommentType, and stripping of the control fields from optimistic update/batch cache patches (MUL-3375 §9). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(issues): exclude handoff records from new-comment counting type='handoff' is a display-only timeline record, not conversation. Exclude it from CountNewCommentsSince so a handoff note never inflates the count of "new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics already excludes it (RecordHandoff is a direct write that emits no analytics event), and the comment-trigger path is already bypassed. Test: a handoff record does not bump the new-comment count; a real comment does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop) Wire the §9 frontend onto the preview endpoint + handoff fields: - Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type; the over-eager nag is gone. Backlog awareness is now a passive label. - RunConfirmModal: single assign + batch assign/status route here. Shows the backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 — then applies via update/batch. No frontend guessing. - create modal: passive CreateRunHint ("将启动 @X" / backlog parked). - single status change stays a direct apply (unchanged). - timeline: render type='handoff' as a distinct, non-interactive handoff card. - i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action keys; locale parity green. Tests: use-issue-actions (assign → run-confirm modal, member → direct), create-issue + comment-card suites updated/green; views typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * test(issues): use a valid anchor in the handoff count-exclusion test CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and excludes every row, so an empty anchor made the control assertion read 0. The production caller always passes a real anchor — mirror that with a non-matching sentinel uuid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * test(core): preview schema malformed/missing/null fallback coverage Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed parse, top-level + item default fills (empty/older backend), and fallback to { triggers: [], total_count: 0 } for malformed shapes, a dropped required issue_id, a wrong-typed total_count, and null/non-object bodies — so the four entry points degrade to "nothing will start" instead of throwing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * refactor(issues): remove display-only handoff timeline record (留痕) The handoff "留痕" timeline record (type='handoff' comment written on run start) was judged superfluous and dropped per product call. This removes only the display-only trace; the handoff NOTE injection into the run's opening prompt + issue_context.md is untouched. - backend: drop RecordHandoff + its call in dispatchIssueRun - db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and migration 123 (comment_type_check reverts to the 4-type set from 001); no production data exists for this unreleased feature - frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card i18n (all locales) - tests: drop handoff_count_test.go and the record-write assertions in issue_trigger_preview_test.go (note-injection tests retained) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(issues): dismissable run-confirm modal + team-handoff copy Two fixes to the pre-trigger confirm modal (MUL-3375). 1. Dismissable: switch RunConfirmModal from AlertDialog to the standard shadcn Dialog so it has the close (X) button + Esc + click-outside. Previously the only choices were "start" / "don't start now" with no way to abort the action entirely; dismissing now cancels with no write. 2. Copy: rework the action-surface wording away from the backend term "run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on record surfaces). Unifies the note's three names to "交接说明", and parallels the rewrite across en/ja/ko. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * chore(agent): bump handoff note min CLI version to 0.3.28 The daemon release that renders handoff notes ships in 0.3.28 (0.3.27 was the prior tag), so move the soft-gate threshold up. Below this the note is silently dropped and the frontend grays the note box — assignment is never blocked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(issues): skip run-confirm when batch-moving issues to backlog A move into backlog never starts a run (service/issue_trigger.go), so the pre-trigger confirm modal degenerated to an empty "won't start" box with a single Apply button — pure friction. Apply directly instead, matching the single-issue status path. Other target statuses still route through the modal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(issues): refine pre-trigger preview hint and copy - Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the property toolbar. It was sharing the footer button row and, lacking a width constraint, reflowed the submit buttons whenever it appeared. Restyle to a borderless, comment-style avatar+caption that is purely a caption (non-interactive avatar). - Distinguish squad from agent in the pre-trigger copy: a squad's leader evaluates and delegates rather than "starting work" itself. Add will_start_named_squad / will_start_squad / create_will_start_squad across en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and branch run-confirm + the create hint on squad assignees. - Bold the assignee name in the run-confirm headline via a language-safe sentinel split (no per-language prefix/suffix keys). - Align zh "开始处理" → "开始工作" on the single-assign copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(issues): stub ActorAvatar in create-issue suite CreateRunHint now renders an ActorAvatar for agent/squad assignees, which pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation hook tree. This form-focused suite only stubbed getActorName, so the squad-forwarding test crashed with "getActorInitials is not a function". Stub the avatar inert — its own behavior is covered elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Walt <walt@multica.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
539 lines
20 KiB
TypeScript
539 lines
20 KiB
TypeScript
import { keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
|
||
import { api } from "../api";
|
||
import type {
|
||
GroupedIssuesResponse,
|
||
Issue,
|
||
IssueStatus,
|
||
ListGroupedIssuesParams,
|
||
ListIssuesParams,
|
||
ListIssuesCache,
|
||
} from "../types";
|
||
import { BOARD_STATUSES } from "./config";
|
||
|
||
export interface IssueSortParam {
|
||
sort_by?: ListIssuesParams["sort_by"];
|
||
sort_direction?: ListIssuesParams["sort_direction"];
|
||
date_field?: ListIssuesParams["date_field"];
|
||
date_start?: ListIssuesParams["date_start"];
|
||
date_end?: ListIssuesParams["date_end"];
|
||
}
|
||
|
||
export const issueKeys = {
|
||
all: (wsId: string) => ["issues", wsId] as const,
|
||
/** PREFIX for invalidation — no sort. */
|
||
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
|
||
/** FULL KEY for queryOptions — includes sort. */
|
||
listSorted: (wsId: string, sort?: IssueSortParam) =>
|
||
[...issueKeys.list(wsId), sort ?? {}] as const,
|
||
assigneeGroupsAll: (wsId: string) =>
|
||
[...issueKeys.all(wsId), "assignee-groups"] as const,
|
||
assigneeGroups: (wsId: string, filter: AssigneeGroupedIssuesFilter) =>
|
||
[...issueKeys.assigneeGroupsAll(wsId), filter] as const,
|
||
/** All "my issues" queries — use for bulk invalidation. */
|
||
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
|
||
/** PREFIX for per-scope invalidation — no sort. */
|
||
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
|
||
[...issueKeys.myAll(wsId), scope, filter] as const,
|
||
/** FULL KEY for queryOptions — includes sort. */
|
||
myListSorted: (wsId: string, scope: string, filter: MyIssuesFilter, sort?: IssueSortParam) =>
|
||
[...issueKeys.myList(wsId, scope, filter), sort ?? {}] as const,
|
||
myAssigneeGroupsAll: (wsId: string) =>
|
||
[...issueKeys.myAll(wsId), "assignee-groups"] as const,
|
||
myAssigneeGroups: (
|
||
wsId: string,
|
||
scope: string,
|
||
filter: AssigneeGroupedIssuesFilter,
|
||
) => [...issueKeys.myAssigneeGroupsAll(wsId), scope, filter] as const,
|
||
/** All Project Gantt queries — prefix-match key for cross-project invalidation. */
|
||
projectGanttAll: (wsId: string) =>
|
||
[...issueKeys.all(wsId), "project-gantt"] as const,
|
||
/**
|
||
* Per-project Gantt issue list (scheduled-only). Uses its own cache key
|
||
* rather than reusing the bucketed `myList` cache so WS handlers and
|
||
* cache helpers don't have to special-case a non-bucketed shape under
|
||
* the `my` prefix.
|
||
*/
|
||
projectGantt: (wsId: string, projectId: string) =>
|
||
[...issueKeys.projectGanttAll(wsId), projectId] as const,
|
||
detail: (wsId: string, id: string) =>
|
||
[...issueKeys.all(wsId), "detail", id] as const,
|
||
children: (wsId: string, id: string) =>
|
||
[...issueKeys.all(wsId), "children", id] as const,
|
||
/** Prefix for invalidating all batched-children queries in a workspace. */
|
||
childrenByParentsAll: (wsId: string) =>
|
||
[...issueKeys.all(wsId), "children-by-parents"] as const,
|
||
/** Full key — includes sorted parent ids for cache stability. */
|
||
childrenByParents: (wsId: string, parentIds: readonly string[]) =>
|
||
[...issueKeys.childrenByParentsAll(wsId), parentIds] as const,
|
||
childProgress: (wsId: string) =>
|
||
[...issueKeys.all(wsId), "child-progress"] as const,
|
||
/** Prefix-match keys for invalidating the per-issue caches below across
|
||
* all issues. These keys carry no wsId, so `issueKeys.all(wsId)` does NOT
|
||
* cover them — WS reconnect recovery must invalidate these `*All`
|
||
* prefixes explicitly, or missed events leave them stale forever under
|
||
* the staleTime: Infinity default (#3953). */
|
||
timelineAll: () => ["issues", "timeline"] as const,
|
||
/** Full-issue timeline (single TanStack Query, no cursor). */
|
||
timeline: (issueId: string) =>
|
||
[...issueKeys.timelineAll(), issueId] as const,
|
||
/** Prefix across all issues — WS task lifecycle events invalidate here so
|
||
* an open composer's trigger preview refreshes when an agent's queue
|
||
* state changes (the dedup guard makes the answer queue-dependent). */
|
||
commentTriggerPreviewAll: () => ["issues", "comment-trigger-preview"] as const,
|
||
/** PREFIX for invalidation — the composer hook appends parent + content signature. */
|
||
commentTriggerPreview: (issueId: string) =>
|
||
[...issueKeys.commentTriggerPreviewAll(), issueId] as const,
|
||
/** Prefix across all issue-trigger previews (assign/status/create/batch).
|
||
* WS task lifecycle events invalidate here so the answer revalidates when an
|
||
* agent's queue state changes (the status source's pending dedup makes it
|
||
* queue-dependent, mirroring commentTriggerPreviewAll). */
|
||
issueTriggerPreviewAll: () => ["issues", "issue-trigger-preview"] as const,
|
||
/** PREFIX — the picker hook appends a signature of the prospective write. */
|
||
issueTriggerPreview: (signature: string) =>
|
||
[...issueKeys.issueTriggerPreviewAll(), signature] as const,
|
||
reactionsAll: () => ["issues", "reactions"] as const,
|
||
reactions: (issueId: string) =>
|
||
[...issueKeys.reactionsAll(), issueId] as const,
|
||
subscribersAll: () => ["issues", "subscribers"] as const,
|
||
subscribers: (issueId: string) =>
|
||
[...issueKeys.subscribersAll(), issueId] as const,
|
||
usageAll: () => ["issues", "usage"] as const,
|
||
usage: (issueId: string) => [...issueKeys.usageAll(), issueId] as const,
|
||
attachmentsAll: () => ["issues", "attachments"] as const,
|
||
/** Issue-level attachments — used by the description editor so its
|
||
* inline file-card / image NodeViews can re-sign download URLs at
|
||
* click time. */
|
||
attachments: (issueId: string) =>
|
||
[...issueKeys.attachmentsAll(), issueId] as const,
|
||
/** Prefix-match key for invalidating tasks across all issues — used by
|
||
* the global WS task: prefix path so any task lifecycle event refreshes
|
||
* every per-issue list, regardless of which issue is currently mounted. */
|
||
tasksAll: () => ["issues", "tasks"] as const,
|
||
/** Per-issue task list (issue-detail Execution log section). */
|
||
tasks: (issueId: string) => [...issueKeys.tasksAll(), issueId] as const,
|
||
};
|
||
|
||
export type MyIssuesFilter = Pick<
|
||
ListIssuesParams,
|
||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id" | "involves_user_id"
|
||
>;
|
||
|
||
export type AssigneeGroupedIssuesFilter = Omit<
|
||
ListGroupedIssuesParams,
|
||
"group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id"
|
||
>;
|
||
|
||
/** 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 = {}, sort?: IssueSortParam): Promise<ListIssuesCache> {
|
||
const responses = await Promise.all(
|
||
PAGINATED_STATUSES.map((status) =>
|
||
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...sort, ...filter }),
|
||
),
|
||
);
|
||
const byStatus: ListIssuesCache["byStatus"] = {};
|
||
PAGINATED_STATUSES.forEach((status, i) => {
|
||
const res = responses[i]!;
|
||
byStatus[status] = { issues: res.issues, total: res.total };
|
||
});
|
||
return { byStatus };
|
||
}
|
||
|
||
/**
|
||
* "All my issues" — union of three server filters:
|
||
* assignee_id=me OR creator_id=me OR involves_user_id=me
|
||
*
|
||
* The backend has no OR-across-user-filters today, so we run the three
|
||
* existing single-filter fetches in parallel and dedupe on the client by
|
||
* issue id within each status bucket. Order within each bucket preserves
|
||
* the first-seen position (each sub-fetch is already server-sorted).
|
||
*
|
||
* Personal lists are bounded (tens to a few hundred issues across all
|
||
* three relations), so 3× the request count is acceptable — a single
|
||
* fetchFirstPages already runs 7 status fetches in parallel, so the total
|
||
* here is 21 small parallel requests. Easy enough; no need to add a new
|
||
* backend query just for this scope.
|
||
*
|
||
* `total` per bucket is set to the merged length, not the true server
|
||
* total — pagination on the "All" scope is out of scope; the first
|
||
* 50-per-status × 3 widening (deduped) is what the page renders.
|
||
*/
|
||
async function fetchAllMyFirstPages(userId: string, sort?: IssueSortParam): Promise<ListIssuesCache> {
|
||
const [byAssignee, byCreator, byInvolves] = await Promise.all([
|
||
fetchFirstPages({ assignee_id: userId }, sort),
|
||
fetchFirstPages({ creator_id: userId }, sort),
|
||
fetchFirstPages({ involves_user_id: userId }, sort),
|
||
]);
|
||
const byStatus: ListIssuesCache["byStatus"] = {};
|
||
for (const status of PAGINATED_STATUSES) {
|
||
const seen = new Set<string>();
|
||
const merged: Issue[] = [];
|
||
for (const cache of [byAssignee, byCreator, byInvolves]) {
|
||
const bucket = cache.byStatus[status];
|
||
if (!bucket) continue;
|
||
for (const issue of bucket.issues) {
|
||
if (seen.has(issue.id)) continue;
|
||
seen.add(issue.id);
|
||
merged.push(issue);
|
||
}
|
||
}
|
||
byStatus[status] = { issues: merged, total: merged.length };
|
||
}
|
||
return { byStatus };
|
||
}
|
||
|
||
/**
|
||
* Sibling of {@link fetchAllMyFirstPages} for the assignee-grouped board
|
||
* view. Runs the three single-filter grouped queries in parallel and
|
||
* merges groups by (assignee_type, assignee_id), deduping issues within
|
||
* each group. Extra filters from the page (statuses, priorities, etc.)
|
||
* pass through unchanged.
|
||
*/
|
||
async function fetchAllMyAssigneeGroups(
|
||
userId: string,
|
||
filter: AssigneeGroupedIssuesFilter,
|
||
sort?: IssueSortParam,
|
||
): Promise<GroupedIssuesResponse> {
|
||
const variants: AssigneeGroupedIssuesFilter[] = [
|
||
{ ...filter, assignee_id: userId },
|
||
{ ...filter, creator_id: userId },
|
||
{ ...filter, involves_user_id: userId },
|
||
];
|
||
const responses = await Promise.all(
|
||
variants.map((f) =>
|
||
api.listGroupedIssues({
|
||
group_by: "assignee",
|
||
limit: ISSUE_PAGE_SIZE,
|
||
offset: 0,
|
||
...sort,
|
||
...f,
|
||
}),
|
||
),
|
||
);
|
||
const groupKey = (g: GroupedIssuesResponse["groups"][number]) =>
|
||
`${g.assignee_type ?? "_"}::${g.assignee_id ?? "_"}`;
|
||
const merged = new Map<string, GroupedIssuesResponse["groups"][number]>();
|
||
for (const res of responses) {
|
||
for (const group of res.groups) {
|
||
const key = groupKey(group);
|
||
const existing = merged.get(key);
|
||
if (!existing) {
|
||
merged.set(key, {
|
||
...group,
|
||
issues: [...group.issues],
|
||
total: group.issues.length,
|
||
});
|
||
continue;
|
||
}
|
||
const seen = new Set(existing.issues.map((i) => i.id));
|
||
for (const issue of group.issues) {
|
||
if (seen.has(issue.id)) continue;
|
||
seen.add(issue.id);
|
||
existing.issues.push(issue);
|
||
}
|
||
existing.total = existing.issues.length;
|
||
}
|
||
}
|
||
return { groups: [...merged.values()] };
|
||
}
|
||
|
||
/**
|
||
* 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 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, sort?: IssueSortParam) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.listSorted(wsId, sort),
|
||
queryFn: () => fetchFirstPages({}, sort),
|
||
select: flattenIssueBuckets,
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
export function issueAssigneeGroupsOptions(
|
||
wsId: string,
|
||
filter: AssigneeGroupedIssuesFilter,
|
||
sort?: IssueSortParam,
|
||
) {
|
||
return queryOptions<GroupedIssuesResponse>({
|
||
queryKey: issueKeys.assigneeGroups(wsId, { ...filter, ...sort }),
|
||
queryFn: () =>
|
||
api.listGroupedIssues({
|
||
group_by: "assignee",
|
||
limit: ISSUE_PAGE_SIZE,
|
||
offset: 0,
|
||
...sort,
|
||
...filter,
|
||
}),
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Server-filtered issue list for the My Issues page.
|
||
* Each scope gets its own cache entry so switching tabs is instant after first load.
|
||
*/
|
||
export function myIssueListOptions(
|
||
wsId: string,
|
||
scope: string,
|
||
filter: MyIssuesFilter,
|
||
// Required when scope === "all" — the user id whose three relations
|
||
// (assignee, creator, agents+squads) we union over. For every other
|
||
// scope the filter object already carries the relevant id and userId
|
||
// is ignored.
|
||
userId?: string,
|
||
sort?: IssueSortParam,
|
||
) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.myListSorted(wsId, scope, filter, sort),
|
||
queryFn: () =>
|
||
scope === "all" && userId
|
||
? fetchAllMyFirstPages(userId, sort)
|
||
: fetchFirstPages(filter, sort),
|
||
select: flattenIssueBuckets,
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Page size for the scheduled-issue fetch. The Gantt view always pulls every
|
||
* scheduled issue (no client pagination), so this is just the chunk size we
|
||
* use to walk the server's `(limit, offset)` window until we hit `total`.
|
||
*/
|
||
export const PROJECT_GANTT_PAGE_LIMIT = 500;
|
||
|
||
/**
|
||
* Paranoia cap on the loop in {@link fetchProjectGanttIssues}. Real projects
|
||
* shouldn't come close to this — a single project carrying 50k scheduled
|
||
* issues is already a product problem, not a Gantt-rendering one — but the
|
||
* guard prevents a buggy server `total` from spinning the loop forever.
|
||
*/
|
||
export const PROJECT_GANTT_MAX_ISSUES = 10_000;
|
||
|
||
async function fetchProjectGanttIssues(projectId: string) {
|
||
const issues = [];
|
||
let offset = 0;
|
||
while (offset < PROJECT_GANTT_MAX_ISSUES) {
|
||
const res = await api.listIssues({
|
||
project_id: projectId,
|
||
scheduled: true,
|
||
limit: PROJECT_GANTT_PAGE_LIMIT,
|
||
offset,
|
||
});
|
||
issues.push(...res.issues);
|
||
if (res.issues.length < PROJECT_GANTT_PAGE_LIMIT) break;
|
||
if (issues.length >= res.total) break;
|
||
offset += PROJECT_GANTT_PAGE_LIMIT;
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/**
|
||
* One-shot fetch of every scheduled issue (`start_date` or `due_date` set)
|
||
* for a project. The Project Gantt view consumes this directly — no status
|
||
* bucketing, no client-side pagination, no Load-all affordance — because
|
||
* the scheduled subset is bounded enough to come back in a small handful of
|
||
* requests.
|
||
*
|
||
* Backed by `GET /api/issues?scheduled=true&project_id=…`; the SQL filter
|
||
* mirrors the same `(start_date IS NOT NULL OR due_date IS NOT NULL)`
|
||
* predicate the Gantt view applies on the client. Pages are walked until
|
||
* `total` is reached so an oversized project can't silently lose bars past
|
||
* the first page.
|
||
*/
|
||
export function projectGanttIssuesOptions(wsId: string, projectId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.projectGantt(wsId, projectId),
|
||
queryFn: () => fetchProjectGanttIssues(projectId),
|
||
});
|
||
}
|
||
|
||
export function myIssueAssigneeGroupsOptions(
|
||
wsId: string,
|
||
scope: string,
|
||
filter: AssigneeGroupedIssuesFilter,
|
||
// See myIssueListOptions for the userId contract — only consulted when
|
||
// scope === "all", and powers the 3-fetch grouped union.
|
||
userId?: string,
|
||
sort?: IssueSortParam,
|
||
) {
|
||
return queryOptions<GroupedIssuesResponse>({
|
||
queryKey: issueKeys.myAssigneeGroups(wsId, scope, { ...filter, ...sort }),
|
||
queryFn: () =>
|
||
scope === "all" && userId
|
||
? fetchAllMyAssigneeGroups(userId, filter, sort)
|
||
: api.listGroupedIssues({
|
||
group_by: "assignee",
|
||
limit: ISSUE_PAGE_SIZE,
|
||
offset: 0,
|
||
...sort,
|
||
...filter,
|
||
}),
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
export function issueDetailOptions(wsId: string, id: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.detail(wsId, id),
|
||
queryFn: () => api.getIssue(id),
|
||
});
|
||
}
|
||
|
||
export function childIssueProgressOptions(wsId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.childProgress(wsId),
|
||
queryFn: () => api.getChildIssueProgress(),
|
||
select: (data) => {
|
||
const map = new Map<string, { done: number; total: number }>();
|
||
for (const entry of data.progress) {
|
||
map.set(entry.parent_issue_id, { done: entry.done, total: entry.total });
|
||
}
|
||
return map;
|
||
},
|
||
});
|
||
}
|
||
|
||
export function childIssuesOptions(wsId: string, id: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.children(wsId, id),
|
||
queryFn: () => api.listChildIssues(id).then((r) => r.issues),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Server cap on parent_ids per `GET /api/issues/children` request — must
|
||
* match `listChildrenByParentsLimit` in server/internal/handler/issue.go.
|
||
* Exceeding it returns 400, so the client chunks larger requests.
|
||
*/
|
||
export const CHILDREN_BY_PARENTS_CHUNK_SIZE = 200;
|
||
|
||
/**
|
||
* Batched variant of {@link childIssuesOptions}: fetches children for all
|
||
* given parents in `GET /api/issues/children?parent_ids=…` requests, chunked
|
||
* to {@link CHILDREN_BY_PARENTS_CHUNK_SIZE} parents each. The queryFn also
|
||
* hydrates each parent's per-parent issueKeys.children cache so other
|
||
* surfaces (issue-detail sub-issues panel, set-parent modal) hit the primed
|
||
* cache instead of re-fetching. Hydration happens in queryFn (not a
|
||
* useEffect) to avoid the setQueryData → re-render → effect loop.
|
||
*
|
||
* Used by SwimLaneView to resolve parent lanes without an N-request fan-out.
|
||
* parentIds must be sorted + deduplicated by the caller for a stable cache key.
|
||
*/
|
||
async function fetchAndHydrateChildrenByParents(
|
||
qc: QueryClient,
|
||
wsId: string,
|
||
parentIds: readonly string[],
|
||
) {
|
||
// Chunk to respect the server cap (parallel, since chunks are independent).
|
||
const chunks: string[][] = [];
|
||
for (let i = 0; i < parentIds.length; i += CHILDREN_BY_PARENTS_CHUNK_SIZE) {
|
||
chunks.push([...parentIds.slice(i, i + CHILDREN_BY_PARENTS_CHUNK_SIZE)]);
|
||
}
|
||
const responses = await Promise.all(chunks.map((c) => api.listChildrenByParents(c)));
|
||
const grouped = new Map<string, Issue[]>();
|
||
for (const response of responses) {
|
||
for (const issue of response.issues) {
|
||
if (!issue.parent_issue_id) continue;
|
||
const bucket = grouped.get(issue.parent_issue_id);
|
||
if (bucket) {
|
||
bucket.push(issue);
|
||
} else {
|
||
grouped.set(issue.parent_issue_id, [issue]);
|
||
}
|
||
}
|
||
}
|
||
for (const [parentId, children] of grouped) {
|
||
// Only hydrate if the per-parent cache is empty — don't overwrite a
|
||
// fresher result that another query (e.g. issue-detail) may have written.
|
||
// This relies on useUpdateIssue.onMutate writing into the per-parent
|
||
// cache (not creating an empty one) — if that contract changes, batch
|
||
// hydration here would silently stop seeding new lanes.
|
||
const existing = qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId));
|
||
if (!existing || existing.length === 0) {
|
||
qc.setQueryData(issueKeys.children(wsId, parentId), children);
|
||
}
|
||
}
|
||
return grouped;
|
||
}
|
||
|
||
export function childrenByParentsOptions(
|
||
wsId: string,
|
||
parentIds: readonly string[],
|
||
qc: QueryClient,
|
||
) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.childrenByParents(wsId, parentIds),
|
||
queryFn: () => fetchAndHydrateChildrenByParents(qc, wsId, parentIds),
|
||
enabled: parentIds.length > 0,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Single-fetch timeline options. The endpoint returns the full ordered set of
|
||
* comments + activities for an issue (server caps at 2000 as a safety net).
|
||
* Cursor pagination was removed in #1929 — at observed data sizes (p99 ~30
|
||
* entries per issue) it added complexity without a UX win and broke reply
|
||
* threads at page boundaries.
|
||
*/
|
||
export function issueTimelineOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.timeline(issueId),
|
||
queryFn: () => api.listTimeline(issueId),
|
||
});
|
||
}
|
||
|
||
export function issueReactionsOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.reactions(issueId),
|
||
queryFn: async () => {
|
||
const issue = await api.getIssue(issueId);
|
||
return issue.reactions ?? [];
|
||
},
|
||
});
|
||
}
|
||
|
||
export function issueSubscribersOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.subscribers(issueId),
|
||
queryFn: () => api.listIssueSubscribers(issueId),
|
||
});
|
||
}
|
||
|
||
export function issueUsageOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.usage(issueId),
|
||
queryFn: () => api.getIssueUsage(issueId),
|
||
});
|
||
}
|
||
|
||
// Backs the description editor's fresh-sign download flow: NodeViews resolve
|
||
// an attachment id by matching the markdown URL against this list. The list
|
||
// is workspace-private metadata and lives on the same cache lifetime as the
|
||
// rest of the issue detail surface.
|
||
export function issueAttachmentsOptions(issueId: string) {
|
||
return queryOptions({
|
||
queryKey: issueKeys.attachments(issueId),
|
||
queryFn: () => api.listAttachments(issueId),
|
||
});
|
||
}
|