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:
Jiayuan Zhang
2026-07-15 11:21:54 +08:00
committed by GitHub
parent 5999eabd92
commit 8f92b5fdeb
15 changed files with 554 additions and 22 deletions

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "システムテーマを使用"

View File

@@ -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": "시스템 테마 사용"

View File

@@ -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": "跟随系统主题"

View File

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

View File

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