fix(ui): remember folded inbox subtasks (#11069)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The inbox helps operators scan parent tasks and their sub-tasks > - Operators can fold a parent task to hide its sub-tasks > - The inbox previously forgot that fold state after a page refresh > - This pull request stores the fold state for each company and restores it when the inbox loads > - The benefit is that the inbox keeps the operator's chosen task layout across page refreshes ## Linked Issues or Issue Description **What happened?** The inbox reset every folded parent task after a page refresh. This made all nested sub-tasks visible again. **Expected behavior** The inbox must keep each folded or unfolded parent state after a page refresh. The state must remain separate for each company. **Steps to reproduce** 1. Open the inbox with parent and child tasks. 2. Fold one parent task. 3. Refresh the page. 4. Observe that the child task is visible again without this fix. **Paperclip version or commit** Current `master` before this pull request. **Deployment mode** Local dev and built-from-source deployments. ## What Changed - Added company-scoped local storage helpers for collapsed inbox parent IDs. - Restored the stored parent fold state when the inbox mounts or the selected company changes. - Saved both direct toggle changes and explicit collapse changes. - Added helper tests and an inbox remount regression test for both folded and unfolded states. ## Verification - `pnpm exec vitest run ui/src/lib/inbox.test.ts ui/src/pages/Inbox.test.tsx` — 77 tests passed. - `pnpm check:token-gates` — all gates passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm --filter @paperclipai/ui build` — passed. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — 3,521 tests passed and four skipped. One unrelated server test on the current base fails because it reads `heartbeat.scheduling_suppressed` instead of `issue_commented`; the same test fails alone and this pull request changes only inbox UI files. ## Risks - Low risk. The state is local to the browser and scoped by company ID. - Old parent IDs can remain in local storage after tasks are deleted, but they do not affect visible tasks. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.6-sol`, with reasoning, tool use, and code execution. The runtime does not expose its context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
66575fe519
commit
9cdaa5416e
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<string>,
|
||||
) {
|
||||
const storageKey = getInboxCollapsedParentsStorageKey(companyId);
|
||||
if (!storageKey) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify([...parentIds]));
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDismissedInboxAlerts(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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<HTMLButtonElement>('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({
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>(new Set());
|
||||
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(
|
||||
() => loadCollapsedInboxParentIds(selectedCompanyId),
|
||||
);
|
||||
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => 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[] => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue