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:
Dotta 2026-08-11 07:09:54 -04:00 committed by GitHub
parent 66575fe519
commit 9cdaa5416e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 139 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@ -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[] => {