diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 76deef856b..3fdf2ef02a 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -35,6 +35,7 @@ import { loadInboxIssueColumns, loadInboxWorkItemGroupBy, loadCollapsedInboxGroupKeys, + loadCollapsedInboxParentIds, loadLastInboxTab, matchesInboxIssueSearch, normalizeInboxIssueColumns, @@ -45,6 +46,7 @@ import { resolveInboxSelectionIndex, saveInboxFilterPreferences, saveCollapsedInboxGroupKeys, + saveCollapsedInboxParentIds, saveInboxIssueColumns, saveInboxWorkItemGroupBy, saveLastInboxTab, @@ -1560,6 +1562,23 @@ describe("inbox helpers", () => { expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set()); }); + it("persists collapsed inbox parents per company", () => { + saveCollapsedInboxParentIds("company-1", new Set(["parent-1", "parent-2"])); + saveCollapsedInboxParentIds("company-2", new Set(["parent-3"])); + + expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set(["parent-1", "parent-2"])); + expect(loadCollapsedInboxParentIds("company-2")).toEqual(new Set(["parent-3"])); + + saveCollapsedInboxParentIds("company-1", new Set()); + expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set()); + }); + + it("returns empty collapsed inbox parents for missing or invalid storage", () => { + expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set()); + localStorage.setItem("paperclip:inbox:collapsed-parents:company-1", JSON.stringify({ nope: true })); + expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set()); + }); + it("does not reset workspace grouping before experimental settings have loaded", () => { expect(shouldResetInboxWorkspaceGrouping("workspace", false, false)).toBe(false); }); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index be0e9145f7..284a1163a2 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -26,6 +26,7 @@ export const INBOX_NESTING_KEY = "paperclip:inbox:nesting"; export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by"; export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters"; export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups"; +export const INBOX_COLLAPSED_PARENTS_KEY_PREFIX = "paperclip:inbox:collapsed-parents"; export type InboxTab = "mine" | "recent" | "unread" | "blocked" | "all"; export type InboxCategoryFilter = | "everything" @@ -187,6 +188,11 @@ function getInboxCollapsedGroupsStorageKey(companyId: string | null | undefined) return `${INBOX_COLLAPSED_GROUPS_KEY_PREFIX}:${companyId}`; } +function getInboxCollapsedParentsStorageKey(companyId: string | null | undefined): string | null { + if (!companyId) return null; + return `${INBOX_COLLAPSED_PARENTS_KEY_PREFIX}:${companyId}`; +} + export function loadInboxFilterPreferences( companyId: string | null | undefined, ): InboxFilterPreferences { @@ -271,6 +277,36 @@ export function saveCollapsedInboxGroupKeys( } } +export function loadCollapsedInboxParentIds( + companyId: string | null | undefined, +): Set { + const storageKey = getInboxCollapsedParentsStorageKey(companyId); + if (!storageKey) return new Set(); + + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []); + } catch { + return new Set(); + } +} + +export function saveCollapsedInboxParentIds( + companyId: string | null | undefined, + parentIds: ReadonlySet, +) { + const storageKey = getInboxCollapsedParentsStorageKey(companyId); + if (!storageKey) return; + + try { + localStorage.setItem(storageKey, JSON.stringify([...parentIds])); + } catch { + // Ignore localStorage failures. + } +} + export function loadDismissedInboxAlerts(): Set { try { const raw = localStorage.getItem(DISMISSED_KEY); diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 2879a6841d..4e5cb53bb5 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -345,6 +345,80 @@ describe("Inbox toolbar", () => { act(() => root.unmount()); }); + it("restores folded and unfolded sub-tasks across remounts", async () => { + routerMock.location.pathname = "/inbox/mine"; + const storageKey = "paperclip:inbox:collapsed-parents:company-1"; + localStorage.removeItem(storageKey); + + const parent = createIssue({ + id: "parent-issue", + identifier: "PAP-1001", + title: "Parent inbox task", + }); + const child = createIssue({ + id: "child-issue", + identifier: "PAP-1002", + parentId: parent.id, + title: "Nested inbox task", + }); + apiMocks.issuesList.mockResolvedValue([parent, child]); + + const mountInbox = async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, + }); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await vi.waitFor(() => { + expect(container.textContent).toContain(parent.title); + }); + return root; + }; + const parentToggle = () => { + const parentRow = Array.from(container.querySelectorAll("[data-inbox-item]")) + .find((row) => row.textContent?.includes(parent.title)); + return parentRow?.querySelector('button[data-slot="icon-button"]') ?? null; + }; + + let root = await mountInbox(); + try { + expect(container.textContent).toContain(child.title); + + await act(async () => { + parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await vi.waitFor(() => { + expect(container.textContent).not.toContain(child.title); + }); + expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([parent.id]); + + act(() => root.unmount()); + root = await mountInbox(); + expect(container.textContent).not.toContain(child.title); + + await act(async () => { + parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await vi.waitFor(() => { + expect(container.textContent).toContain(child.title); + }); + expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([]); + + act(() => root.unmount()); + root = await mountInbox(); + expect(container.textContent).toContain(child.title); + } finally { + localStorage.removeItem(storageKey); + act(() => root.unmount()); + } + }); + it("shows blocked toolbar controls on the Blocked tab", async () => { routerMock.location.pathname = "/inbox/blocked"; const queryClient = new QueryClient({ diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index e9abc46966..4bf93eb0d4 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -144,6 +144,7 @@ import { isInboxEntityDismissed, isMineInboxTab, loadCollapsedInboxGroupKeys, + loadCollapsedInboxParentIds, loadInboxFilterPreferences, loadInboxIssueColumns, loadInboxNesting, @@ -155,6 +156,7 @@ import { resolveInboxSelectionIndex, saveInboxFilterPreferences, saveCollapsedInboxGroupKeys, + saveCollapsedInboxParentIds, saveInboxIssueColumns, saveInboxNesting, saveInboxWorkItemGroupBy, @@ -801,6 +803,7 @@ export function Inbox() { previousSelectedCompanyIdRef.current = selectedCompanyId; setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId)); setCollapsedGroupKeys(loadCollapsedInboxGroupKeys(selectedCompanyId)); + setCollapsedInboxParents(loadCollapsedInboxParentIds(selectedCompanyId)); } }, [selectedCompanyId]); @@ -1372,7 +1375,9 @@ export function Inbox() { return next; }); }, []); - const [collapsedInboxParents, setCollapsedInboxParents] = useState>(new Set()); + const [collapsedInboxParents, setCollapsedInboxParents] = useState>( + () => loadCollapsedInboxParentIds(selectedCompanyId), + ); const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => loadCollapsedInboxGroupKeys(selectedCompanyId)); const toggleGroupCollapse = useCallback((groupKey: string) => { setCollapsedGroupKeys((prev) => { @@ -1508,18 +1513,20 @@ export function Inbox() { const next = new Set(prev); if (next.has(parentId)) next.delete(parentId); else next.add(parentId); + saveCollapsedInboxParentIds(selectedCompanyId, next); return next; }); - }, []); + }, [selectedCompanyId]); const setInboxParentCollapsed = useCallback((parentId: string, collapsed: boolean) => { setCollapsedInboxParents((prev) => { if (prev.has(parentId) === collapsed) return prev; const next = new Set(prev); if (collapsed) next.add(parentId); else next.delete(parentId); + saveCollapsedInboxParentIds(selectedCompanyId, next); return next; }); - }, []); + }, [selectedCompanyId]); // Build flat navigation list from visible rows so keyboard traversal respects collapsed groups. const flatNavItems = useMemo((): NavEntry[] => {