mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 23:49:22 +02:00
Compare commits
11 Commits
feat/react
...
agent/walt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53c04b6d6b | ||
|
|
42b4bc6af5 | ||
|
|
2aa71b3cd3 | ||
|
|
3cf06f8028 | ||
|
|
69399b920c | ||
|
|
ac2f2c7789 | ||
|
|
f9cda25a06 | ||
|
|
24cf98dead | ||
|
|
91e6c779d6 | ||
|
|
9d7060caf1 | ||
|
|
6d0e875dbb |
@@ -13,4 +13,18 @@ import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "./globals.css";
|
||||
|
||||
// react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click any
|
||||
// element to copy its source path + line + component stack for pasting to an AI.
|
||||
// Opt-in per developer: only loads when VITE_REACT_GRAB is set in a local,
|
||||
// gitignored apps/desktop/.env.development.local — it never activates for anyone
|
||||
// else, and the whole branch is tree-shaken out of production builds. The web app
|
||||
// wires the same tool via next/script in apps/web/app/layout.tsx.
|
||||
// See https://www.react-grab.com/
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_REACT_GRAB) {
|
||||
const grab = document.createElement("script");
|
||||
grab.src = "//unpkg.com/react-grab/dist/index.global.js";
|
||||
grab.crossOrigin = "anonymous";
|
||||
document.head.appendChild(grab);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
@@ -116,6 +117,24 @@ export default async function RootLayout({
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
{/*
|
||||
react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click
|
||||
any element to copy its source path + line + component stack for pasting
|
||||
to an AI. Opt-in per developer: only loads when VITE_REACT_GRAB is set in
|
||||
a local, gitignored apps/web/.env.local — it never activates for anyone
|
||||
else. Both guards are read server-side, so the <Script> is omitted from
|
||||
the HTML entirely unless you opted in. The VITE_ prefix is shared with the
|
||||
desktop renderer (apps/desktop/src/renderer/src/main.tsx), where Vite only
|
||||
exposes VITE_-prefixed vars to client code, so one var name covers both
|
||||
apps. See https://www.react-grab.com/
|
||||
*/}
|
||||
{process.env.NODE_ENV === "development" && process.env.VITE_REACT_GRAB && (
|
||||
<Script
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
crossOrigin="anonymous"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
)}
|
||||
<ThemeProvider>
|
||||
<WebProviders locale={locale} resources={resources}>
|
||||
{children}
|
||||
|
||||
@@ -11,6 +11,11 @@ if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
// jsdom doesn't implement elementFromPoint; input-otp uses it internally.
|
||||
if (typeof document.elementFromPoint !== "function") {
|
||||
document.elementFromPoint = () => null;
|
||||
}
|
||||
|
||||
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
|
||||
// Create a proper localStorage mock if methods are missing.
|
||||
if (
|
||||
|
||||
130
packages/core/issues/cache-helpers.test.ts
Normal file
130
packages/core/issues/cache-helpers.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Issue, ListIssuesCache } from "../types";
|
||||
import { insertByPosition, patchIssueInBuckets } from "./cache-helpers";
|
||||
|
||||
const WS_ID = "ws-1";
|
||||
|
||||
function mk(id: string, status: Issue["status"], position: number): Issue {
|
||||
return {
|
||||
id,
|
||||
workspace_id: WS_ID,
|
||||
number: 1,
|
||||
identifier: `MUL-${id}`,
|
||||
title: id,
|
||||
description: null,
|
||||
status,
|
||||
priority: "none",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
metadata: {},
|
||||
labels: [],
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function cache(byStatus: ListIssuesCache["byStatus"]): ListIssuesCache {
|
||||
return { byStatus };
|
||||
}
|
||||
|
||||
function ids(c: ListIssuesCache, status: Issue["status"]): string[] {
|
||||
return (c.byStatus[status]?.issues ?? []).map((i) => i.id);
|
||||
}
|
||||
|
||||
describe("insertByPosition", () => {
|
||||
it("inserts at the position-sorted slot", () => {
|
||||
const a = mk("a", "todo", 1);
|
||||
const c = mk("c", "todo", 3);
|
||||
const b = mk("b", "todo", 2);
|
||||
expect(insertByPosition([a, c], b).map((i) => i.id)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends when the new position is the largest", () => {
|
||||
const a = mk("a", "todo", 1);
|
||||
const z = mk("z", "todo", 9);
|
||||
expect(insertByPosition([a], z).map((i) => i.id)).toEqual(["a", "z"]);
|
||||
});
|
||||
|
||||
it("prepends when the new position is the smallest", () => {
|
||||
const b = mk("b", "todo", 2);
|
||||
const a = mk("a", "todo", 1);
|
||||
expect(insertByPosition([b], a).map((i) => i.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchIssueInBuckets — cross-status move", () => {
|
||||
it("inserts the moved card at its position slot, not the end", () => {
|
||||
const c0 = cache({
|
||||
todo: { issues: [mk("moved", "todo", 5)], total: 1 },
|
||||
in_progress: {
|
||||
issues: [mk("x", "in_progress", 1), mk("y", "in_progress", 3)],
|
||||
total: 2,
|
||||
},
|
||||
});
|
||||
// Move "moved" into in_progress at position 2 (between x and y).
|
||||
const next = patchIssueInBuckets(c0, "moved", {
|
||||
status: "in_progress",
|
||||
position: 2,
|
||||
});
|
||||
expect(ids(next, "in_progress")).toEqual(["x", "moved", "y"]);
|
||||
expect(ids(next, "todo")).toEqual([]);
|
||||
});
|
||||
|
||||
it("adjusts both bucket totals", () => {
|
||||
const c0 = cache({
|
||||
todo: { issues: [mk("moved", "todo", 5)], total: 1 },
|
||||
in_progress: { issues: [mk("x", "in_progress", 1)], total: 1 },
|
||||
});
|
||||
const next = patchIssueInBuckets(c0, "moved", {
|
||||
status: "in_progress",
|
||||
position: 2,
|
||||
});
|
||||
expect(next.byStatus.todo?.total).toBe(0);
|
||||
expect(next.byStatus.in_progress?.total).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchIssueInBuckets — same status", () => {
|
||||
it("keeps the slot for a plain field update (no reorder)", () => {
|
||||
const c0 = cache({
|
||||
todo: {
|
||||
issues: [mk("a", "todo", 1), mk("b", "todo", 2), mk("c", "todo", 3)],
|
||||
total: 3,
|
||||
},
|
||||
});
|
||||
// A remote label/title edit must not move the card.
|
||||
const next = patchIssueInBuckets(c0, "b", { title: "renamed" });
|
||||
expect(ids(next, "todo")).toEqual(["a", "b", "c"]);
|
||||
expect(next.byStatus.todo?.issues[1]?.title).toBe("renamed");
|
||||
});
|
||||
|
||||
it("re-sorts within the column when position changes", () => {
|
||||
const c0 = cache({
|
||||
todo: {
|
||||
issues: [mk("a", "todo", 1), mk("b", "todo", 2), mk("c", "todo", 3)],
|
||||
total: 3,
|
||||
},
|
||||
});
|
||||
// Drag "a" below "b" (new position 2.5).
|
||||
const next = patchIssueInBuckets(c0, "a", { position: 2.5 });
|
||||
expect(ids(next, "todo")).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchIssueInBuckets — unknown issue", () => {
|
||||
it("returns the cache unchanged when the id is absent", () => {
|
||||
const c0 = cache({ todo: { issues: [mk("a", "todo", 1)], total: 1 } });
|
||||
expect(patchIssueInBuckets(c0, "ghost", { position: 9 })).toBe(c0);
|
||||
});
|
||||
});
|
||||
@@ -63,10 +63,28 @@ export function removeIssueFromBuckets(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `issue` into `issues` at the slot implied by `position ASC` — the same
|
||||
* ordering the board renders (server `ORDER BY position ASC`). Returns a new
|
||||
* array; the input is not mutated.
|
||||
*
|
||||
* Inserting at the right slot (instead of appending to the end) is what keeps an
|
||||
* optimistic move from snapping: the card lands where it will be after the
|
||||
* server confirms, so no later cache refresh teleports it to the column tail.
|
||||
*/
|
||||
export function insertByPosition(issues: Issue[], issue: Issue): Issue[] {
|
||||
const idx = issues.findIndex((i) => i.position > issue.position);
|
||||
if (idx === -1) return [...issues, issue];
|
||||
return [...issues.slice(0, idx), issue, ...issues.slice(idx)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* are adjusted. The moved card — and a same-column card whose `position`
|
||||
* changed — is re-inserted at its `position`-sorted slot rather than appended,
|
||||
* so the cache order stays consistent with what the board renders. A plain
|
||||
* field update (no status/position change) keeps the card in place.
|
||||
*/
|
||||
export function patchIssueInBuckets(
|
||||
resp: ListIssuesCache,
|
||||
@@ -80,9 +98,23 @@ export function patchIssueInBuckets(
|
||||
|
||||
if (nextStatus === loc.status) {
|
||||
const bucket = getBucket(resp, loc.status);
|
||||
const positionChanged =
|
||||
patch.position !== undefined && patch.position !== loc.issue.position;
|
||||
if (!positionChanged) {
|
||||
// Plain field update (labels, metadata, title, …): keep the slot so a
|
||||
// remote edit never reorders an otherwise-untouched column.
|
||||
return setBucket(resp, loc.status, {
|
||||
...bucket,
|
||||
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
|
||||
});
|
||||
}
|
||||
// Same-column reorder: lift the card out and re-insert at its new slot.
|
||||
return setBucket(resp, loc.status, {
|
||||
...bucket,
|
||||
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
|
||||
issues: insertByPosition(
|
||||
bucket.issues.filter((i) => i.id !== id),
|
||||
merged,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,7 +125,7 @@ export function patchIssueInBuckets(
|
||||
total: Math.max(0, fromBucket.total - 1),
|
||||
});
|
||||
next = setBucket(next, nextStatus, {
|
||||
issues: [...toBucket.issues, merged],
|
||||
issues: insertByPosition(toBucket.issues, merged),
|
||||
total: toBucket.total + 1,
|
||||
});
|
||||
return next;
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { setApiInstance } from "../api";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus, useResolveComment } from "./mutations";
|
||||
import {
|
||||
useBatchUpdateIssues,
|
||||
useLoadMoreByAssigneeGroup,
|
||||
useLoadMoreByStatus,
|
||||
useResolveComment,
|
||||
useUpdateIssue,
|
||||
} from "./mutations";
|
||||
import {
|
||||
issueKeys,
|
||||
type IssueSortParam,
|
||||
@@ -314,6 +320,228 @@ describe("useLoadMoreByAssigneeGroup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUpdateIssue — optimistic move keeps every bucketed board in sync", () => {
|
||||
const sort: IssueSortParam = { sort_by: "position", sort_direction: undefined };
|
||||
const myScope = "assigned";
|
||||
const myFilter = { assignee_id: "user-1" };
|
||||
const wsKey = issueKeys.listSorted(WS_ID, sort);
|
||||
// My-Issues AND the Project board both ride this myList cache; a move that
|
||||
// only patched the workspace cache snaps back on those boards.
|
||||
const myKey = issueKeys.myListSorted(WS_ID, myScope, myFilter, sort);
|
||||
|
||||
let qc: QueryClient;
|
||||
let updateIssue: ReturnType<typeof vi.fn<(id: string, data: unknown) => Promise<Issue>>>;
|
||||
|
||||
function makeBucketed(): ListIssuesCache {
|
||||
return {
|
||||
byStatus: {
|
||||
todo: { issues: [makeIssue(1)], total: 1 },
|
||||
in_progress: { issues: [], total: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bucketIds(
|
||||
key: readonly unknown[],
|
||||
status: "todo" | "in_progress",
|
||||
): string[] {
|
||||
const c = qc.getQueryData<ListIssuesCache>(key);
|
||||
return (c?.byStatus[status]?.issues ?? []).map((i) => i.id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
updateIssue = vi.fn();
|
||||
setApiInstance({ updateIssue } as unknown as ApiClient);
|
||||
qc.setQueryData<ListIssuesCache>(wsKey, makeBucketed());
|
||||
qc.setQueryData<ListIssuesCache>(myKey, makeBucketed());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
qc.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("optimistically moves the card in both the workspace and myList caches", async () => {
|
||||
let resolve!: (issue: Issue) => void;
|
||||
updateIssue.mockReturnValue(
|
||||
new Promise<Issue>((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateIssue(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ id: "issue-1", status: "in_progress", position: 5 });
|
||||
});
|
||||
|
||||
// Optimistic state — the regression: myList must move too, not just ws.
|
||||
for (const key of [wsKey, myKey]) {
|
||||
expect(bucketIds(key, "todo")).toEqual([]);
|
||||
expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
resolve(makeIssue(1, { status: "in_progress", position: 5 }));
|
||||
});
|
||||
|
||||
// Authoritative settle keeps the card in place in both caches.
|
||||
for (const key of [wsKey, myKey]) {
|
||||
expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls both caches back when the request fails", async () => {
|
||||
updateIssue.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useUpdateIssue(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current
|
||||
.mutateAsync({ id: "issue-1", status: "in_progress", position: 5 })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
for (const key of [wsKey, myKey]) {
|
||||
expect(bucketIds(key, "todo")).toEqual(["issue-1"]);
|
||||
expect(bucketIds(key, "in_progress")).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not invalidate the board list on settle (no refetch flicker)", async () => {
|
||||
updateIssue.mockResolvedValue(makeIssue(1, { status: "in_progress", position: 5 }));
|
||||
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(() => useUpdateIssue(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ id: "issue-1", status: "in_progress", position: 5 });
|
||||
});
|
||||
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
// The board list + myList are reconciled surgically, never refetched.
|
||||
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
|
||||
expect(invalidatedKeys).not.toContainEqual(issueKeys.myAll(WS_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useBatchUpdateIssues — optimistic patch covers filtered boards too", () => {
|
||||
const sort: IssueSortParam = { sort_by: "position", sort_direction: undefined };
|
||||
const myScope = "assigned";
|
||||
const myFilter = { assignee_id: "user-1" };
|
||||
const wsKey = issueKeys.listSorted(WS_ID, sort);
|
||||
const myKey = issueKeys.myListSorted(WS_ID, myScope, myFilter, sort);
|
||||
|
||||
let qc: QueryClient;
|
||||
let batchUpdateIssues: ReturnType<
|
||||
typeof vi.fn<(ids: string[], updates: unknown) => Promise<{ updated: number }>>
|
||||
>;
|
||||
|
||||
function makeBucketed(): ListIssuesCache {
|
||||
return {
|
||||
byStatus: {
|
||||
todo: { issues: [makeIssue(1)], total: 1 },
|
||||
in_progress: { issues: [], total: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bucketIds(key: readonly unknown[], status: "todo" | "in_progress"): string[] {
|
||||
const c = qc.getQueryData<ListIssuesCache>(key);
|
||||
return (c?.byStatus[status]?.issues ?? []).map((i) => i.id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
batchUpdateIssues = vi.fn();
|
||||
setApiInstance({ batchUpdateIssues } as unknown as ApiClient);
|
||||
qc.setQueryData<ListIssuesCache>(wsKey, makeBucketed());
|
||||
qc.setQueryData<ListIssuesCache>(myKey, makeBucketed());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
qc.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("optimistically patches BOTH the workspace and myList caches (not just ws)", async () => {
|
||||
let resolve!: (r: { updated: number }) => void;
|
||||
batchUpdateIssues.mockReturnValue(
|
||||
new Promise<{ updated: number }>((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBatchUpdateIssues(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ ids: ["issue-1"], updates: { status: "in_progress" } });
|
||||
});
|
||||
|
||||
// The regression Howard flagged: batch must move the card on the myList
|
||||
// board too, not only the workspace board. onMutate awaits cancelQueries,
|
||||
// so the optimistic patch lands a microtask later — wait for it.
|
||||
await waitFor(() => {
|
||||
for (const key of [wsKey, myKey]) {
|
||||
expect(bucketIds(key, "todo")).toEqual([]);
|
||||
expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]);
|
||||
}
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolve({ updated: 1 });
|
||||
});
|
||||
|
||||
for (const key of [wsKey, myKey]) {
|
||||
expect(bucketIds(key, "in_progress")).toEqual(["issue-1"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls both caches back when the request fails", async () => {
|
||||
batchUpdateIssues.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useBatchUpdateIssues(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current
|
||||
.mutateAsync({ ids: ["issue-1"], updates: { status: "in_progress" } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
for (const key of [wsKey, myKey]) {
|
||||
expect(bucketIds(key, "todo")).toEqual(["issue-1"]);
|
||||
expect(bucketIds(key, "in_progress")).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not invalidate the board list on settle (no refetch flicker)", async () => {
|
||||
batchUpdateIssues.mockResolvedValue({ updated: 1 });
|
||||
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(() => useBatchUpdateIssues(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ ids: ["issue-1"], updates: { status: "in_progress" } });
|
||||
});
|
||||
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useResolveComment", () => {
|
||||
const ISSUE_ID = "issue-1";
|
||||
|
||||
|
||||
@@ -211,6 +211,21 @@ export function useCreateIssue() {
|
||||
export function useUpdateIssue() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
// Every bucketed board cache an optimistic move must keep in sync: the
|
||||
// workspace board (issueKeys.list*) AND the My-Issues / Project board
|
||||
// (issueKeys.myList* under `my`), which share the ListIssuesCache shape.
|
||||
// Filtering by `byStatus` skips the grouped (assignee) and flat
|
||||
// (gantt/detail/children) caches that also live under those prefixes. The
|
||||
// board reconciles local columns from its own feeding cache on settle, so a
|
||||
// move that only patched the workspace cache would snap back on My-Issues /
|
||||
// Project boards.
|
||||
const readBucketedLists = () =>
|
||||
[
|
||||
...qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) }),
|
||||
...qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.myAll(wsId) }),
|
||||
].filter(
|
||||
(entry): entry is [QueryKey, ListIssuesCache] => !!entry[1]?.byStatus,
|
||||
);
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
|
||||
api.updateIssue(id, data),
|
||||
@@ -220,7 +235,8 @@ 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 prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
|
||||
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
const prevLists = readBucketedLists();
|
||||
const firstListData = prevLists[0]?.[1];
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
|
||||
@@ -280,9 +296,29 @@ export function useUpdateIssue() {
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: (serverIssue) => {
|
||||
// Reconcile with the authoritative server entity by patching the one card
|
||||
// in place — NOT by invalidating + refetching the list. The list refetch
|
||||
// is what made a successful move flicker: the optimistic card was already
|
||||
// in the right place, then the refetch replaced the whole column and the
|
||||
// card re-landed. updateIssue returns the full issue and a position update
|
||||
// touches only that row, so a surgical patch is the authoritative
|
||||
// reconcile and is a visual no-op when the optimistic value matched.
|
||||
for (const [key, cached] of readBucketedLists()) {
|
||||
qc.setQueryData<ListIssuesCache>(
|
||||
key,
|
||||
patchIssueInBuckets(cached, serverIssue.id, serverIssue),
|
||||
);
|
||||
}
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, serverIssue.id), (old) =>
|
||||
old ? { ...old, ...serverIssue } : old,
|
||||
);
|
||||
},
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
// The issue's own list + detail caches are reconciled surgically in
|
||||
// onSuccess / onError, so they are deliberately NOT invalidated here — a
|
||||
// full-list refetch on settle is what made drags flicker. Only aggregate
|
||||
// caches that cannot be patched from a single issue are refreshed below.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
||||
@@ -408,10 +444,21 @@ export function useBatchUpdateIssues() {
|
||||
updates: UpdateIssueRequest;
|
||||
}) => api.batchUpdateIssues(ids, updates),
|
||||
onMutate: async ({ ids, updates }) => {
|
||||
// Patch BOTH the workspace board (issueKeys.list) and the filtered
|
||||
// My-Issues / Project / actor lists (issueKeys.myAll). The single-issue
|
||||
// update already patches both; batch only touched issueKeys.list, so a
|
||||
// batch edit on a My-Issues board had no optimistic effect and relied
|
||||
// entirely on the settle refetch. Filter to bucketed (byStatus) caches so
|
||||
// grouped/flat caches under the same prefix are skipped.
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
|
||||
await qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
const prevLists = [
|
||||
...qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) }),
|
||||
...qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.myAll(wsId) }),
|
||||
].filter(
|
||||
(entry): entry is [QueryKey, ListIssuesCache] => !!entry[1]?.byStatus,
|
||||
);
|
||||
for (const [key, cached] of prevLists) {
|
||||
if (!cached) continue;
|
||||
let next = cached;
|
||||
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
|
||||
qc.setQueryData<ListIssuesCache>(key, next);
|
||||
@@ -451,7 +498,13 @@ export function useBatchUpdateIssues() {
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, _vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
// Deliberately NOT invalidating issueKeys.list / myAll here: the onMutate
|
||||
// patch above is a complete surgical reconcile for these bucketed boards
|
||||
// (batch changes status / priority / project — never a server-computed
|
||||
// value), so a full-board refetch on settle would only re-introduce the
|
||||
// flicker the single-issue update already removed. Aggregate / grouped
|
||||
// caches that cannot be recomputed from a single-issue patch are still
|
||||
// refreshed below.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
||||
|
||||
@@ -262,6 +262,67 @@ describe("project progress invalidation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("onIssueUpdated — position move is surgical, not a list refetch", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
});
|
||||
|
||||
const issueA: Issue = { ...baseIssue, id: "issue-1", position: 0 };
|
||||
const issueB: Issue = { ...baseIssue, id: "issue-2", position: 10 };
|
||||
|
||||
it("reorders the moved card in place and does NOT invalidate the workspace list", () => {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), makeListCache(issueA, issueB));
|
||||
|
||||
// issue-1 moves below issue-2 (position 0 -> 20) — a remote/echoed drag.
|
||||
onIssueUpdated(qc, WS_ID, { ...issueA, position: 20 });
|
||||
|
||||
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
|
||||
// Surgically reordered into its new slot: proof the patch alone suffices.
|
||||
expect(list?.byStatus.todo?.issues.map((i) => i.id)).toEqual(["issue-2", "issue-1"]);
|
||||
// The old redundant `position -> invalidate(list)` is gone — no full-board
|
||||
// refetch on top of the surgical patch (that was the flicker source).
|
||||
expect(qc.getQueryState(issueKeys.list(WS_ID))?.isInvalidated).toBe(false);
|
||||
});
|
||||
|
||||
it("surgically patches the filtered myAll lists on a non-membership change (no refetch)", () => {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), makeListCache(issueA, issueB));
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA, issueB));
|
||||
|
||||
// Pure position move: membership cannot change, so myAll is patched in place.
|
||||
onIssueUpdated(qc, WS_ID, { ...issueA, position: 20 });
|
||||
|
||||
const my = qc.getQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID));
|
||||
expect(my?.byStatus.todo?.issues.map((i) => i.id)).toEqual(["issue-2", "issue-1"]);
|
||||
// Reconciled in place — no full-list refetch on My Issues (that was the
|
||||
// remaining drag flicker on filtered boards).
|
||||
expect(qc.getQueryState(issueKeys.myAll(WS_ID))?.isInvalidated).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates myAll when the assignee changes (membership may shift)", () => {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
|
||||
|
||||
onIssueUpdated(
|
||||
qc,
|
||||
WS_ID,
|
||||
{ ...issueA, assignee_type: "member", assignee_id: "user-2" },
|
||||
{ assigneeChanged: true },
|
||||
);
|
||||
|
||||
expectInvalidated(qc, issueKeys.myAll(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates myAll when the project changes (Project board membership)", () => {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
|
||||
|
||||
// issueA.project_id is null; moving it into a project shifts Project-board membership.
|
||||
onIssueUpdated(qc, WS_ID, { ...issueA, project_id: "project-9" });
|
||||
|
||||
expectInvalidated(qc, issueKeys.myAll(WS_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("onIssueDeleted", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ export function onIssueUpdated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Partial<Issue> & { id: string },
|
||||
// assigneeChanged comes from the server's issue:updated flags. It gates the
|
||||
// filtered-list (myAll) invalidate so a non-membership change keeps those
|
||||
// lists in place instead of refetching.
|
||||
meta: { assigneeChanged?: boolean } = {},
|
||||
) {
|
||||
// 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
|
||||
@@ -56,13 +60,45 @@ export function onIssueUpdated(
|
||||
const parentChanged =
|
||||
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
||||
|
||||
// Project-board membership keys on project_id. There is no project_changed
|
||||
// flag on the wire, so diff the incoming project_id against the cached one.
|
||||
const oldProjectId =
|
||||
detailData?.project_id ??
|
||||
(firstListData ? findIssueLocation(firstListData, issue.id)?.issue.project_id : null) ??
|
||||
null;
|
||||
const projectChanged =
|
||||
issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId;
|
||||
|
||||
for (const [key, data] of listQueries) {
|
||||
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issue.id, issue));
|
||||
}
|
||||
if (issue.position !== undefined) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
// The workspace board (issueKeys.list) is NOT filtered: an issue is always a
|
||||
// member, so patchIssueInBuckets above is a complete surgical reconcile —
|
||||
// cross-status move, same-column reorder, and field updates all land in the
|
||||
// right bucket/slot. The old `if (position) invalidateQueries(list)` re-pulled
|
||||
// the entire board on top of that, which is the full-list refetch that made a
|
||||
// drag (local or echoed back over WS) flicker. It is pure redundancy here.
|
||||
//
|
||||
// myAll (My Issues / Project / actor lists) IS filtered. Surgically patch the
|
||||
// cards that already live in those caches too, so a non-membership change
|
||||
// (pure status / position / priority / label) reconciles in place — no
|
||||
// refetch, no flicker — exactly like the workspace board above.
|
||||
const myListQueries = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.myAll(wsId) });
|
||||
for (const [key, data] of myListQueries) {
|
||||
if (data?.byStatus) {
|
||||
qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issue.id, issue));
|
||||
}
|
||||
}
|
||||
// Only refetch the filtered lists when the change can actually move an issue
|
||||
// in/out of one. My-Issues / actor-panel membership keys on the assignee (the
|
||||
// "involves" leg — my agents / my squads — is assignee-based too), so the
|
||||
// server's assignee_changed flag covers it; the Project board keys on
|
||||
// project_id. A pure status / position / priority / label change cannot change
|
||||
// membership, so the surgical patch above is the complete reconcile and we
|
||||
// skip the invalidate that used to make a My-Issues drag refetch + flicker.
|
||||
if (meta.assigneeChanged || projectChanged) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
if (issue.status !== undefined || issue.project_id !== undefined) {
|
||||
|
||||
@@ -572,11 +572,14 @@ export function useRealtimeSync(
|
||||
// Instead, both mutations and WS handlers use dedup checks to be idempotent.
|
||||
|
||||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
const payload = p as IssueUpdatedPayload;
|
||||
const { issue } = payload;
|
||||
if (!issue?.id) return;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) {
|
||||
onIssueUpdated(qc, wsId, issue);
|
||||
onIssueUpdated(qc, wsId, issue, {
|
||||
assigneeChanged: payload.assignee_changed,
|
||||
});
|
||||
if (issue.status) {
|
||||
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,12 @@ export interface IssueCreatedPayload {
|
||||
|
||||
export interface IssueUpdatedPayload {
|
||||
issue: Issue;
|
||||
// The server stamps issue:updated with which fields actually changed
|
||||
// (server/internal/handler/issue.go publish). Only assignee_changed is read
|
||||
// today: it lets the realtime layer keep filtered myList caches in place on a
|
||||
// non-membership change instead of refetching. Other change flags are present
|
||||
// on the wire too and can be surfaced here when needed.
|
||||
assignee_changed?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueDeletedPayload {
|
||||
|
||||
@@ -198,6 +198,23 @@ describe("LoginPage", () => {
|
||||
expect(screen.getByText(/test@example.com/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("autofocuses the OTP input when the code step opens", async () => {
|
||||
mockSendCode.mockResolvedValueOnce(undefined);
|
||||
renderWithI18n(<LoginPage onSuccess={onSuccess} />);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText(/email/i), "test@example.com");
|
||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/check your email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The OTP field should be focused on mount so the user can type the code
|
||||
// without clicking it first — important when repeatedly switching accounts.
|
||||
expect(getOTPInput()).toHaveFocus();
|
||||
});
|
||||
|
||||
it("shows error when sendCode fails", async () => {
|
||||
mockSendCode.mockRejectedValueOnce(new Error("Rate limited"));
|
||||
renderWithI18n(<LoginPage onSuccess={onSuccess} />);
|
||||
|
||||
@@ -349,6 +349,7 @@ export function LoginPage({
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<InputOTP
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(value) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { BoardCardContent } from "./board-card";
|
||||
import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel";
|
||||
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
|
||||
import type { ChildProgress } from "./list-row";
|
||||
import { useDragSettle } from "./use-drag-settle";
|
||||
import { useT } from "../../i18n";
|
||||
import {
|
||||
type DragMoveUpdates,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
buildColumns,
|
||||
computePosition,
|
||||
findColumn,
|
||||
insertIdByPosition,
|
||||
issueMatchesGroup,
|
||||
getMoveUpdates,
|
||||
} from "../utils/drag-utils";
|
||||
@@ -213,35 +215,27 @@ export function BoardView({
|
||||
|
||||
// --- Drag state ---
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const isSettlingRef = useRef(false);
|
||||
const [settleVersion, setSettleVersion] = useState(0);
|
||||
|
||||
// --- Local columns state ---
|
||||
// Between drags: follows TQ via useEffect.
|
||||
// During drag: local-only, driven by onDragOver/onDragEnd.
|
||||
const [columns, setColumns] = useState<Record<string, string[]>>(() =>
|
||||
buildColumns(groupedIssues, groups, grouping),
|
||||
);
|
||||
const columnsRef = useRef(columns);
|
||||
columnsRef.current = columns;
|
||||
// Shared drag/settle primitive: owns the local column mirror, the
|
||||
// dragging/settling locks, the post-move animation-frame throttle, and the
|
||||
// settle callback. Shared with list-view (and swimlane) so the surfaces
|
||||
// can't drift apart. Local columns follow TQ between drags via the resync
|
||||
// effect below; during a drag/settle they are frozen by the locks.
|
||||
const {
|
||||
columns,
|
||||
setColumns,
|
||||
columnsRef,
|
||||
isDraggingRef,
|
||||
isSettlingRef,
|
||||
recentlyMovedRef,
|
||||
settleVersion,
|
||||
beginSettle,
|
||||
} = useDragSettle(() => buildColumns(groupedIssues, groups, grouping));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRef.current && !isSettlingRef.current) {
|
||||
setColumns(buildColumns(groupedIssues, groups, grouping));
|
||||
}
|
||||
}, [groupedIssues, groups, grouping, settleVersion]);
|
||||
|
||||
// After a cross-column move, lock for one animation frame so dnd-kit's
|
||||
// collision detection can stabilize before processing the next move.
|
||||
// Without this, collision oscillates: A→B→A→B… until React bails out.
|
||||
const recentlyMovedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
recentlyMovedRef.current = false;
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [columns]);
|
||||
}, [groupedIssues, groups, grouping, settleVersion, setColumns, isDraggingRef, isSettlingRef]);
|
||||
|
||||
// --- Issue map ---
|
||||
// Frozen during drag so BoardColumn/DraggableBoardCard props stay
|
||||
@@ -269,7 +263,7 @@ export function BoardView({
|
||||
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
|
||||
setActiveIssue(issue);
|
||||
},
|
||||
[],
|
||||
[isDraggingRef],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
@@ -296,7 +290,7 @@ export function BoardView({
|
||||
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||
});
|
||||
},
|
||||
[groupIds, sortBy],
|
||||
[groupIds, sortBy, recentlyMovedRef, setColumns],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
@@ -359,11 +353,24 @@ export function BoardView({
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
isSettlingRef.current = true;
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), () => {
|
||||
isSettlingRef.current = false;
|
||||
setSettleVersion((v) => v + 1);
|
||||
// Optimistically move the card into the target column *now*. Without
|
||||
// this, the sortBy != "position" path never touches local columns on
|
||||
// drop, so onDragOver having been a no-op leaves the card in its origin
|
||||
// column for the whole request — it only jumps across when the mutation
|
||||
// settles. That is the "snaps back to origin, then moves" glitch.
|
||||
// Placement mirrors the cache (insertByPosition) so the settle rebuild
|
||||
// from TanStack Query is a visual no-op.
|
||||
setColumns((prev) => {
|
||||
const fromIds = (prev[activeCol] ?? []).filter((cid) => cid !== activeId);
|
||||
const toIds = insertIdByPosition(
|
||||
prev[overCol] ?? [],
|
||||
activeId,
|
||||
currentIssue.position,
|
||||
map,
|
||||
);
|
||||
return { ...prev, [activeCol]: fromIds, [overCol]: toIds };
|
||||
});
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), beginSettle());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,12 +386,14 @@ export function BoardView({
|
||||
return;
|
||||
}
|
||||
|
||||
isSettlingRef.current = true;
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), () => {
|
||||
isSettlingRef.current = false;
|
||||
});
|
||||
// beginSettle() holds the lock and returns the onSettled callback that
|
||||
// releases it and resyncs local columns from the cache: a no-op on
|
||||
// success (onSuccess already patched the moved card in place), the revert
|
||||
// on error (onError restored the snapshot). Without it a failed move would
|
||||
// strand the card at the drop target, since onSettled no longer refetches.
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), beginSettle());
|
||||
},
|
||||
[groupedIssues, groups, grouping, onMoveIssue, groupIds, groupMap, sortBy],
|
||||
[groupedIssues, groups, grouping, onMoveIssue, groupIds, groupMap, sortBy, beginSettle, columnsRef, isDraggingRef, setColumns],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
||||
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
|
||||
import { StatusHeading } from "./status-heading";
|
||||
import { ListRow, DraggableListRow, type ChildProgress } from "./list-row";
|
||||
import { useDragSettle } from "./use-drag-settle";
|
||||
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
|
||||
import { useT } from "../../i18n";
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
buildColumns,
|
||||
computePosition,
|
||||
findColumn,
|
||||
insertIdByPosition,
|
||||
issueMatchesGroup,
|
||||
getMoveUpdates,
|
||||
} from "../utils/drag-utils";
|
||||
@@ -113,29 +115,24 @@ export function ListView({
|
||||
|
||||
// --- Drag state ---
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const isSettlingRef = useRef(false);
|
||||
const [settleVersion, setSettleVersion] = useState(0);
|
||||
|
||||
const [columns, setColumns] = useState<Record<string, string[]>>(() =>
|
||||
buildColumns(issues, groups, "status"),
|
||||
);
|
||||
const columnsRef = useRef(columns);
|
||||
columnsRef.current = columns;
|
||||
// Shared drag/settle primitive (see use-drag-settle) — same machine as
|
||||
// board-view, so the two surfaces can't drift apart.
|
||||
const {
|
||||
columns,
|
||||
setColumns,
|
||||
columnsRef,
|
||||
isDraggingRef,
|
||||
isSettlingRef,
|
||||
recentlyMovedRef,
|
||||
settleVersion,
|
||||
beginSettle,
|
||||
} = useDragSettle(() => buildColumns(issues, groups, "status"));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRef.current && !isSettlingRef.current) {
|
||||
setColumns(buildColumns(issues, groups, "status"));
|
||||
}
|
||||
}, [issues, groups, settleVersion]);
|
||||
|
||||
const recentlyMovedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
recentlyMovedRef.current = false;
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [columns]);
|
||||
}, [issues, groups, settleVersion, setColumns, isDraggingRef, isSettlingRef]);
|
||||
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
@@ -165,7 +162,7 @@ export function ListView({
|
||||
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
|
||||
setActiveIssue(issue);
|
||||
},
|
||||
[],
|
||||
[isDraggingRef],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
@@ -192,7 +189,7 @@ export function ListView({
|
||||
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||
});
|
||||
},
|
||||
[groupIds, sortBy],
|
||||
[groupIds, sortBy, recentlyMovedRef, setColumns],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
@@ -253,11 +250,23 @@ export function ListView({
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
isSettlingRef.current = true;
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), () => {
|
||||
isSettlingRef.current = false;
|
||||
setSettleVersion((v) => v + 1);
|
||||
// Optimistically move the row into the target group *now*. Without this
|
||||
// the sortBy != "position" branch never touched local columns on drop,
|
||||
// so the row sat in its origin group for the whole request and only
|
||||
// jumped across when the mutation settled — the same "snaps back, then
|
||||
// moves" glitch the board view had. Placement mirrors the cache
|
||||
// (insertIdByPosition) so the settle rebuild is a visual no-op.
|
||||
setColumns((prev) => {
|
||||
const fromIds = (prev[activeCol] ?? []).filter((cid) => cid !== activeId);
|
||||
const toIds = insertIdByPosition(
|
||||
prev[finalCol] ?? [],
|
||||
activeId,
|
||||
currentIssue.position,
|
||||
map,
|
||||
);
|
||||
return { ...prev, [activeCol]: fromIds, [finalCol]: toIds };
|
||||
});
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), beginSettle());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -273,12 +282,12 @@ export function ListView({
|
||||
return;
|
||||
}
|
||||
|
||||
isSettlingRef.current = true;
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), () => {
|
||||
isSettlingRef.current = false;
|
||||
});
|
||||
// beginSettle() also bumps settleVersion on settle (board-view did, this
|
||||
// branch did not) so a failed position move reverts instead of stranding
|
||||
// the row at the drop target.
|
||||
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), beginSettle());
|
||||
},
|
||||
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy],
|
||||
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy, beginSettle, setColumns, columnsRef, isDraggingRef],
|
||||
);
|
||||
|
||||
const content = (
|
||||
|
||||
@@ -612,11 +612,34 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith("orphan-1", {
|
||||
parent_issue_id: null,
|
||||
status: "in_progress",
|
||||
position: 300,
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"orphan-1",
|
||||
{ parent_issue_id: null, status: "in_progress", position: 300 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a settle callback that releases the lock without error", () => {
|
||||
const mockOnMoveIssue = vi.fn();
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={mockIssues} onMoveIssue={mockOnMoveIssue} />,
|
||||
);
|
||||
|
||||
const targetCellId = "swim:parent:none:in_progress";
|
||||
act(() => {
|
||||
lastOnDragOver({ active: { id: "orphan-1" }, over: { id: targetCellId } });
|
||||
});
|
||||
act(() => {
|
||||
lastOnDragEnd({ active: { id: "orphan-1" }, over: { id: targetCellId } });
|
||||
});
|
||||
|
||||
// The move carries a settle callback (held from drop until the mutation
|
||||
// settles); invoking it releases the lock and re-syncs from the cache.
|
||||
const onSettled = mockOnMoveIssue.mock.calls[0]?.[2] as
|
||||
| (() => void)
|
||||
| undefined;
|
||||
expect(typeof onSettled).toBe("function");
|
||||
expect(() => act(() => onSettled?.())).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not call onMoveIssue when drop target equals source cell (no-op)", () => {
|
||||
@@ -661,6 +684,7 @@ describe("SwimLaneView", () => {
|
||||
parent_issue_id: "parent-1",
|
||||
status: "todo",
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1060,6 +1084,7 @@ describe("SwimLaneView", () => {
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-c",
|
||||
expect.objectContaining({ project_id: "proj-1", status: "todo" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1082,6 +1107,7 @@ describe("SwimLaneView", () => {
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-a",
|
||||
expect.objectContaining({ project_id: null, status: "in_review" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1164,6 +1190,7 @@ describe("SwimLaneView", () => {
|
||||
assignee_id: "user-1",
|
||||
status: "in_review",
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1190,6 +1217,7 @@ describe("SwimLaneView", () => {
|
||||
assignee_id: null,
|
||||
status: "done",
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -467,7 +467,11 @@ export function SwimLaneView({
|
||||
activeFilters?: Omit<IssueFilters, "statusFilters" | "runningIssueIds">;
|
||||
visibleStatuses?: IssueStatus[];
|
||||
hiddenStatuses?: IssueStatus[];
|
||||
onMoveIssue: (issueId: string, updates: SwimLaneMoveUpdates) => void;
|
||||
onMoveIssue: (
|
||||
issueId: string,
|
||||
updates: SwimLaneMoveUpdates,
|
||||
onSettled?: () => void,
|
||||
) => void;
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
myIssuesScope?: string;
|
||||
myIssuesFilter?: MyIssuesFilter;
|
||||
@@ -782,6 +786,12 @@ export function SwimLaneView({
|
||||
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
// Settle lock: held from drop until the move mutation settles, so a cache
|
||||
// change that lands mid-flight (e.g. a membership refetch) does not rebuild
|
||||
// localCells out from under the optimistic move. Mirrors board-view /
|
||||
// list-view. settleVersion forces the resync once the lock releases.
|
||||
const isSettlingRef = useRef(false);
|
||||
const [settleVersion, setSettleVersion] = useState(0);
|
||||
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
@@ -790,7 +800,7 @@ export function SwimLaneView({
|
||||
}, [mergedIssues]);
|
||||
|
||||
const issueMapRef = useRef(issueMap);
|
||||
if (!isDraggingRef.current) {
|
||||
if (!isDraggingRef.current && !isSettlingRef.current) {
|
||||
issueMapRef.current = issueMap;
|
||||
}
|
||||
|
||||
@@ -799,10 +809,10 @@ export function SwimLaneView({
|
||||
localCellsRef.current = localCells;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRef.current) {
|
||||
if (!isDraggingRef.current && !isSettlingRef.current) {
|
||||
setLocalCells(cells);
|
||||
}
|
||||
}, [cells]);
|
||||
}, [cells, settleVersion]);
|
||||
|
||||
const recentlyMovedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -1062,11 +1072,19 @@ export function SwimLaneView({
|
||||
return;
|
||||
}
|
||||
|
||||
onMoveIssue(activeId, {
|
||||
...targetLane.moveUpdates,
|
||||
status: finalOverCell.status as IssueStatus,
|
||||
position: newPosition,
|
||||
});
|
||||
isSettlingRef.current = true;
|
||||
onMoveIssue(
|
||||
activeId,
|
||||
{
|
||||
...targetLane.moveUpdates,
|
||||
status: finalOverCell.status as IssueStatus,
|
||||
position: newPosition,
|
||||
},
|
||||
() => {
|
||||
isSettlingRef.current = false;
|
||||
setSettleVersion((v) => v + 1);
|
||||
},
|
||||
);
|
||||
},
|
||||
[cells, cellSet, laneByKey, laneGroups, onMoveIssue, swimlaneGrouping, viewStoreApi],
|
||||
);
|
||||
|
||||
68
packages/views/issues/components/use-drag-settle.ts
Normal file
68
packages/views/issues/components/use-drag-settle.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Shared drag/settle state machine for the issue boards (board-view, list-view).
|
||||
*
|
||||
* All three drag surfaces (board, list, swimlane) follow the same contract:
|
||||
*
|
||||
* - Local column state mirrors the TanStack Query cache *between* drags.
|
||||
* - While dragging, or while a drop is *settling* (the move mutation is
|
||||
* in flight), that mirror is frozen so an optimistic move isn't clobbered
|
||||
* by a cache change that lands mid-flight.
|
||||
* - On settle the lock releases and `settleVersion` bumps, forcing one resync
|
||||
* from the now-reconciled cache.
|
||||
*
|
||||
* This hook owns that primitive so the surfaces can't drift apart (list-view
|
||||
* once silently lost the optimistic-move half of it). The resync `useEffect`
|
||||
* itself stays in each caller because its dependency list is data-source
|
||||
* specific (workspace board vs. status-only list), but it reads `settleVersion`
|
||||
* and the refs from here.
|
||||
*
|
||||
* `initialColumns` is only read once (useState initializer); callers drive
|
||||
* subsequent updates through their own resync effect + `setColumns`.
|
||||
*/
|
||||
export function useDragSettle(
|
||||
initialColumns: () => Record<string, string[]>,
|
||||
) {
|
||||
const isDraggingRef = useRef(false);
|
||||
const isSettlingRef = useRef(false);
|
||||
// Throttles onDragOver: set true after a local move, cleared one frame later.
|
||||
const recentlyMovedRef = useRef(false);
|
||||
const [settleVersion, setSettleVersion] = useState(0);
|
||||
|
||||
const [columns, setColumns] = useState<Record<string, string[]>>(
|
||||
initialColumns,
|
||||
);
|
||||
const columnsRef = useRef(columns);
|
||||
columnsRef.current = columns;
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
recentlyMovedRef.current = false;
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [columns]);
|
||||
|
||||
/**
|
||||
* Engage the settle lock and return the `onSettled` callback to hand to the
|
||||
* move mutation. The callback releases the lock and triggers a single resync.
|
||||
*/
|
||||
const beginSettle = useCallback(() => {
|
||||
isSettlingRef.current = true;
|
||||
return () => {
|
||||
isSettlingRef.current = false;
|
||||
setSettleVersion((v) => v + 1);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
columns,
|
||||
setColumns,
|
||||
columnsRef,
|
||||
isDraggingRef,
|
||||
isSettlingRef,
|
||||
recentlyMovedRef,
|
||||
settleVersion,
|
||||
beginSettle,
|
||||
};
|
||||
}
|
||||
71
packages/views/issues/utils/drag-utils.test.ts
Normal file
71
packages/views/issues/utils/drag-utils.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import { insertIdByPosition } from "./drag-utils";
|
||||
|
||||
function mk(id: string, position: number): Issue {
|
||||
return {
|
||||
id,
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: `MUL-${id}`,
|
||||
title: id,
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
metadata: {},
|
||||
labels: [],
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function mapOf(...issues: Issue[]): Map<string, Issue> {
|
||||
return new Map(issues.map((i) => [i.id, i]));
|
||||
}
|
||||
|
||||
describe("insertIdByPosition", () => {
|
||||
it("inserts the id at its position-sorted slot", () => {
|
||||
const map = mapOf(mk("a", 1), mk("c", 3), mk("b", 2));
|
||||
expect(insertIdByPosition(["a", "c"], "b", 2, map)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends when the position is the largest", () => {
|
||||
const map = mapOf(mk("a", 1), mk("z", 9));
|
||||
expect(insertIdByPosition(["a"], "z", 9, map)).toEqual(["a", "z"]);
|
||||
});
|
||||
|
||||
it("prepends when the position is the smallest", () => {
|
||||
const map = mapOf(mk("b", 2), mk("a", 1));
|
||||
expect(insertIdByPosition(["b"], "a", 1, map)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("appends into an empty target column", () => {
|
||||
const map = mapOf(mk("a", 5));
|
||||
expect(insertIdByPosition([], "a", 5, map)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("matches insertByPosition ordering so the settle rebuild is a no-op", () => {
|
||||
// Same scenario the board's optimistic drop and the cache patch both apply:
|
||||
// landing a card between two neighbours must produce the same order in the
|
||||
// id list (board) and the issue list (cache).
|
||||
const map = mapOf(mk("x", 1), mk("y", 3), mk("moved", 2));
|
||||
expect(insertIdByPosition(["x", "y"], "moved", 2, map)).toEqual([
|
||||
"x",
|
||||
"moved",
|
||||
"y",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,27 @@ export function computePosition(ids: string[], activeId: string, issueMap: Map<s
|
||||
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `id` into `ids` at the slot implied by `position ASC`, reading each
|
||||
* id's position from `issueMap`. Mirrors `insertByPosition` in
|
||||
* `@multica/core/issues/cache-helpers` so the board's optimistic placement on
|
||||
* drop matches the cache the settle reconcile rebuilds from — otherwise the
|
||||
* card would land in one slot, then jump when local columns re-derive from TQ.
|
||||
*/
|
||||
export function insertIdByPosition(
|
||||
ids: string[],
|
||||
id: string,
|
||||
position: number,
|
||||
issueMap: Map<string, Issue>,
|
||||
): string[] {
|
||||
const idx = ids.findIndex((existing) => {
|
||||
const p = issueMap.get(existing)?.position;
|
||||
return p !== undefined && p > position;
|
||||
});
|
||||
if (idx === -1) return [...ids, id];
|
||||
return [...ids.slice(0, idx), id, ...ids.slice(idx)];
|
||||
}
|
||||
|
||||
export function findColumn(
|
||||
columns: Record<string, string[]>,
|
||||
id: string,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
@@ -25,6 +26,8 @@ Your responsibilities, in order:
|
||||
|
||||
1. **Read the issue** (title, description, latest comments, acceptance
|
||||
criteria) and decide which squad member is best suited to do the work.
|
||||
Match the task to each member's listed **skills** and role in the Squad
|
||||
Roster below — prefer the member whose skills cover the work.
|
||||
2. **Delegate by @mention.** Post a single comment on this issue that
|
||||
@mentions the chosen member(s) and tells them what to do.
|
||||
- **Be terse.** Every Multica agent already has full context of the
|
||||
@@ -178,7 +181,9 @@ func renderMemberRow(ctx context.Context, q *db.Queries, m db.SquadMember) strin
|
||||
if ag.ArchivedAt.Valid {
|
||||
return ""
|
||||
}
|
||||
return formatRosterRow(ag.Name, "agent", role, formatMention(ag.Name, "agent", id))
|
||||
// Agents carry skills; surfacing them lets the leader delegate by
|
||||
// capability instead of guessing from the free-text role label.
|
||||
return formatRosterRow(ag.Name, "agent", role, agentSkillsRosterSegment(ctx, q, m.MemberID), formatMention(ag.Name, "agent", id))
|
||||
case "member":
|
||||
user, err := q.GetUser(ctx, m.MemberID)
|
||||
if err != nil {
|
||||
@@ -186,14 +191,38 @@ func renderMemberRow(ctx context.Context, q *db.Queries, m db.SquadMember) strin
|
||||
}
|
||||
// Mention syntax for humans uses the user_id (matches the rest of
|
||||
// the product — see util.MentionRe and frontend mention payloads).
|
||||
// Humans have no Multica skills, so no skills segment is rendered.
|
||||
userID := util.UUIDToString(m.MemberID)
|
||||
return formatRosterRow(user.Name, "member (human)", role, formatMention(user.Name, "member", userID))
|
||||
return formatRosterRow(user.Name, "member (human)", role, "", formatMention(user.Name, "member", userID))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatRosterRow(name, kind, role, mention string) string {
|
||||
// agentSkillsRosterSegment returns the roster segment describing an agent
|
||||
// member's assigned skills. "skills: a, b" when the agent has skills (the
|
||||
// names are pre-sorted by ListAgentSkillSummaries), "no skills assigned" when
|
||||
// it has none so the leader knows the capability is genuinely absent, and ""
|
||||
// only when the lookup fails — a transient DB error degrades to the prior
|
||||
// name+role row rather than asserting a misleading "no skills". Builtin
|
||||
// multica-* skills are added at runtime (not in agent_skill) and are
|
||||
// deliberately omitted; the leader cares about the configured capabilities.
|
||||
func agentSkillsRosterSegment(ctx context.Context, q *db.Queries, agentID pgtype.UUID) string {
|
||||
skills, err := q.ListAgentSkillSummaries(ctx, agentID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(skills) == 0 {
|
||||
return "no skills assigned"
|
||||
}
|
||||
names := make([]string, 0, len(skills))
|
||||
for _, s := range skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
return "skills: " + strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func formatRosterRow(name, kind, role, skills, mention string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("- ")
|
||||
sb.WriteString(name)
|
||||
@@ -204,6 +233,10 @@ func formatRosterRow(name, kind, role, mention string) string {
|
||||
sb.WriteString(role)
|
||||
sb.WriteString(`"`)
|
||||
}
|
||||
if skills != "" {
|
||||
sb.WriteString(" — ")
|
||||
sb.WriteString(skills)
|
||||
}
|
||||
sb.WriteString(" — `")
|
||||
sb.WriteString(mention)
|
||||
sb.WriteString("`\n")
|
||||
|
||||
@@ -150,6 +150,64 @@ func TestBuildSquadLeaderBriefing_FullSquad(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// assignSkillToAgent creates a workspace skill and attaches it to the agent,
|
||||
// registering cleanup for the skill row (agent_skill cascades on skill delete).
|
||||
func assignSkillToAgent(t *testing.T, agentID, skillName string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var skillID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO skill (workspace_id, name, description, content, created_by)
|
||||
VALUES ($1, $2, '', '', $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, skillName, testUserID).Scan(&skillID); err != nil {
|
||||
t.Fatalf("create skill %s: %v", skillName, err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM skill WHERE id = $1`, skillID) })
|
||||
if _, err := testPool.Exec(ctx,
|
||||
`INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`,
|
||||
agentID, skillID,
|
||||
); err != nil {
|
||||
t.Fatalf("assign skill %s to agent: %v", skillName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSquadLeaderBriefing_MemberSkillsInRoster locks in the delegation
|
||||
// fix: an agent member's assigned skills appear in the leader roster so the
|
||||
// leader can route by capability. Agents with no skills get an explicit
|
||||
// marker; human members never carry a skills segment.
|
||||
func TestBuildSquadLeaderBriefing_MemberSkillsInRoster(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
leaderID, _ := seededLeaderAgent(t)
|
||||
squad := seedSquadForBriefing(t, leaderID, "Skilled Squad", "")
|
||||
|
||||
skilled := createHandlerTestAgent(t, "Skilled Bot", []byte("[]"))
|
||||
addAgentMember(t, squad.ID, skilled, "backend")
|
||||
// ListAgentSkillSummaries orders by name ASC → "polars" before "stat…".
|
||||
assignSkillToAgent(t, skilled, "polars")
|
||||
assignSkillToAgent(t, skilled, "statistical-analysis")
|
||||
|
||||
plain := createHandlerTestAgent(t, "Plain Bot", []byte("[]"))
|
||||
addAgentMember(t, squad.ID, plain, "")
|
||||
|
||||
memberRowID, userID, userName := seededHumanMember(t)
|
||||
_ = memberRowID
|
||||
addHumanMember(t, squad.ID, userID, "reviewer")
|
||||
|
||||
out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad)
|
||||
|
||||
if !strings.Contains(out, "skills: polars, statistical-analysis") {
|
||||
t.Errorf("expected skilled member skills in roster, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Plain Bot — agent — no skills assigned") {
|
||||
t.Errorf("expected no-skills marker for skill-less agent, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, userName+" — member (human), role: \"reviewer\" — skills:") ||
|
||||
strings.Contains(out, userName+" — member (human), role: \"reviewer\" — no skills") {
|
||||
t.Errorf("human member must not render a skills segment, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSquadLeaderBriefing_OnlyLeader(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
leaderID, _ := seededLeaderAgent(t)
|
||||
@@ -226,34 +284,34 @@ func TestBuildSquadLeaderBriefing_MentionsRoundTrip(t *testing.T) {
|
||||
// claimAndDecodeAgent runs ClaimTaskByRuntime for the given runtime and
|
||||
// returns the agent block of the response. Fails the test on non-200.
|
||||
func claimAndDecodeAgent(t *testing.T, runtimeID string) *TaskAgentData {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-squad-briefing")
|
||||
req = withURLParam(req, "runtimeId", runtimeID)
|
||||
testHandler.ClaimTaskByRuntime(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Task *struct {
|
||||
Agent *TaskAgentData `json:"agent"`
|
||||
} `json:"task"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Task == nil || resp.Task.Agent == nil {
|
||||
t.Fatalf("expected task.agent in response, got: %s", w.Body.String())
|
||||
}
|
||||
return resp.Task.Agent
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-squad-briefing")
|
||||
req = withURLParam(req, "runtimeId", runtimeID)
|
||||
testHandler.ClaimTaskByRuntime(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Task *struct {
|
||||
Agent *TaskAgentData `json:"agent"`
|
||||
} `json:"task"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Task == nil || resp.Task.Agent == nil {
|
||||
t.Fatalf("expected task.agent in response, got: %s", w.Body.String())
|
||||
}
|
||||
return resp.Task.Agent
|
||||
}
|
||||
|
||||
// queueSquadIssueTaskFor creates an issue assigned to the squad and a queued
|
||||
// task for the given (agentID, runtimeID). Returns the issue + task IDs.
|
||||
func queueSquadIssueTaskFor(t *testing.T, squadID, agentID, runtimeID string, issueNumber int) (issueID, taskID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO issue (
|
||||
workspace_id, title, status, priority, creator_id, creator_type,
|
||||
assignee_type, assignee_id, number, position
|
||||
@@ -261,101 +319,101 @@ assignee_type, assignee_id, number, position
|
||||
'squad', $3, $4, 0)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, testUserID, squadID, issueNumber).Scan(&issueID); err != nil {
|
||||
t.Fatalf("create squad-assigned issue: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
|
||||
t.Fatalf("create squad-assigned issue: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
|
||||
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
|
||||
VALUES ($1, $2, $3, 'queued', 0)
|
||||
RETURNING id
|
||||
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
|
||||
t.Fatalf("queue task: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
||||
return
|
||||
t.Fatalf("queue task: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
||||
return
|
||||
}
|
||||
|
||||
// TestClaimTask_LeaderGetsBriefing — when the squad leader claims a task on
|
||||
// a squad-assigned issue, the response's agent.instructions must include
|
||||
// the Operating Protocol + Roster + user instructions.
|
||||
func TestClaimTask_LeaderGetsBriefing(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
var leaderID, runtimeID string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
|
||||
testWorkspaceID,
|
||||
).Scan(&leaderID, &runtimeID); err != nil {
|
||||
t.Fatalf("get leader agent: %v", err)
|
||||
}
|
||||
var leaderID, runtimeID string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
|
||||
testWorkspaceID,
|
||||
).Scan(&leaderID, &runtimeID); err != nil {
|
||||
t.Fatalf("get leader agent: %v", err)
|
||||
}
|
||||
|
||||
squad := seedSquadForBriefing(t, leaderID, "Briefing Claim Squad", "Be terse.")
|
||||
squad := seedSquadForBriefing(t, leaderID, "Briefing Claim Squad", "Be terse.")
|
||||
|
||||
helper := createHandlerTestAgent(t, "Briefing Helper", []byte("[]"))
|
||||
addAgentMember(t, squad.ID, helper, "implementer")
|
||||
helper := createHandlerTestAgent(t, "Briefing Helper", []byte("[]"))
|
||||
addAgentMember(t, squad.ID, helper, "implementer")
|
||||
|
||||
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), leaderID, runtimeID, 95001)
|
||||
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), leaderID, runtimeID, 95001)
|
||||
|
||||
agent := claimAndDecodeAgent(t, runtimeID)
|
||||
for _, want := range []string{
|
||||
"## Squad Operating Protocol",
|
||||
"## Squad Roster",
|
||||
"Leader (you):",
|
||||
"## Squad Instructions (Briefing Claim Squad)",
|
||||
"Be terse.",
|
||||
"`[@Briefing Helper](mention://agent/" + helper + ")`",
|
||||
} {
|
||||
if !strings.Contains(agent.Instructions, want) {
|
||||
t.Errorf("expected agent.instructions to contain %q\n--- instructions ---\n%s", want, agent.Instructions)
|
||||
}
|
||||
}
|
||||
agent := claimAndDecodeAgent(t, runtimeID)
|
||||
for _, want := range []string{
|
||||
"## Squad Operating Protocol",
|
||||
"## Squad Roster",
|
||||
"Leader (you):",
|
||||
"## Squad Instructions (Briefing Claim Squad)",
|
||||
"Be terse.",
|
||||
"`[@Briefing Helper](mention://agent/" + helper + ")`",
|
||||
} {
|
||||
if !strings.Contains(agent.Instructions, want) {
|
||||
t.Errorf("expected agent.instructions to contain %q\n--- instructions ---\n%s", want, agent.Instructions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimTask_NonLeaderGetsNoBriefing — when a non-leader squad member
|
||||
// claims a task on a squad-assigned issue, NO briefing is injected.
|
||||
func TestClaimTask_NonLeaderGetsNoBriefing(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
var leaderID string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
|
||||
testWorkspaceID,
|
||||
).Scan(&leaderID); err != nil {
|
||||
t.Fatalf("get leader agent: %v", err)
|
||||
}
|
||||
var leaderID string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
|
||||
testWorkspaceID,
|
||||
).Scan(&leaderID); err != nil {
|
||||
t.Fatalf("get leader agent: %v", err)
|
||||
}
|
||||
|
||||
squad := seedSquadForBriefing(t, leaderID, "Non-Leader Squad", "Squad guidance.")
|
||||
squad := seedSquadForBriefing(t, leaderID, "Non-Leader Squad", "Squad guidance.")
|
||||
|
||||
// Create a second agent (NOT the leader) with its own runtime so the
|
||||
// claim path picks its task without ambiguity.
|
||||
helperID := createHandlerTestAgent(t, "Non Leader Helper", []byte("[]"))
|
||||
addAgentMember(t, squad.ID, helperID, "")
|
||||
var helperRuntime string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT runtime_id FROM agent WHERE id = $1`, helperID,
|
||||
).Scan(&helperRuntime); err != nil {
|
||||
t.Fatalf("get helper runtime: %v", err)
|
||||
}
|
||||
// Create a second agent (NOT the leader) with its own runtime so the
|
||||
// claim path picks its task without ambiguity.
|
||||
helperID := createHandlerTestAgent(t, "Non Leader Helper", []byte("[]"))
|
||||
addAgentMember(t, squad.ID, helperID, "")
|
||||
var helperRuntime string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT runtime_id FROM agent WHERE id = $1`, helperID,
|
||||
).Scan(&helperRuntime); err != nil {
|
||||
t.Fatalf("get helper runtime: %v", err)
|
||||
}
|
||||
|
||||
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), helperID, helperRuntime, 95002)
|
||||
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), helperID, helperRuntime, 95002)
|
||||
|
||||
agent := claimAndDecodeAgent(t, helperRuntime)
|
||||
for _, mustNot := range []string{
|
||||
"Squad Operating Protocol",
|
||||
"Squad Roster",
|
||||
"Squad Instructions (Non-Leader Squad)",
|
||||
} {
|
||||
if strings.Contains(agent.Instructions, mustNot) {
|
||||
t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mustNot, agent.Instructions)
|
||||
}
|
||||
}
|
||||
agent := claimAndDecodeAgent(t, helperRuntime)
|
||||
for _, mustNot := range []string{
|
||||
"Squad Operating Protocol",
|
||||
"Squad Roster",
|
||||
"Squad Instructions (Non-Leader Squad)",
|
||||
} {
|
||||
if strings.Contains(agent.Instructions, mustNot) {
|
||||
t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mustNot, agent.Instructions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid "imported and not used: pgtype" if helpers above are the only users.
|
||||
|
||||
@@ -138,7 +138,12 @@ agent instructions. The briefing includes:
|
||||
- Squad Instructions, only when `instructions` is non-empty.
|
||||
|
||||
Roster entries include member name, member type, mention markdown, and non-empty
|
||||
role. Archived agent members are skipped from the briefing roster.
|
||||
role. For agent members the roster also lists their assigned skills
|
||||
(`skills: a, b`, or `no skills assigned` when the agent has none) so the leader
|
||||
can delegate by capability instead of guessing from the role label; human
|
||||
members carry no skills segment. Builtin `multica-*` skills are not listed —
|
||||
only the workspace skills explicitly attached to the agent. Archived agent
|
||||
members are skipped from the briefing roster.
|
||||
|
||||
## Issue assignment behavior
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ Contracts:
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/internal/handler/squad_briefing.go # buildSquadLeaderBriefing ~104, buildSquadRoster ~121, renderMemberRow ~169
|
||||
server/internal/handler/squad_briefing.go # buildSquadLeaderBriefing ~104, buildSquadRoster ~121, renderMemberRow ~169, agentSkillsRosterSegment, formatRosterRow
|
||||
server/internal/handler/daemon.go # briefing injection ~1187, ~1530
|
||||
```
|
||||
|
||||
@@ -93,6 +93,10 @@ Contracts:
|
||||
(squad_briefing.go:104-117);
|
||||
- `instructions` section appears only when non-empty (squad_briefing.go:110-112);
|
||||
- archived agent members are skipped from roster (squad_briefing.go:178-179);
|
||||
- agent member roster rows list assigned workspace skills via
|
||||
`agentSkillsRosterSegment` (ListAgentSkillSummaries) — "skills: a, b" or
|
||||
"no skills assigned"; builtin multica-* skills are excluded and human
|
||||
members carry no skills segment (squad_briefing.go renderMemberRow);
|
||||
- no traced behavior injects `instructions` into every squad member.
|
||||
|
||||
## Issue Assignment
|
||||
|
||||
Reference in New Issue
Block a user