mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
feat(search): add fold/unfold all comments commands to the command palette (#5417)
On an issue page, Cmd+K now offers Fold All Comments / Unfold All Comments. Folding collapses every thread card via the persisted comment-collapse store; unfolding also expands resolved threads, whose session-only expand state moves from issue-detail useState into a new core resolved-expand store so the palette can drive it (MUL-4763). Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
81
packages/core/issues/stores/comment-collapse-store.test.ts
Normal file
81
packages/core/issues/stores/comment-collapse-store.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCommentCollapseStore } from "./comment-collapse-store";
|
||||
|
||||
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
|
||||
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
|
||||
// can round-trip values.
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.localStorage?.setItem !== "function") {
|
||||
const values = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (k) => values.get(k) ?? null,
|
||||
key: (i) => Array.from(values.keys())[i] ?? null,
|
||||
removeItem: (k) => { values.delete(k); },
|
||||
setItem: (k, v) => { values.set(k, v); },
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
|
||||
}
|
||||
});
|
||||
|
||||
describe("comment collapse store", () => {
|
||||
beforeEach(() => {
|
||||
useCommentCollapseStore.setState({ collapsedByIssue: {} });
|
||||
});
|
||||
|
||||
it("toggle collapses and expands a single comment", () => {
|
||||
const { toggle } = useCommentCollapseStore.getState();
|
||||
|
||||
toggle("issue-1", "c1");
|
||||
expect(useCommentCollapseStore.getState().isCollapsed("issue-1", "c1")).toBe(true);
|
||||
expect(useCommentCollapseStore.getState().isCollapsed("issue-1", "c2")).toBe(false);
|
||||
|
||||
toggle("issue-1", "c1");
|
||||
expect(useCommentCollapseStore.getState().isCollapsed("issue-1", "c1")).toBe(false);
|
||||
expect(useCommentCollapseStore.getState().collapsedByIssue).toEqual({});
|
||||
});
|
||||
|
||||
it("collapseAll replaces the issue's collapsed set and dedupes ids", () => {
|
||||
const { toggle, collapseAll } = useCommentCollapseStore.getState();
|
||||
toggle("issue-1", "stale");
|
||||
toggle("issue-2", "kept");
|
||||
|
||||
collapseAll("issue-1", ["c1", "c2", "c1"]);
|
||||
|
||||
const state = useCommentCollapseStore.getState();
|
||||
expect(state.collapsedByIssue["issue-1"]).toEqual(["c1", "c2"]);
|
||||
expect(state.isCollapsed("issue-1", "stale")).toBe(false);
|
||||
expect(state.isCollapsed("issue-2", "kept")).toBe(true);
|
||||
});
|
||||
|
||||
it("collapseAll with no ids clears the issue entry", () => {
|
||||
const { collapseAll } = useCommentCollapseStore.getState();
|
||||
collapseAll("issue-1", ["c1"]);
|
||||
|
||||
collapseAll("issue-1", []);
|
||||
expect(useCommentCollapseStore.getState().collapsedByIssue).toEqual({});
|
||||
|
||||
const before = useCommentCollapseStore.getState().collapsedByIssue;
|
||||
collapseAll("issue-1", []);
|
||||
expect(useCommentCollapseStore.getState().collapsedByIssue).toBe(before);
|
||||
});
|
||||
|
||||
it("expandAll clears one issue without touching others", () => {
|
||||
const { collapseAll, expandAll } = useCommentCollapseStore.getState();
|
||||
collapseAll("issue-1", ["c1", "c2"]);
|
||||
collapseAll("issue-2", ["c9"]);
|
||||
|
||||
expandAll("issue-1");
|
||||
|
||||
const state = useCommentCollapseStore.getState();
|
||||
expect(state.collapsedByIssue["issue-1"]).toBeUndefined();
|
||||
expect(state.collapsedByIssue["issue-2"]).toEqual(["c9"]);
|
||||
|
||||
const before = state.collapsedByIssue;
|
||||
expandAll("issue-1");
|
||||
expect(useCommentCollapseStore.getState().collapsedByIssue).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,10 @@ interface CommentCollapseStore {
|
||||
collapsedByIssue: Record<string, string[]>;
|
||||
isCollapsed: (issueId: string, commentId: string) => boolean;
|
||||
toggle: (issueId: string, commentId: string) => void;
|
||||
/** Replace the issue's collapsed set with `commentIds` (fold-all). */
|
||||
collapseAll: (issueId: string, commentIds: readonly string[]) => void;
|
||||
/** Clear the issue's collapsed set (unfold-all). */
|
||||
expandAll: (issueId: string) => void;
|
||||
}
|
||||
|
||||
export const useCommentCollapseStore = create<CommentCollapseStore>()(
|
||||
@@ -35,6 +39,21 @@ export const useCommentCollapseStore = create<CommentCollapseStore>()(
|
||||
}
|
||||
return { collapsedByIssue: { ...s.collapsedByIssue, [issueId]: [...current, commentId] } };
|
||||
}),
|
||||
collapseAll: (issueId, commentIds) =>
|
||||
set((s) => {
|
||||
if (commentIds.length === 0) {
|
||||
if (!(issueId in s.collapsedByIssue)) return s;
|
||||
const { [issueId]: _, ...rest } = s.collapsedByIssue;
|
||||
return { collapsedByIssue: rest };
|
||||
}
|
||||
return { collapsedByIssue: { ...s.collapsedByIssue, [issueId]: [...new Set(commentIds)] } };
|
||||
}),
|
||||
expandAll: (issueId) =>
|
||||
set((s) => {
|
||||
if (!(issueId in s.collapsedByIssue)) return s;
|
||||
const { [issueId]: _, ...rest } = s.collapsedByIssue;
|
||||
return { collapsedByIssue: rest };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_comment_collapse",
|
||||
|
||||
@@ -17,6 +17,10 @@ export {
|
||||
} from "./view-store-context";
|
||||
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
|
||||
export { useCommentCollapseStore } from "./comment-collapse-store";
|
||||
export {
|
||||
useResolvedExpandStore,
|
||||
selectExpandedResolved,
|
||||
} from "./resolved-expand-store";
|
||||
export { useCommentComposerStore } from "./comment-composer-store";
|
||||
export { useCommentDraftStore, type CommentDraftKey } from "./comment-draft-store";
|
||||
export {
|
||||
|
||||
84
packages/core/issues/stores/resolved-expand-store.test.ts
Normal file
84
packages/core/issues/stores/resolved-expand-store.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { selectExpandedResolved, useResolvedExpandStore } from "./resolved-expand-store";
|
||||
|
||||
describe("resolved expand store", () => {
|
||||
beforeEach(() => {
|
||||
useResolvedExpandStore.setState({ expandedByIssue: {} });
|
||||
});
|
||||
|
||||
it("setExpanded adds and removes ids per issue", () => {
|
||||
const { setExpanded } = useResolvedExpandStore.getState();
|
||||
|
||||
setExpanded("issue-1", "c1", true);
|
||||
setExpanded("issue-1", "c2", true);
|
||||
setExpanded("issue-2", "c9", true);
|
||||
|
||||
const state = useResolvedExpandStore.getState();
|
||||
expect([...state.expandedByIssue["issue-1"]!]).toEqual(["c1", "c2"]);
|
||||
expect([...state.expandedByIssue["issue-2"]!]).toEqual(["c9"]);
|
||||
|
||||
setExpanded("issue-1", "c1", false);
|
||||
expect([...useResolvedExpandStore.getState().expandedByIssue["issue-1"]!]).toEqual(["c2"]);
|
||||
});
|
||||
|
||||
it("setExpanded drops the issue key when its last id is removed", () => {
|
||||
const { setExpanded } = useResolvedExpandStore.getState();
|
||||
setExpanded("issue-1", "c1", true);
|
||||
setExpanded("issue-1", "c1", false);
|
||||
|
||||
expect(useResolvedExpandStore.getState().expandedByIssue).toEqual({});
|
||||
});
|
||||
|
||||
it("setExpanded is a no-op when the id is already in the requested state", () => {
|
||||
const { setExpanded } = useResolvedExpandStore.getState();
|
||||
setExpanded("issue-1", "c1", true);
|
||||
const before = useResolvedExpandStore.getState().expandedByIssue;
|
||||
|
||||
setExpanded("issue-1", "c1", true);
|
||||
setExpanded("issue-1", "missing", false);
|
||||
|
||||
expect(useResolvedExpandStore.getState().expandedByIssue).toBe(before);
|
||||
});
|
||||
|
||||
it("expandAll unions ids into the issue's set and ignores empty input", () => {
|
||||
const { setExpanded, expandAll } = useResolvedExpandStore.getState();
|
||||
setExpanded("issue-1", "c1", true);
|
||||
|
||||
expandAll("issue-1", ["c2", "c3", "c2"]);
|
||||
expect([...useResolvedExpandStore.getState().expandedByIssue["issue-1"]!].sort()).toEqual([
|
||||
"c1",
|
||||
"c2",
|
||||
"c3",
|
||||
]);
|
||||
|
||||
const before = useResolvedExpandStore.getState().expandedByIssue;
|
||||
expandAll("issue-1", []);
|
||||
expect(useResolvedExpandStore.getState().expandedByIssue).toBe(before);
|
||||
});
|
||||
|
||||
it("collapseAll clears one issue without touching others", () => {
|
||||
const { expandAll, collapseAll } = useResolvedExpandStore.getState();
|
||||
expandAll("issue-1", ["c1", "c2"]);
|
||||
expandAll("issue-2", ["c9"]);
|
||||
|
||||
collapseAll("issue-1");
|
||||
|
||||
const state = useResolvedExpandStore.getState();
|
||||
expect(state.expandedByIssue["issue-1"]).toBeUndefined();
|
||||
expect([...state.expandedByIssue["issue-2"]!]).toEqual(["c9"]);
|
||||
|
||||
const before = state.expandedByIssue;
|
||||
collapseAll("issue-1");
|
||||
expect(useResolvedExpandStore.getState().expandedByIssue).toBe(before);
|
||||
});
|
||||
|
||||
it("selectExpandedResolved returns a stable empty set for untracked issues", () => {
|
||||
const select = selectExpandedResolved("issue-1");
|
||||
const a = select(useResolvedExpandStore.getState());
|
||||
useResolvedExpandStore.getState().setExpanded("issue-2", "c9", true);
|
||||
const b = select(useResolvedExpandStore.getState());
|
||||
|
||||
expect(a.size).toBe(0);
|
||||
expect(b).toBe(a);
|
||||
});
|
||||
});
|
||||
66
packages/core/issues/stores/resolved-expand-store.ts
Normal file
66
packages/core/issues/stores/resolved-expand-store.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* Tracks which resolved threads are temporarily expanded, keyed by issue ID.
|
||||
* Only expanded thread-root IDs are stored — folded-to-a-bar is the default.
|
||||
*
|
||||
* Deliberately NOT persisted (matches Linear): a reload folds every resolved
|
||||
* thread back to its summary bar. It lives in a store rather than issue-detail
|
||||
* component state so the command palette's fold/unfold-all-comments commands
|
||||
* can drive it from outside the issue page.
|
||||
*/
|
||||
interface ResolvedExpandStore {
|
||||
expandedByIssue: Record<string, ReadonlySet<string>>;
|
||||
setExpanded: (issueId: string, commentId: string, expand: boolean) => void;
|
||||
/** Expand every thread root in `commentIds` at once (unfold-all). */
|
||||
expandAll: (issueId: string, commentIds: readonly string[]) => void;
|
||||
/** Fold every expanded thread back to its bar (fold-all). */
|
||||
collapseAll: (issueId: string) => void;
|
||||
}
|
||||
|
||||
const EMPTY_EXPANDED: ReadonlySet<string> = new Set();
|
||||
|
||||
function withoutIssue(
|
||||
expandedByIssue: Record<string, ReadonlySet<string>>,
|
||||
issueId: string,
|
||||
) {
|
||||
const { [issueId]: _, ...rest } = expandedByIssue;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export const useResolvedExpandStore = create<ResolvedExpandStore>()((set) => ({
|
||||
expandedByIssue: {},
|
||||
setExpanded: (issueId, commentId, expand) =>
|
||||
set((s) => {
|
||||
const current = s.expandedByIssue[issueId] ?? EMPTY_EXPANDED;
|
||||
if (current.has(commentId) === expand) return s;
|
||||
const next = new Set(current);
|
||||
if (expand) next.add(commentId);
|
||||
else next.delete(commentId);
|
||||
if (next.size === 0) {
|
||||
return { expandedByIssue: withoutIssue(s.expandedByIssue, issueId) };
|
||||
}
|
||||
return { expandedByIssue: { ...s.expandedByIssue, [issueId]: next } };
|
||||
}),
|
||||
expandAll: (issueId, commentIds) =>
|
||||
set((s) => {
|
||||
if (commentIds.length === 0) return s;
|
||||
const next = new Set(s.expandedByIssue[issueId] ?? EMPTY_EXPANDED);
|
||||
for (const id of commentIds) next.add(id);
|
||||
return { expandedByIssue: { ...s.expandedByIssue, [issueId]: next } };
|
||||
}),
|
||||
collapseAll: (issueId) =>
|
||||
set((s) => {
|
||||
if (!(issueId in s.expandedByIssue)) return s;
|
||||
return { expandedByIssue: withoutIssue(s.expandedByIssue, issueId) };
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Stable-reference selector for one issue's expanded set (falls back to a
|
||||
* shared frozen empty set so unrelated store writes don't re-render readers).
|
||||
*/
|
||||
export function selectExpandedResolved(issueId: string) {
|
||||
return (s: ResolvedExpandStore): ReadonlySet<string> =>
|
||||
s.expandedByIssue[issueId] ?? EMPTY_EXPANDED;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, TimelineEntry } from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import { useResolvedExpandStore } from "@multica/core/issues/stores/resolved-expand-store";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enIssues from "../../locales/en/issues.json";
|
||||
|
||||
@@ -256,7 +257,13 @@ vi.mock("@multica/core/issues/config", () => ({
|
||||
|
||||
// Mock recent issues store
|
||||
const mockRecordVisit = vi.fn();
|
||||
vi.mock("@multica/core/issues/stores", () => ({
|
||||
vi.mock("@multica/core/issues/stores", async () => ({
|
||||
// Real store, not a stub: resolved-thread expand/collapse behavior under
|
||||
// test runs through it. Deep import keeps the persisted sibling stores
|
||||
// (which need localStorage) out of this mock.
|
||||
...(await vi.importActual<
|
||||
typeof import("@multica/core/issues/stores/resolved-expand-store")
|
||||
>("@multica/core/issues/stores/resolved-expand-store")),
|
||||
useRecentIssuesStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = { byWorkspace: {}, recordVisit: mockRecordVisit, pruneWorkspaces: vi.fn() };
|
||||
@@ -355,6 +362,9 @@ beforeEach(() => {
|
||||
writable: true,
|
||||
value: scrollIntoViewSpy,
|
||||
});
|
||||
// The resolved-expand store is module-global (not per-mount like the old
|
||||
// useState); reset so one test's expansions can't leak into the next.
|
||||
useResolvedExpandStore.setState({ expandedByIssue: {} });
|
||||
});
|
||||
|
||||
// Mock modals
|
||||
|
||||
@@ -75,7 +75,12 @@ import { projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { issueLabelsOptions } from "@multica/core/labels";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useCommentComposerStore, useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import {
|
||||
selectExpandedResolved,
|
||||
useCommentComposerStore,
|
||||
useRecentIssuesStore,
|
||||
useResolvedExpandStore,
|
||||
} from "@multica/core/issues/stores";
|
||||
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
|
||||
import { BatchActionToolbar } from "./batch-action-toolbar";
|
||||
import { useIssueTimeline } from "../hooks/use-issue-timeline";
|
||||
@@ -780,14 +785,12 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
|
||||
// Per-session: which resolved threads the user has temporarily expanded.
|
||||
// Not persisted (matches Linear) — reload collapses everything back to bars.
|
||||
const [expandedResolved, setExpandedResolved] = useState<Set<string>>(() => new Set());
|
||||
// Store-backed (not component state) so the command palette's fold/unfold
|
||||
// all-comments commands can drive it from outside this page.
|
||||
const expandedResolved = useResolvedExpandStore(selectExpandedResolved(id));
|
||||
const setResolvedExpanded = useResolvedExpandStore((s) => s.setExpanded);
|
||||
const toggleResolvedExpand = useCallback((commentId: string, expand: boolean) => {
|
||||
setExpandedResolved((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (expand) next.add(commentId);
|
||||
else next.delete(commentId);
|
||||
return next;
|
||||
});
|
||||
setResolvedExpanded(id, commentId, expand);
|
||||
// On collapse the thread shrinks and the viewport would jump to whatever was
|
||||
// below; pull the just-folded thread back into view with the smallest
|
||||
// movement. rAF waits for the collapse to land before measuring.
|
||||
@@ -796,15 +799,10 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
document.getElementById(`comment-${commentId}`)?.scrollIntoView({ block: "nearest" }),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [id, setResolvedExpanded]);
|
||||
const clearResolvedExpand = useCallback((commentId: string) => {
|
||||
setExpandedResolved((prev) => {
|
||||
if (!prev.has(commentId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(commentId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
setResolvedExpanded(id, commentId, false);
|
||||
}, [id, setResolvedExpanded]);
|
||||
|
||||
// Per-session activity-block expansion overrides. The default rule is
|
||||
// "only the trailing block is expanded" (computed from timelineView.groups
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineEntry } from "@multica/core/types";
|
||||
import { collectThreadReplies } from "./thread-utils";
|
||||
import {
|
||||
collectThreadReplies,
|
||||
resolvedThreadRootIds,
|
||||
rootCommentIds,
|
||||
} from "./thread-utils";
|
||||
|
||||
function comment(id: string, createdAt: string, parentId: string | null): TimelineEntry {
|
||||
return {
|
||||
@@ -61,3 +65,65 @@ describe("collectThreadReplies", () => {
|
||||
expect(out.map((e) => e.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
function activity(id: string, createdAt: string): TimelineEntry {
|
||||
return {
|
||||
type: "activity",
|
||||
id,
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
action: "status_changed",
|
||||
created_at: createdAt,
|
||||
} as TimelineEntry;
|
||||
}
|
||||
|
||||
describe("rootCommentIds", () => {
|
||||
it("returns top-level comments only, skipping replies and activities", () => {
|
||||
const entries = [
|
||||
activity("act-1", "2026-06-11T09:00:00Z"),
|
||||
comment("root-1", "2026-06-11T10:00:00Z", null),
|
||||
comment("reply-1", "2026-06-11T10:05:00Z", "root-1"),
|
||||
comment("root-2", "2026-06-11T11:00:00Z", null),
|
||||
];
|
||||
|
||||
expect(rootCommentIds(entries)).toEqual(["root-1", "root-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvedThreadRootIds", () => {
|
||||
it("includes root-resolved and reply-resolved threads, excludes unresolved", () => {
|
||||
const rootResolved = {
|
||||
...comment("root-resolved", "2026-06-11T10:00:00Z", null),
|
||||
resolved_at: "2026-06-11T12:00:00Z",
|
||||
};
|
||||
const replyResolvedRoot = comment("root-reply-resolved", "2026-06-11T10:10:00Z", null);
|
||||
const resolutionReply = {
|
||||
...comment("reply-resolution", "2026-06-11T10:20:00Z", "root-reply-resolved"),
|
||||
resolved_at: "2026-06-11T12:30:00Z",
|
||||
};
|
||||
const openRoot = comment("root-open", "2026-06-11T10:30:00Z", null);
|
||||
const openReply = comment("reply-open", "2026-06-11T10:40:00Z", "root-open");
|
||||
|
||||
const ids = resolvedThreadRootIds([
|
||||
activity("act-1", "2026-06-11T09:00:00Z"),
|
||||
rootResolved,
|
||||
replyResolvedRoot,
|
||||
resolutionReply,
|
||||
openRoot,
|
||||
openReply,
|
||||
]);
|
||||
|
||||
expect(ids).toEqual(["root-resolved", "root-reply-resolved"]);
|
||||
});
|
||||
|
||||
it("detects a resolution on a nested reply", () => {
|
||||
const root = comment("root", "2026-06-11T10:00:00Z", null);
|
||||
const reply = comment("reply", "2026-06-11T10:05:00Z", "root");
|
||||
const nested = {
|
||||
...comment("nested", "2026-06-11T10:10:00Z", "reply"),
|
||||
resolved_at: "2026-06-11T12:00:00Z",
|
||||
};
|
||||
|
||||
expect(resolvedThreadRootIds([root, reply, nested])).toEqual(["root"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,3 +60,42 @@ export function deriveThreadResolution(
|
||||
}
|
||||
return chosen ? { kind: "reply", resolutionId: chosen.id } : { kind: "none" };
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of every thread root (top-level comment) in a timeline — the units the
|
||||
* per-comment collapse store folds. Same root/reply split as issue-detail's
|
||||
* `timelineView` grouping: a comment is a root iff it has no `parent_id`.
|
||||
*/
|
||||
export function rootCommentIds(entries: readonly TimelineEntry[]): string[] {
|
||||
return entries
|
||||
.filter((e) => e.type === "comment" && !e.parent_id)
|
||||
.map((e) => e.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of thread roots that carry a resolution (on the root itself or on a
|
||||
* reply) — the threads that render folded behind a bar until expanded via
|
||||
* `useResolvedExpandStore`. Unresolved roots are excluded on purpose: seeding
|
||||
* them into the expand set would keep them expanded through a later resolve.
|
||||
*/
|
||||
export function resolvedThreadRootIds(entries: readonly TimelineEntry[]): string[] {
|
||||
const roots: TimelineEntry[] = [];
|
||||
const repliesByParent = new Map<string, TimelineEntry[]>();
|
||||
for (const e of entries) {
|
||||
if (e.type !== "comment") continue;
|
||||
if (!e.parent_id) {
|
||||
roots.push(e);
|
||||
} else {
|
||||
const list = repliesByParent.get(e.parent_id) ?? [];
|
||||
list.push(e);
|
||||
repliesByParent.set(e.parent_id, list);
|
||||
}
|
||||
}
|
||||
return roots
|
||||
.filter(
|
||||
(root) =>
|
||||
deriveThreadResolution(root, collectThreadReplies(root.id, repliesByParent)).kind !==
|
||||
"none",
|
||||
)
|
||||
.map((root) => root.id);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"new_project": "New Project",
|
||||
"copy_issue_link": "Copy Issue Link",
|
||||
"copy_identifier": "Copy Identifier ({{identifier}})",
|
||||
"fold_all_comments": "Fold All Comments",
|
||||
"unfold_all_comments": "Unfold All Comments",
|
||||
"switch_to_light": "Switch to Light Theme",
|
||||
"switch_to_dark": "Switch to Dark Theme",
|
||||
"use_system_theme": "Use System Theme"
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"new_project": "新規プロジェクト",
|
||||
"copy_issue_link": "イシューのリンクをコピー",
|
||||
"copy_identifier": "識別子をコピー ({{identifier}})",
|
||||
"fold_all_comments": "すべてのコメントを折りたたむ",
|
||||
"unfold_all_comments": "すべてのコメントを展開",
|
||||
"switch_to_light": "ライトテーマに切り替え",
|
||||
"switch_to_dark": "ダークテーマに切り替え",
|
||||
"use_system_theme": "システムテーマを使用"
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"new_project": "새 프로젝트",
|
||||
"copy_issue_link": "이슈 링크 복사",
|
||||
"copy_identifier": "식별자 복사 ({{identifier}})",
|
||||
"fold_all_comments": "모든 댓글 접기",
|
||||
"unfold_all_comments": "모든 댓글 펼치기",
|
||||
"switch_to_light": "라이트 테마로 전환",
|
||||
"switch_to_dark": "다크 테마로 전환",
|
||||
"use_system_theme": "시스템 테마 사용"
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"new_project": "新建项目",
|
||||
"copy_issue_link": "复制 issue 链接",
|
||||
"copy_identifier": "复制标识符 ({{identifier}})",
|
||||
"fold_all_comments": "收起全部评论",
|
||||
"unfold_all_comments": "展开全部评论",
|
||||
"switch_to_light": "切换到浅色主题",
|
||||
"switch_to_dark": "切换到深色主题",
|
||||
"use_system_theme": "跟随系统主题"
|
||||
|
||||
@@ -40,6 +40,11 @@ const {
|
||||
mockOpenModal,
|
||||
mockToastSuccess,
|
||||
mockClipboardWrite,
|
||||
mockTimeline,
|
||||
mockCommentCollapseAll,
|
||||
mockCommentExpandAll,
|
||||
mockResolvedCollapseAll,
|
||||
mockResolvedExpandAll,
|
||||
} = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchIssues: vi.fn(),
|
||||
@@ -79,6 +84,11 @@ const {
|
||||
mockOpenModal: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockClipboardWrite: vi.fn(() => Promise.resolve()),
|
||||
mockTimeline: { current: [] as Array<Record<string, unknown>> },
|
||||
mockCommentCollapseAll: vi.fn(),
|
||||
mockCommentExpandAll: vi.fn(),
|
||||
mockResolvedCollapseAll: vi.fn(),
|
||||
mockResolvedExpandAll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
@@ -131,6 +141,18 @@ vi.mock("@multica/core/issues/stores", () => {
|
||||
wsId ? (state.byWorkspace[wsId] ?? EMPTY) : EMPTY,
|
||||
openCreateIssueWithPreference: (data?: Record<string, unknown> | null) =>
|
||||
mockOpenModal("quick-create-issue", data ?? null),
|
||||
useCommentCollapseStore: Object.assign(vi.fn(), {
|
||||
getState: () => ({
|
||||
collapseAll: mockCommentCollapseAll,
|
||||
expandAll: mockCommentExpandAll,
|
||||
}),
|
||||
}),
|
||||
useResolvedExpandStore: Object.assign(vi.fn(), {
|
||||
getState: () => ({
|
||||
collapseAll: mockResolvedCollapseAll,
|
||||
expandAll: mockResolvedExpandAll,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -160,6 +182,9 @@ vi.mock("@multica/core/issues/queries", () => ({
|
||||
issueDetailOptions: (_wsId: string, id: string) => ({
|
||||
queryKey: ["issues", "ws-test", "detail", id],
|
||||
}),
|
||||
issueTimelineOptions: (id: string) => ({
|
||||
queryKey: ["issues", "timeline", id],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
@@ -200,6 +225,12 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
},
|
||||
useQueries: (opts: { queries: Array<{ queryKey: readonly unknown[] }> }) =>
|
||||
opts.queries.map((q) => ({ data: resolveIssue(q.queryKey) })),
|
||||
useQueryClient: () => ({
|
||||
ensureQueryData: (opts: { queryKey: readonly unknown[] }) =>
|
||||
opts.queryKey[1] === "timeline"
|
||||
? Promise.resolve(mockTimeline.current)
|
||||
: Promise.reject(new Error(`unexpected key ${String(opts.queryKey)}`)),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
@@ -235,6 +266,11 @@ describe("SearchCommand", () => {
|
||||
mockOpenModal.mockReset();
|
||||
mockToastSuccess.mockReset();
|
||||
mockClipboardWrite.mockReset().mockResolvedValue(undefined);
|
||||
mockTimeline.current = [];
|
||||
mockCommentCollapseAll.mockReset();
|
||||
mockCommentExpandAll.mockReset();
|
||||
mockResolvedCollapseAll.mockReset();
|
||||
mockResolvedExpandAll.mockReset();
|
||||
|
||||
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -454,6 +490,81 @@ describe("SearchCommand", () => {
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("hides fold/unfold-all-comments commands off issue detail routes", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockPathname.current = "/ws-test/projects";
|
||||
renderSearch();
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "fold");
|
||||
|
||||
expect(screen.queryByText("Fold All Comments")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Unfold All Comments")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds all comments on the current issue, including expanded resolved threads", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockPathname.current = "/ws-test/issues/issue-1";
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-42", title: "Demo", status: "todo" },
|
||||
];
|
||||
mockTimeline.current = [
|
||||
{ type: "activity", id: "act-1", actor_type: "member", actor_id: "u1", created_at: "2026-01-01T00:00:00Z", action: "status_changed" },
|
||||
{ type: "comment", id: "root-1", actor_type: "member", actor_id: "u1", created_at: "2026-01-01T01:00:00Z", parent_id: null },
|
||||
{ type: "comment", id: "reply-1", actor_type: "member", actor_id: "u1", created_at: "2026-01-01T02:00:00Z", parent_id: "root-1" },
|
||||
{ type: "comment", id: "root-2", actor_type: "member", actor_id: "u1", created_at: "2026-01-01T03:00:00Z", parent_id: null, resolved_at: "2026-01-02T00:00:00Z" },
|
||||
];
|
||||
renderSearch();
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "fold");
|
||||
|
||||
const foldItem = await screen.findByText(
|
||||
(_, el) => el?.textContent === "Fold All Comments" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(foldItem);
|
||||
|
||||
await waitFor(() => {
|
||||
// Root comments only — replies fold with their thread, activities are not comments.
|
||||
expect(mockCommentCollapseAll).toHaveBeenCalledWith("issue-1", ["root-1", "root-2"]);
|
||||
});
|
||||
expect(mockResolvedCollapseAll).toHaveBeenCalledWith("issue-1");
|
||||
expect(mockCommentExpandAll).not.toHaveBeenCalled();
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("unfolds all comments and expands resolved threads", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockPathname.current = "/ws-test/issues/issue-1";
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-42", title: "Demo", status: "todo" },
|
||||
];
|
||||
mockTimeline.current = [
|
||||
{ type: "comment", id: "root-1", actor_type: "member", actor_id: "u1", created_at: "2026-01-01T01:00:00Z", parent_id: null },
|
||||
{ type: "comment", id: "root-2", actor_type: "member", actor_id: "u1", created_at: "2026-01-01T03:00:00Z", parent_id: null, resolved_at: "2026-01-02T00:00:00Z" },
|
||||
// root-3 is reply-resolved: the resolution lives on the reply.
|
||||
{ type: "comment", id: "root-3", actor_type: "member", actor_id: "u1", created_at: "2026-01-01T04:00:00Z", parent_id: null },
|
||||
{ type: "comment", id: "reply-3", actor_type: "member", actor_id: "u1", created_at: "2026-01-01T05:00:00Z", parent_id: "root-3", resolved_at: "2026-01-02T01:00:00Z" },
|
||||
];
|
||||
renderSearch();
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "unfold");
|
||||
|
||||
const unfoldItem = await screen.findByText(
|
||||
(_, el) => el?.textContent === "Unfold All Comments" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(unfoldItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCommentExpandAll).toHaveBeenCalledWith("issue-1");
|
||||
});
|
||||
// Only threads carrying a resolution get seeded into the expand set.
|
||||
expect(mockResolvedExpandAll).toHaveBeenCalledWith("issue-1", ["root-2", "root-3"]);
|
||||
expect(mockCommentCollapseAll).not.toHaveBeenCalled();
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("filters theme commands by query keywords", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSearch();
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
SearchIcon,
|
||||
Inbox,
|
||||
CircleUser,
|
||||
ListChevronsDownUp,
|
||||
ListChevronsUpDown,
|
||||
ListTodo,
|
||||
FolderKanban,
|
||||
Bot,
|
||||
@@ -24,7 +26,7 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
MemberWithUser,
|
||||
@@ -35,9 +37,11 @@ import { api } from "@multica/core/api";
|
||||
import {
|
||||
openCreateIssueWithPreference,
|
||||
selectRecentIssues,
|
||||
useCommentCollapseStore,
|
||||
useRecentIssuesStore,
|
||||
useResolvedExpandStore,
|
||||
} from "@multica/core/issues/stores";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { issueDetailOptions, issueTimelineOptions } from "@multica/core/issues/queries";
|
||||
import { useWorkspaceId } from "@multica/core";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import type { WorkspacePaths } from "@multica/core/paths";
|
||||
@@ -46,6 +50,7 @@ import { createShortcutChord } from "@multica/core/shortcuts";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url";
|
||||
import { StatusIcon } from "../issues/components";
|
||||
import { resolvedThreadRootIds, rootCommentIds } from "../issues/components/thread-utils";
|
||||
import { ProjectIcon } from "../projects/components/project-icon";
|
||||
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
||||
import type { ProjectStatus } from "@multica/core/types";
|
||||
@@ -201,6 +206,7 @@ export function SearchCommand() {
|
||||
...issueDetailOptions(wsId, currentIssueId ?? ""),
|
||||
enabled: !!currentIssueId,
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const activeThemeCheck = (value: ThemeValue) =>
|
||||
@@ -234,7 +240,7 @@ export function SearchCommand() {
|
||||
},
|
||||
];
|
||||
|
||||
if (currentIssue) {
|
||||
if (currentIssueId && currentIssue) {
|
||||
const identifier = currentIssue.identifier;
|
||||
items.push(
|
||||
{
|
||||
@@ -261,6 +267,46 @@ export function SearchCommand() {
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "fold-all-comments",
|
||||
label: t(($) => $.commands.fold_all_comments),
|
||||
icon: ListChevronsDownUp,
|
||||
keywords: ["fold", "collapse", "comments", "收起", "折叠", "评论"],
|
||||
onSelect: () => {
|
||||
// The timeline is already cached whenever the issue page has
|
||||
// rendered; ensureQueryData only fetches on a cold cache. If it
|
||||
// still can't load, no comments are on screen — dropping the
|
||||
// action matches the visible state.
|
||||
void queryClient
|
||||
.ensureQueryData(issueTimelineOptions(currentIssueId))
|
||||
.then((entries) => {
|
||||
useCommentCollapseStore
|
||||
.getState()
|
||||
.collapseAll(currentIssueId, rootCommentIds(entries));
|
||||
useResolvedExpandStore.getState().collapseAll(currentIssueId);
|
||||
})
|
||||
.catch(() => {});
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "unfold-all-comments",
|
||||
label: t(($) => $.commands.unfold_all_comments),
|
||||
icon: ListChevronsUpDown,
|
||||
keywords: ["unfold", "expand", "comments", "展开", "评论"],
|
||||
onSelect: () => {
|
||||
void queryClient
|
||||
.ensureQueryData(issueTimelineOptions(currentIssueId))
|
||||
.then((entries) => {
|
||||
useCommentCollapseStore.getState().expandAll(currentIssueId);
|
||||
useResolvedExpandStore
|
||||
.getState()
|
||||
.expandAll(currentIssueId, resolvedThreadRootIds(entries));
|
||||
})
|
||||
.catch(() => {});
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,7 +347,7 @@ export function SearchCommand() {
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [currentIssue, getShareableUrl, pathname, setOpen, setTheme, theme, t]);
|
||||
}, [currentIssue, currentIssueId, getShareableUrl, pathname, queryClient, setOpen, setTheme, theme, t]);
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user