You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Land a heading link on its heading, even when the heading is folded or filtered #338
A heading link fails in two ways today. A click on a link to a folded or filtered heading does nothing. A cold-opened ?id= link, for example from a digest email, scrolls before the document syncs, so it can land at the top. After this change, both paths land on the heading. A hidden target unfolds for this session only, and folds saved on the device do not change.
Acceptance criteria
A click on a heading link to a folded target unfolds only the sections that hide it. The heading then scrolls to the top of the pad.
The same click works when a Filter hides the target.
A cold tab on /<pad>?id=<toc-id> of a long pad shows that heading at the top after sync. This holds on desktop and on a phone.
A cold tab on /<pad>/<term>?id=<toc-id> lands on the heading after the Filter applies, and the Filter does not fold it again.
After a click arrival on a pad address with no ?id=, a reload shows the target's ancestor section folded again.
After that click arrival, fold a different section by hand and reload: that manual fold is kept.
A toc-id that holds a quote or a backslash does not throw.
A link to a heading that no longer exists behaves as today: nothing moves, and nothing throws.
The change adds no new setTimeout.
Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone. The browser check below is required before the issue closes.
Category: bug
Current behavior:
Cold open: scrollDown in apps/webapp/src/components/TipTap/TipTap.tsx:73-85 runs from onCreate (:268) plus a 200 ms timer. It does not wait for sync. It uses block: 'nearest', and its selector [data-toc-id="${id}"] is not escaped.
Click: runInternalDocumentLink calls scrollToHeading(link.headingId) with the default block: 'nearest' (TipTap/hyperlinkPopovers/internalDocumentLinkActions.ts:30-32).
scrollToHeading returns silently when no heading matches (apps/webapp/src/utils/scrollToHeading.ts:22-25). A folded heading still matches, but it is display: none, so the scroll does nothing. It already escapes the id (escapeAttr, :12-17).
A folded body gets the heading-fold-hidden class, which is display: none !important (apps/webapp/src/styles/editor/_heading-fold.scss:12-14).
Filter hides sections through the same fold state. HeadingFilter gets a foldAdapter whose setTemporaryFolds sends a fold set meta with persist: false (TipTap.tsx:154-165).
Find already reveals a hidden hit for the session only: revealHit and foldedSectionsAt in TipTap/extensions/caret-find/caret-find-plugin.ts:101-183.
Desired behavior:
One arrival function: unfold the sections that hide the target for this session, then call scrollToHeading(id, { block: 'start' }).
The click path and the cold-open path both call it.
The cold-open path runs from a new hook. Gate it like useApplyFilters (apps/webapp/src/hooks/useApplyFilters.ts:19): router.isReady, the editor instance set, not loading, not providerSyncing. It runs once per ?id= value, like the handledRef guard in useCheckUrlAndOpenHeadingChat (hooks/useCheckUrlAndOpenHeadingChat.ts:48-50). Remove scrollDown and the onCreate: scrollDown line.
The click path has no editor argument today. Read the editor from the store (useStore.getState().settings.editor.instance).
Trap (fold storage): a fold set meta with persist: false turns on skipPersist in the fold plugin state (TipTap/extensions/heading-fold/heading-fold-plugin.ts:347-357). That flag stays on until the next set without persist: false. Find restores the old value when Find closes (closeFindTr, caret-find-plugin.ts:227). An arrival has no close step. So a plain persist: false set makes every later manual fold stop saving for the session. The two reload criteria above catch this. Follow the pattern of foldedIdsIncludingFind (caret-find-plugin.ts:186-190): the save must still see the arrival-opened ids as folded.
Where to start:
Move foldedSectionsAt next to the fold plugin (for example into extensions/heading-fold/helpers/), and import it from Find. Do not copy it.
scrollToHeading.ts, internalDocumentLinkActions.ts, TipTap.tsx (scrollDown), hooks/useEditorAndProvider.ts (mounts useCheckUrlAndOpenHeadingChat and useApplyFilters).
Cold Filter URLs: the arrival must run after the Filter applies. useApplyFilters runs editor.commands.applyFilter synchronously in an effect. useEditorAndProvider calls useCheckUrlAndOpenHeadingChat() (:25) before useApplyFilters() (:29). React runs effects of one component in call order. So call the new hook after useApplyFilters(), with the same gate.
Line numbers are hints as of 2026-09-28; the agent searches by symbol.
Rules that apply:
CONTEXT.md §Pad tools (Find unfolds a hit for the session only and never writes folds to storage).
apps/webapp/CLAUDE.md §Document Filters: never poll the DOM for readiness; gate on !loading and !providerSyncing.
apps/webapp/CLAUDE.md §Heading Fold Crinkle and §TOC And Heading Actions.
apps/webapp/src/components/TipTap/CLAUDE.md §Editor State And References: leaf useStore selectors; the canonical editor handle.
AGENTS.md §Test Policy: add a test only if the arrival order gets dense branching. A Cypress spec beside cypress/e2e/editor/heading/heading-fold-unfold.cy.js fits best. If you add one, prove it by sabotage. The same section says the mobile shell is user-agent gated.
Verify:
cd apps/webapp && bun run typecheck, then bun run lint at the root.
Start the local stack with make dev-local. Note the webapp port; it is not always 3000. From apps/webapp, run bunx cypress run --spec "cypress/e2e/editor/heading/*,cypress/e2e/editor/find/*,cypress/e2e/editor/filter/*" --env EDITOR_BASE_URL=http://localhost:<port>. It stays as green as before. The Filter apply Cypress test has never passed #311 notes one Filter spec that has never passed.
Browser, desktop, light and dark, on a pad address with no ?id=: fold a section that holds an H2. Elsewhere, press Mod-k and pick that H2 to insert a heading link. Click it: the section opens and the H2 sits at the top. Reload: the section is folded again.
Browser: after that click arrival, fold another section by hand, then reload. That fold is kept.
Browser: apply a Filter that hides the H2, then click the link. The H2 shows at the top.
Browser: open /<pad>?id=a%22b%5C and /<pad>?id=<deleted-toc-id>. Nothing moves, and the console shows no error.
Cold tab: open a fresh profile or private window on /<pad>?id=<toc-id> for a heading far down a long pad. Throttle the network to "Slow 4G". The heading lands at the top after the skeleton leaves.
Phone: repeat the cold tab with a mobile user agent (CDP Network.setUserAgentOverride). A narrow window renders the desktop shell and proves nothing. A maintainer walks it on iOS Safari and Android Chrome after the agent finishes.
Parent
#328. Related: #309, #150
What to build
A heading link fails in two ways today. A click on a link to a folded or filtered heading does nothing. A cold-opened
?id=link, for example from a digest email, scrolls before the document syncs, so it can land at the top. After this change, both paths land on the heading. A hidden target unfolds for this session only, and folds saved on the device do not change.Acceptance criteria
/<pad>?id=<toc-id>of a long pad shows that heading at the top after sync. This holds on desktop and on a phone./<pad>/<term>?id=<toc-id>lands on the heading after the Filter applies, and the Filter does not fold it again.?id=, a reload shows the target's ancestor section folded again.toc-idthat holds a quote or a backslash does not throw.setTimeout.Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone. The browser check below is required before the issue closes.
Category: bug
Current behavior:
scrollDowninapps/webapp/src/components/TipTap/TipTap.tsx:73-85runs fromonCreate(:268) plus a 200 ms timer. It does not wait for sync. It usesblock: 'nearest', and its selector[data-toc-id="${id}"]is not escaped.runInternalDocumentLinkcallsscrollToHeading(link.headingId)with the defaultblock: 'nearest'(TipTap/hyperlinkPopovers/internalDocumentLinkActions.ts:30-32).scrollToHeadingreturns silently when no heading matches (apps/webapp/src/utils/scrollToHeading.ts:22-25). A folded heading still matches, but it isdisplay: none, so the scroll does nothing. It already escapes the id (escapeAttr,:12-17).heading-fold-hiddenclass, which isdisplay: none !important(apps/webapp/src/styles/editor/_heading-fold.scss:12-14).HeadingFiltergets afoldAdapterwhosesetTemporaryFoldssends a foldsetmeta withpersist: false(TipTap.tsx:154-165).revealHitandfoldedSectionsAtinTipTap/extensions/caret-find/caret-find-plugin.ts:101-183.Desired behavior:
scrollToHeading(id, { block: 'start' }).useApplyFilters(apps/webapp/src/hooks/useApplyFilters.ts:19):router.isReady, the editor instance set, notloading, notproviderSyncing. It runs once per?id=value, like thehandledRefguard inuseCheckUrlAndOpenHeadingChat(hooks/useCheckUrlAndOpenHeadingChat.ts:48-50). RemovescrollDownand theonCreate: scrollDownline.useStore.getState().settings.editor.instance).setmeta withpersist: falseturns onskipPersistin the fold plugin state (TipTap/extensions/heading-fold/heading-fold-plugin.ts:347-357). That flag stays on until the nextsetwithoutpersist: false. Find restores the old value when Find closes (closeFindTr,caret-find-plugin.ts:227). An arrival has no close step. So a plainpersist: falseset makes every later manual fold stop saving for the session. The two reload criteria above catch this. Follow the pattern offoldedIdsIncludingFind(caret-find-plugin.ts:186-190): the save must still see the arrival-opened ids as folded.Where to start:
foldedSectionsAtnext to the fold plugin (for example intoextensions/heading-fold/helpers/), and import it from Find. Do not copy it.scrollToHeading.ts,internalDocumentLinkActions.ts,TipTap.tsx(scrollDown),hooks/useEditorAndProvider.ts(mountsuseCheckUrlAndOpenHeadingChatanduseApplyFilters).useApplyFiltersrunseditor.commands.applyFiltersynchronously in an effect.useEditorAndProvidercallsuseCheckUrlAndOpenHeadingChat()(:25) beforeuseApplyFilters()(:29). React runs effects of one component in call order. So call the new hook afteruseApplyFilters(), with the same gate.Rules that apply:
!loadingand!providerSyncing.useStoreselectors; the canonical editor handle.cypress/e2e/editor/heading/heading-fold-unfold.cy.jsfits best. If you add one, prove it by sabotage. The same section says the mobile shell is user-agent gated.Verify:
cd apps/webapp && bun run typecheck, thenbun run lintat the root.make dev-local. Note the webapp port; it is not always 3000. Fromapps/webapp, runbunx cypress run --spec "cypress/e2e/editor/heading/*,cypress/e2e/editor/find/*,cypress/e2e/editor/filter/*" --env EDITOR_BASE_URL=http://localhost:<port>. It stays as green as before. The Filter apply Cypress test has never passed #311 notes one Filter spec that has never passed.?id=: fold a section that holds an H2. Elsewhere, press Mod-k and pick that H2 to insert a heading link. Click it: the section opens and the H2 sits at the top. Reload: the section is folded again./<pad>?id=a%22b%5Cand/<pad>?id=<deleted-toc-id>. Nothing moves, and the console shows no error./<pad>?id=<toc-id>for a heading far down a long pad. Throttle the network to "Slow 4G". The heading lands at the top after the skeleton leaves.Network.setUserAgentOverride). A narrow window renders the desktop shell and proves nothing. A maintainer walks it on iOS Safari and Android Chrome after the agent finishes.Out of scope
useCheckUrlAndOpenHeadingChat— Replace the 800ms setTimeout in useCheckUrlAndOpenHeadingChat with a real readiness signal #150.