Repository navigation
Richer internal-link chips, and a kind icon beside the pad link #174
Copy link
Copy link
Open
Labels
EditorTiptap & ProsemirrorTiptap & ProsemirrorFeatureUIenhancementNew feature or requestNew feature or request
Description
Goal
Make same-document hyperlinks easier to read and more useful than a generic URL.
Two surfaces, one classifier (classifyInternalDocumentLink):
- Preview chip — show the real destination, not a count. A filter link that says “Filtered view · 1 term” must name that term.
- In the pad — a small leading icon on the linked text so a filter, heading, chat, history, or “this document” link is visible before anyone opens the popover. Same idea as GitHub’s leading mark on a special link, not a second toolbar.
Facts (2026-08-21)
- Five internal kinds already exist:
document,heading,chat,filter,history. Labels come fromdescribeInternalDocumentLink. Filter copy isFiltered view · N term(s)plusMatch any/Match all. The terms themselves never appear in the chip. - Desktop preview: imperative chip is the Go button. Mobile sheet and composer: React chip is display-only; Go is a separate row. Copy always yields the raw href.
- In the document the mark still renders as a plain
<a href>. No kind icon. No extra schema attr. - FilterBar term chips on the pad are a different species (
info/--info-ink). Do not merge them with this hyperlink chip. - Hyperlink
renderHTMLis['a', attrs, 0]. Icons must not be written into Yjs text. Do not addtoDOMon the storage hyperlink stub. - Do not stamp persistent attributes onto nodes inside
.ProseMirrorfrom JS (DOMObserver recreates media node-views). Widget decorations and CSS on the existing<a>are the safe paths. Live regions inside the editor stay forbidden. - Webapp icon catalog is Lucide via
Icons. Do not put emoji,Fa*, orSi*into pad text or the chip.
What to build
Preview chip — name the destination
- Filter: list the terms in the chip (and keep Match any/all). One term → show it. Several terms → show the first few and a
+N; the full set stays in the popover. - Heading / chat: keep the quoted heading title when the pad editor can resolve it (already does).
- History: keep
Version N/History. - Desktop, mobile sheet, and composer all read the same descriptor. Do not fork copy per surface.
- Filter term pills inside the preview may reuse FilterBar’s
info/--info-inklanguage so they look like the same filters. The icon tile on the chip stays the internal-link primary wash.
Pad text — leading kind icon
- When classify returns an internal kind, show a leading Lucide icon beside that hyperlink in the pad (filter, heading, chat, history, document). External and other-document links stay unchanged.
- The icon is decoration. The existing click path stays: editable tap opens the preview popover; the chip/Go still runs the destination. Do not add a second clickable control inside the link (nested button).
- Same treatment in the chat composer preview of an internal href is welcome if cheap; pad is the required surface.
- Print / copy HTML must not serialize a fake character into the document. The stored mark stays href + text.
Interaction
Internal links may do more in the preview than an external unfurl (named destination, Go in place, terms). They must not do more in the document click than today’s preview/open split. Keyboard and screen readers still meet one link, not “icon + link”.
Out of scope
- Changing classify precedence or treating another docs.plus slug as internal.
- Special-URL / favicon icons on external
https://github.com/…links (later, separate). - Writing icons or emoji into the hyperlink mark or the visible text.
- Merging this chip with FilterBar, TOC, or heading-action chips.
- Changing chat Bookmark tables or My Documents Favorites (Favorites in My Documents, then a later list UX pass #173).
Acceptance criteria
- A one-term filter link’s preview names that term. A many-term filter names terms or
+N, not only a count. - Desktop popover, mobile sheet, and composer preview agree on that copy.
- Pad hyperlinks for all five internal kinds show a leading Lucide icon; external links do not.
- Click, copy, and Go behaviour match today (preview when editing; in-place run from the chip/Go; copy is the raw href).
- Stored Yjs / HTML round-trip has no extra character or mark attr for the icon.
- Light + dark, desktop + mobile, print: walked. Media embeds on the same page do not reload when the popover opens.
Blocked by
None. Visual density of many-term chips (how many names before +N) is HITL on this issue if the first walk feels crowded.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
EditorTiptap & ProsemirrorTiptap & ProsemirrorFeatureUIenhancementNew feature or requestNew feature or request
Activity
HMarzban commentedon Sep 28, 2026
Build notes for the kind icon (checked against
main, 2026-09-28)These notes cover the pad-text half of this issue: the leading kind icon. Each code claim below was read in the repo. Runtime claims that need a browser say so.
Scope. Build the kind icon only, with no background wash. This issue asks for the icon, not a wash. A wash needs a maintainer ruling on this issue first. If a wash is ruled in, draw it with CSS on the anchor, not with inline decorations. Inline decorations draw one pill per text node on a link with mixed marks.
Which links to scan
hyperlinkandlink. Stored pads, Markdown import and connected-app writes still hold the oldlinkmark. The pad keeps it registered but inert (apps/webapp/src/components/TipTap/TipTap.tsx:113-121). Store links from Markdown import and connected-app writes on the hyperlink mark #335 moves new Markdown links ontohyperlink; old pads keeplink.filter, and one built with?chatroom=classifies aschat. Build stored heading and message links from the pad path, not the address bar #331 fixes new links. Stored links keep their kind, and the preview already shows that kind today.wwwlink does not classify, because the origin must match exactly (TipTap/hyperlinkPopovers/internalDocumentLink.ts:29).classifyInternalDocumentLink, not in@docs.plus/extension-hyperlink. The classifier reads the pad route.Widget placement
Decoration.widget(from, icon, { side: 1, key: kind + href })and nomarksoption.prosemirror-view1.42.3 through@tiptap/pm. In itsupdateChildren(prosemirror-view/src/viewdesc.ts:787-790), a widget withside >= 0and nomarkssyncs to the live marks of the next child. So the icon joins the live<a>.marks: [mark]keeps a copy of the mark. After an href edit on a mapped set, it can draw a second, stale<a>. Readers would then meet two links. Confirm the single<a>in a browser.<a>, a tap on the icon opens the same preview. The click path usestarget.closest('a')(extensions/extension-hyperlink/src/interactions/clickHandler.ts:18).<a>from JS. A mark has acontentDOM, so a toggle dirties its range.apps/webapp/src/utils/lucideSvgString.ts, so no user text goes intoinnerHTML. Mark itaria-hidden, so readers meet one link.Rebuild rule and cost
y-syncmeta. Otherwise map the set.@tiptap/y-tiptap3.0.9 applies each remote change as onereplaceover the whole document (_typeChangedin@tiptap/y-tiptap/dist/y-tiptap.js:794-798). Mapping then drops every decoration. So "rebuild ony-sync" is a full walk on every remote keystroke.key. Cache the classify result by href and pad slug. Rescan only betweenoldDoc.content.findDiffStart(newDoc.content)andfindDiffEnd. y-tiptap reuses unchanged nodes, so that diff is cheap. This path is code-read, not run.Checks before merge
IOSCaretFixruns (TipTap.tsx:234), and Android Gboard composition. A widget iscontentEditable=false.apps/webapp/src/styles/_print.scss..cursor/docs/design-system.mdfor the in-text kind icon. The doc has no chip-in-text row today.bun run --filter @docs.plus/webapp typecheck. Walk the icon in light and dark, on desktop and phone.Rules that apply
apps/webapp/src/components/TipTap/CLAUDE.md§Editor Performance: map the decoration set; do not rebuild on every keystroke.AGENTS.md§UI And Theme and thedesign-systemskill for the icon size and color.AGENTS.md§Test Policy: no test is required. If the rebuild rule grows dense branching, a unit test may pin it. Prove it by sabotage.