Skip to content

Richer internal-link chips, and a kind icon beside the pad link #174

Description

@HMarzban

Goal

Make same-document hyperlinks easier to read and more useful than a generic URL.

Two surfaces, one classifier (classifyInternalDocumentLink):

  1. Preview chip — show the real destination, not a count. A filter link that says “Filtered view · 1 term” must name that term.
  2. 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 from describeInternalDocumentLink. Filter copy is Filtered view · N term(s) plus Match 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 renderHTML is ['a', attrs, 0]. Icons must not be written into Yjs text. Do not add toDOM on the storage hyperlink stub.
  • Do not stamp persistent attributes onto nodes inside .ProseMirror from 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*, or Si* 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-ink language 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.

Activity

  1. HMarzban commented on Sep 28, 2026

    @HMarzban
    CollaboratorAuthor

    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

    • Scan text with an internal-classifying href on both hyperlink and link. Stored pads, Markdown import and connected-app writes still hold the old link mark. 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 onto hyperlink; old pads keep link.
    • Some stored links show the wrong kind. A heading link built in a Filter view classifies as filter, and one built with ?chatroom= classifies as chat. 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.
    • A www link does not classify, because the origin must match exactly (TipTap/hyperlinkPopovers/internalDocumentLink.ts:29).
    • Put the plugin in the webapp, next to classifyInternalDocumentLink, not in @docs.plus/extension-hyperlink. The classifier reads the pad route.

    Widget placement

    • Draw the icon with Decoration.widget(from, icon, { side: 1, key: kind + href }) and no marks option.
    • Reason: the webapp resolves prosemirror-view 1.42.3 through @tiptap/pm. In its updateChildren (prosemirror-view/src/viewdesc.ts:787-790), a widget with side >= 0 and no marks syncs to the live marks of the next child. So the icon joins the live <a>.
    • A widget that carries 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.
    • With the icon inside the <a>, a tap on the icon opens the same preview. The click path uses target.closest('a') (extensions/extension-hyperlink/src/interactions/clickHandler.ts:18).
    • Never toggle attributes on the <a> from JS. A mark has a contentDOM, so a toggle dirties its range.
    • A link with mixed marks has several text nodes. Give only the first one the icon.
    • Build the icon from apps/webapp/src/utils/lucideSvgString.ts, so no user text goes into innerHTML. Mark it aria-hidden, so readers meet one link.

    Rebuild rule and cost

    • Rebuild only when a transaction touches link marks or carries y-sync meta. Otherwise map the set.
    • Cost trap: @tiptap/y-tiptap 3.0.9 applies each remote change as one replace over the whole document (_typeChanged in @tiptap/y-tiptap/dist/y-tiptap.js:794-798). Mapping then drops every decoration. So "rebuild on y-sync" is a full walk on every remote keystroke.
    • A cheaper path has three parts. Give each widget a stable key. Cache the classify result by href and pad slug. Rescan only between oldDoc.content.findDiffStart(newDoc.content) and findDiffEnd. y-tiptap reuses unchanged nodes, so that diff is cheap. This path is code-read, not run.
    • Before merge, measure the rebuild on a large pad while a second client types. Record the per-transaction time in a comment on this issue.

    Checks before merge

    • Walk typing at a link start in Chrome, Safari and Firefox on desktop. Also walk it on iOS Safari, where IOSCaretFix runs (TipTap.tsx:234), and Android Gboard composition. A widget is contentEditable=false.
    • Stale clients show plain links. No schema change, so there is no two-release rollout.
    • Print: the icon prints in PDF export and shows in History. Decide on print when you build it. If it must not print, hide it with one rule in apps/webapp/src/styles/_print.scss.
    • Add one row to .cursor/docs/design-system.md for the in-text kind icon. The doc has no chip-in-text row today.
    • Run 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 the design-system skill 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions