From 8f92b5fdeb042934953039b3fe3230036a3abcbc Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 15 Jul 2026 11:21:54 +0800 Subject: [PATCH] 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 Co-authored-by: multica-agent --- .../stores/comment-collapse-store.test.ts | 81 +++++++++++++ .../issues/stores/comment-collapse-store.ts | 19 +++ packages/core/issues/stores/index.ts | 4 + .../stores/resolved-expand-store.test.ts | 84 +++++++++++++ .../issues/stores/resolved-expand-store.ts | 66 +++++++++++ .../issues/components/issue-detail.test.tsx | 12 +- .../views/issues/components/issue-detail.tsx | 30 +++-- .../issues/components/thread-utils.test.ts | 68 ++++++++++- .../views/issues/components/thread-utils.ts | 39 ++++++ packages/views/locales/en/search.json | 2 + packages/views/locales/ja/search.json | 2 + packages/views/locales/ko/search.json | 2 + packages/views/locales/zh-Hans/search.json | 2 + packages/views/search/search-command.test.tsx | 111 ++++++++++++++++++ packages/views/search/search-command.tsx | 54 ++++++++- 15 files changed, 554 insertions(+), 22 deletions(-) create mode 100644 packages/core/issues/stores/comment-collapse-store.test.ts create mode 100644 packages/core/issues/stores/resolved-expand-store.test.ts create mode 100644 packages/core/issues/stores/resolved-expand-store.ts diff --git a/packages/core/issues/stores/comment-collapse-store.test.ts b/packages/core/issues/stores/comment-collapse-store.test.ts new file mode 100644 index 0000000000..7ec3ba616e --- /dev/null +++ b/packages/core/issues/stores/comment-collapse-store.test.ts @@ -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(); + 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); + }); +}); diff --git a/packages/core/issues/stores/comment-collapse-store.ts b/packages/core/issues/stores/comment-collapse-store.ts index 59aa9606a0..cfe7570d72 100644 --- a/packages/core/issues/stores/comment-collapse-store.ts +++ b/packages/core/issues/stores/comment-collapse-store.ts @@ -11,6 +11,10 @@ interface CommentCollapseStore { collapsedByIssue: Record; 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()( @@ -35,6 +39,21 @@ export const useCommentCollapseStore = create()( } 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", diff --git a/packages/core/issues/stores/index.ts b/packages/core/issues/stores/index.ts index cdae4ae81c..b52161a021 100644 --- a/packages/core/issues/stores/index.ts +++ b/packages/core/issues/stores/index.ts @@ -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 { diff --git a/packages/core/issues/stores/resolved-expand-store.test.ts b/packages/core/issues/stores/resolved-expand-store.test.ts new file mode 100644 index 0000000000..5b4fa0bac5 --- /dev/null +++ b/packages/core/issues/stores/resolved-expand-store.test.ts @@ -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); + }); +}); diff --git a/packages/core/issues/stores/resolved-expand-store.ts b/packages/core/issues/stores/resolved-expand-store.ts new file mode 100644 index 0000000000..f8b02161f2 --- /dev/null +++ b/packages/core/issues/stores/resolved-expand-store.ts @@ -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>; + 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 = new Set(); + +function withoutIssue( + expandedByIssue: Record>, + issueId: string, +) { + const { [issueId]: _, ...rest } = expandedByIssue; + return rest; +} + +export const useResolvedExpandStore = create()((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 => + s.expandedByIssue[issueId] ?? EMPTY_EXPANDED; +} diff --git a/packages/views/issues/components/issue-detail.test.tsx b/packages/views/issues/components/issue-detail.test.tsx index 57ffae2c66..00ac4ded8a 100644 --- a/packages/views/issues/components/issue-detail.test.tsx +++ b/packages/views/issues/components/issue-detail.test.tsx @@ -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 diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index c41efacf7d..d27ac0316f 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -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>(() => 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 diff --git a/packages/views/issues/components/thread-utils.test.ts b/packages/views/issues/components/thread-utils.test.ts index 2c1f9c76ce..7886b22c80 100644 --- a/packages/views/issues/components/thread-utils.test.ts +++ b/packages/views/issues/components/thread-utils.test.ts @@ -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"]); + }); +}); diff --git a/packages/views/issues/components/thread-utils.ts b/packages/views/issues/components/thread-utils.ts index a47074fb91..e711831f7a 100644 --- a/packages/views/issues/components/thread-utils.ts +++ b/packages/views/issues/components/thread-utils.ts @@ -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(); + 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); +} diff --git a/packages/views/locales/en/search.json b/packages/views/locales/en/search.json index d3f156249d..cc5500de81 100644 --- a/packages/views/locales/en/search.json +++ b/packages/views/locales/en/search.json @@ -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" diff --git a/packages/views/locales/ja/search.json b/packages/views/locales/ja/search.json index 2a6d7f332f..4003e45ec0 100644 --- a/packages/views/locales/ja/search.json +++ b/packages/views/locales/ja/search.json @@ -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": "システムテーマを使用" diff --git a/packages/views/locales/ko/search.json b/packages/views/locales/ko/search.json index 11d01041de..b45552f0ad 100644 --- a/packages/views/locales/ko/search.json +++ b/packages/views/locales/ko/search.json @@ -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": "시스템 테마 사용" diff --git a/packages/views/locales/zh-Hans/search.json b/packages/views/locales/zh-Hans/search.json index 656d806967..f1567c387b 100644 --- a/packages/views/locales/zh-Hans/search.json +++ b/packages/views/locales/zh-Hans/search.json @@ -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": "跟随系统主题" diff --git a/packages/views/search/search-command.test.tsx b/packages/views/search/search-command.test.tsx index fe3d358199..60e08b9128 100644 --- a/packages/views/search/search-command.test.tsx +++ b/packages/views/search/search-command.test.tsx @@ -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> }, + 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 | 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(); diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx index 891fbda451..f10b33fc6b 100644 --- a/packages/views/search/search-command.tsx +++ b/packages/views/search/search-command.tsx @@ -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(() => { 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();