Compare commits

...

11 Commits

Author SHA1 Message Date
Naiyuan Qing
53c04b6d6b refactor(issues): extract shared useDragSettle hook for board + list
board-view and list-view carried byte-identical drag/settle scaffolding (the
local columns mirror, the dragging/settling locks, the post-move animation-frame
throttle, and the settle callback). That duplication is exactly what let
list-view silently drift earlier (it had lost the optimistic-move half of the
fix, and its position-branch settle callback omitted the settleVersion bump).
Extract the primitive into useDragSettle so both surfaces share one
implementation and can't drift again.

Behavior-preserving for board-view. For list-view the one intended alignment:
its position-branch failed move now reverts, gaining the settleVersion bump
board-view already had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 20:45:20 +08:00
Naiyuan Qing
42b4bc6af5 fix(issues): add settle-lock to swimlane drag (no clobber mid-flight)
The swimlane drag had no settle window: the resync useEffect (and the issueMap
freeze) guarded only isDraggingRef, so a cache change landing after drop but
before the move settled could rebuild localCells out from under the optimistic
move. Adds isSettlingRef + settleVersion (mirroring board-view / list-view): the
lock is held from drop until onMoveIssue settles, then released, forcing a
single resync from the reconciled cache.

onMoveIssue now accepts the same optional onSettled callback board/list already
use; the parent handleMoveIssue supplies it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 20:04:14 +08:00
Naiyuan Qing
2aa71b3cd3 fix(issues): keep My-Issues/Project boards in place on non-membership WS change
onIssueUpdated now surgically patches the filtered myList (myAll) caches and
only invalidates them when the change can actually move an issue in/out of the
filter: an assignee change (covers My-Issues direct-assignee + the involves leg
+ actor panels) or a project change (Project board). A pure status / position /
priority / label change reconciles in place -- no refetch -- removing the last
drag flicker on filtered boards.

Uses the assignee_changed flag the server already sends on issue:updated
(surfaced on IssueUpdatedPayload + forwarded by the realtime dispatch); project
change is diffed client-side against the cached value. No predicate replication,
no backend change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 19:54:21 +08:00
Naiyuan Qing
3cf06f8028 fix(issues): list view optimistically moves row on non-position drag
The sortBy != "position" branch called onMoveIssue without moving the row in
local columns, so the row sat in its origin group for the whole request and
only jumped across on settle -- the same snap-back the board view had before
its fix. Now mirrors board-view: setColumns(insertIdByPosition) on drop so
the settle rebuild is a visual no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 19:26:43 +08:00
Naiyuan Qing
69399b920c fix(issues): batch update patches myList + stops list refetch on settle
- onMutate now patches both issueKeys.list and the filtered issueKeys.myAll
  bucketed caches, so a batch edit on a My-Issues / Project board is
  optimistic too. Previously only the workspace board was patched, so batch
  edits on those boards relied entirely on the settle refetch.
- onSettled no longer invalidates issueKeys.list: the optimistic patch is a
  complete reconcile for these bucketed boards (batch changes status /
  priority / project, never a server-computed value), so a full-board
  refetch only re-introduced the flicker the single-issue path removed.
  Aggregate / grouped caches still refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 19:26:43 +08:00
Naiyuan Qing
ac2f2c7789 fix(issues): drop redundant WS position->list invalidate
onIssueUpdated already surgically patches the non-filtered workspace board
via patchIssueInBuckets (cross-status move + same-column reorder). The extra
`if (position) invalidateQueries(list)` re-pulled the whole board on top of
that, re-introducing drag flicker through the echoed-back WS event. Removed.
Filtered myAll lists still invalidate (membership can change there) — the
client-side membership reconciliation for those is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 19:26:43 +08:00
Naiyuan Qing
f9cda25a06 fix(issues): patch My-Issues / Project board caches on move too
The drag fix made the board reconcile local columns from its feeding cache
on settle. The workspace board rides issueKeys.list (patched by onMutate),
but the My-Issues and Project boards ride the myList cache, which the
mutation did not patch — so a successful move snapped back on those boards.

useUpdateIssue now patches/snapshots/rolls back every bucketed list cache
(workspace list + myList), selected by the ListIssuesCache `byStatus` shape
so grouped (assignee) and flat (gantt) caches are skipped. Adds renderHook
regression tests covering both-cache optimistic move, both-cache rollback,
and no-list-invalidation-on-settle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 16:33:59 +08:00
Naiyuan Qing
24cf98dead fix(issues): stop kanban card snapping back on drag
A cross-column drag on a non-position-sorted board left the card in its
origin column for the whole request, then jumped to the target only when
the mutation settled — the "snaps back, then moves" glitch. Root cause was
three coupled choices in the optimistic path:

- board-view never updated local columns on drop for sortBy != "position"
  (onDragOver is a no-op there), so the card relied on the settle refetch
  to move across.
- useUpdateIssue invalidated the whole list on settle, replacing the column
  and re-landing the card even on success.
- patchIssueInBuckets appended a moved card to the column tail instead of
  its position slot, so any later cache refresh teleported it to the end.

Fixes:
- board-view: optimistically move the card into the target column on drop
  for the non-position path (insertIdByPosition), and reconcile local
  columns from the cache on settle for both paths (revert on error now that
  the list is no longer refetched).
- mutations: reconcile via onSuccess surgical patch of the returned entity;
  drop the list/detail invalidation from onSettled (aggregates still flush).
- cache-helpers: patchIssueInBuckets inserts the moved/reordered card at its
  position slot; a plain field update still keeps its slot.

Adds cache-helpers and drag-utils unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 16:15:27 +08:00
hal9000botagent
91e6c779d6 feat(squad): surface member skills in leader briefing roster (#4363)
When an issue is assigned to a squad, only the leader is triggered. The
leader briefing's Squad Roster listed each member's name, type, role, and
mention link — but not the member's assigned skills, so the leader had to
infer capability from the free-text role label when deciding who to
delegate to.

renderMemberRow now loads each agent member's assigned skills via
ListAgentSkillSummaries and formatRosterRow renders them as
"skills: a, b" (or "no skills assigned" when the agent has none). Builtin
multica-* skills are excluded (they live outside agent_skill); human
members carry no skills segment; a skill-lookup error degrades to the
prior name+role row rather than asserting a misleading "no skills".
Operating-protocol step 1 now tells the leader to match the task to each
member's listed skills.

Updates the multica-squads builtin skill and its source map to document
the new roster content, and adds
TestBuildSquadLeaderBriefing_MemberSkillsInRoster.

Co-authored-by: hal9000botagent <hal9000botagent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:33:12 +08:00
Stiliyan Monev
9d7060caf1 fix(auth): autofocus OTP input on verification step (#4344)
* fix(auth): autofocus OTP input on verification step

The email-verification step renders the OTP input without focus, so
users must click the field before typing the code. This is friction on
every login, especially when switching accounts.

Add `autoFocus` to the InputOTP so the cursor lands in the field as
soon as the step mounts. Mirrors the existing email-step input and the
mobile OTP component, both of which already autofocus.

* test(web): polyfill document.elementFromPoint for input-otp in jsdom

Autofocusing the OTP input makes input-otp run its focus-time DOM
measurement, which calls document.elementFromPoint. jsdom doesn't
implement it, so the web login test threw an unhandled error.

packages/views/test/setup.ts already stubs this for the same reason;
mirror the stub in the web test setup (which already stubs
ResizeObserver for input-otp).

* test(auth): assert OTP input autofocuses on verification step

Guards the autofocus behavior: the test fails if the autoFocus prop is
removed from the verification-step InputOTP. Lives in packages/views
since it covers shared component behavior, mocking @multica/core.
2026-06-22 10:00:57 +08:00
Naiyuan Qing
6d0e875dbb feat: add opt-in react-grab dev element inspector (web + desktop) (#4381)
* feat(web): add opt-in react-grab dev element inspector

Loads the react-grab overlay (hold ⌘C / Ctrl+C + click to copy an
element's source path + component stack) only when REACT_GRAB is set in
a local, gitignored apps/web/.env.local. Both the NODE_ENV and REACT_GRAB
guards are evaluated server-side in the root layout, so the <Script> tag
is omitted from the HTML for anyone who hasn't opted in — no effect on
other developers or production.

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

* feat(desktop): add opt-in react-grab dev element inspector

Mirrors the web wiring for the Electron renderer: injects the react-grab
overlay (hold ⌘C / Ctrl+C + click to copy an element's source path +
component stack) only when VITE_REACT_GRAB is set in a local, gitignored
apps/desktop/.env.development.local. Guarded by import.meta.env.DEV so the
branch is tree-shaken out of production builds; never activates for other
developers. No CSP/sandbox blocks the unpkg script (webSecurity is off).

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

* refactor(web): unify react-grab opt-in var to VITE_REACT_GRAB

Use the same env var name as the desktop renderer so one variable name
controls both apps. The desktop renderer is bundled by Vite, which only
exposes VITE_-prefixed vars to client code, so the shared name must carry
the VITE_ prefix; web reads it server-side where the name is unconstrained.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:49:24 +08:00
24 changed files with 1119 additions and 190 deletions

View File

@@ -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 />);

View File

@@ -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}

View File

@@ -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 (

View 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);
});
});

View File

@@ -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;

View File

@@ -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";

View File

@@ -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) });

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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} />);

View File

@@ -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) => {

View File

@@ -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 (

View File

@@ -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 = (

View File

@@ -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),
);
});

View File

@@ -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],
);

View 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,
};
}

View 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",
]);
});
});

View File

@@ -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,

View File

@@ -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")

View File

@@ -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.

View File

@@ -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

View File

@@ -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