Skip to content

feat(chat): citations & sources visual + interaction system#769

Open
blove wants to merge 16 commits into
mainfrom
blove/suspicious-ellis-644600
Open

feat(chat): citations & sources visual + interaction system#769
blove wants to merge 16 commits into
mainfrom
blove/suspicious-ellis-644600

Conversation

@blove

@blove blove commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Gives @threadplane/chat a prod-ready, cohesive, extensible visual + interaction system for citations and sources — previously the plumbing existed (semantic BEM markup, resolver, two-tier provider/markdown citations) but had zero styling. Now:

  • Inline markers render as numbered pills (baseline-aligned, accent-tint on hover/focus, tap-friendly, group cleanly) across prose and table cells — three graceful states: resolved+URL (link), resolved no-URL (role=button, preview-only), and unresolved (muted dashed, non-interactive).
  • Hover / tap preview card — an on-demand provenance card portaled via the existing chatConnectedOverlay primitive: favicon/monogram, domain, source-type, title, snippet, an explicit Open source action, and a freshness slot. "Lightweight by default, deep on demand."
  • Collapsible Sources panel — a Sources · N header (stacked-favicon preview + chevron) over detail cards sharing the preview card's visual language; expanded by default, scales from 2 to 20+ sources.

Everything is driven by new --tplane-chat-citation-* tokens (light/dark for free), browser-safe (no third-party favicon fetches — monogram fallback; consumers can supply iconUrl), accessible (keyboard, focus, WCAG-AA contrast, reduced-motion), and pointer-adaptive (hover on desktop, tap on touch — no surprise navigation).

Design spec: docs/superpowers/specs/2026-07-06-chat-citations-sources-design.md
Plan: docs/superpowers/plans/2026-07-06-chat-citations-sources.md

Public API

  • Citation gains optional sourceType?, iconUrl?, publishedAt? (non-breaking).
  • New export ChatCitationPreviewComponent; pure display helpers (deriveDomain, deriveMonogram, monogramColor, citationTypeLabel, formatPublished, …) exported for custom chatCitationCard templates.
  • api-docs regenerated.

Test Plan

  • Unit suite green — npx nx test chat (951 tests): marker states, preview-card content, detail card, panel collapse, display helpers, tokens, and a tap-gesture regression test (preview must not self-close on focus→click).
  • Lint 0 errors (npx nx lint chat), npx nx build chat succeeds.
  • Example compiles lib source with strict:false and loads with a clean console (examples-chat).
  • Live-LLM interaction check (needs OpenAI key + Python backend): serve examples-chat, run welcome suggestion feat: glassy gradient website redesign + docs refresh #3 ("search + cite inline [^id]"), then verify in a real browser: pills render (incl. grouped/in-table), hover/focus opens the preview and moving into it keeps it open, click opens the source, Sources panel collapses/expands, dark mode contrast, and mobile tap previews without navigating.

Reviewer notes

A final adversarial review caught and this PR fixes: tap/Enter self-closing the preview (focus→click race), a WCAG-AA contrast miss on muted text, non-reactive pointer detection (SSR), a dead --no-url class, and cross-file duplication (extracted to shared helpers).

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
threadplane Ready Ready Preview, Comment Jul 7, 2026 5:42am

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 3m 28s —— View job


Code Review

  • Gather context
  • Read changed source files
  • Review for correctness bugs, type safety, Angular/RxJS pitfalls
  • Review public API surface
  • Check test coverage
  • Post inline findings

Overall the PR is well-structured. The display helpers are pure and well-tested, the pointer-adaptive interaction model is sound, and the tap-gesture regression test (justOpenedByFocus guard) is a good explicit contract. Three issues worth addressing before merge:

Bugs

  1. Event listener leak (markdown-citation-reference.component.ts:164) — pane.addEventListener for mouseenter/mouseleave is called in onAttached() but removeEventListener is never called — neither in close() nor in the DestroyRef.onDestroy callback. If the overlay directive reuses the same portal element, listeners accumulate; even if the element is freshly created each time, the component-closing closures delay GC. See inline comment.

  2. onBlur uses document.activeElement instead of event.relatedTarget (markdown-citation-reference.component.ts:132) — document.activeElement can transiently be document.body during a blur→focus transition in several browsers, causing the pane-contains check to fail and spuriously closing the preview when the user tabs into the preview card. FocusEvent.relatedTarget is set synchronously and is more reliable. See inline comment.

Accessibility

  1. aria-haspopup="dialog" mismatch (markdown-citation-reference.component.ts:45) — the marker declares it opens a dialog, but the preview card has role="group". Screen readers that honour aria-haspopup="dialog" will misrepresent the interaction. aria-expanded alone is sufficient for this disclosure pattern; drop aria-haspopup. See inline comment.

Minor (non-blocking)

  1. ChatCitationsCardComponent imports the full CHAT_CITATIONS_PANEL_STYLES (chat-citations-card.component.ts:8) — the constant bundles both panel-header selectors (.chat-citations__*) and card selectors. The card component only needs the latter; splitting the constant removes unused rules from each card's encapsulated stylesheet. See inline comment.

Public API surface looks good — the three new Citation fields (sourceType, iconUrl, publishedAt) are additive and non-breaking, the new exports (ChatCitationPreviewComponent, display helpers) are appropriate to expose. The rel="noopener noreferrer" on every target="_blank" anchor is correct.

}
}

protected onAttached(pane: HTMLElement): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: event listeners are added here but never removed.

close() sets this.pane = null without first calling removeEventListener. If the overlay directive reuses the same portal element across open/close cycles, each cycle adds two more listeners to the same node. Even when the element is freshly created each time, the closures hold a reference back to the component and can delay GC.

The fix is to remove the listeners before clearing the reference:

Suggested change
protected onAttached(pane: HTMLElement): void {
protected onAttached(pane: HTMLElement): void {
this.pane = pane;
pane.addEventListener('mouseenter', this.onPaneEnter);
pane.addEventListener('mouseleave', this.onPaneLeave);
}

…and in close():

protected close(): void {
  this.clearTimers();
  this.justOpenedByFocus = false;
  this.open.set(false);
  if (this.pane) {
    this.pane.removeEventListener('mouseenter', this.onPaneEnter);
    this.pane.removeEventListener('mouseleave', this.onPaneLeave);
    this.pane = null;
  }
}

Also extend the DestroyRef.onDestroy callback to do the same cleanup so a component that is destroyed while the preview is open doesn't orphan listeners.

Fix this →

this.justOpenedByFocus = true;
}

protected onBlur(): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reliability: prefer event.relatedTarget over document.activeElement in onBlur.

During a blur → focus transition, document.activeElement can transiently be document.body (or even null) in several browsers before the new element fully receives focus. This means this.pane.contains(active) can return false even when focus is genuinely moving into the pane, causing a spurious close.

FocusEvent.relatedTarget is the element that just received focus — it is set synchronously and is more reliable for this check:

Suggested change
protected onBlur(): void {
protected onBlur(e: FocusEvent): void {
this.justOpenedByFocus = false;
const relatedTarget = e.relatedTarget as Element | null;
if (this.pane && relatedTarget && this.pane.contains(relatedTarget)) return;
this.close();
}

Fix this →

[attr.rel]="r.citation.url ? 'noopener noreferrer' : null"
[attr.role]="r.citation.url ? null : 'button'"
[attr.tabindex]="r.citation.url ? null : '0'"
aria-haspopup="dialog"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARIA semantic mismatch: aria-haspopup="dialog" but the popup has role="group".

aria-haspopup="dialog" tells assistive technology that activating this element opens a dialog, which implies focus trapping and an explicit close mechanism. The preview card uses role="group" (or no explicit role), which is not a dialog. Screen readers that honour aria-haspopup="dialog" will misrepresent the interaction.

For a tooltip-like hover/tap preview with no focus trap, two correct options:

  • Drop aria-haspopup entirely and rely on aria-expanded to convey state — aria-expanded alone is sufficient for a disclosure pattern.
  • Keep the popup as a role="tooltip" (on the preview element) and switch the marker attribute to aria-describedby pointing at the tooltip's id.

Fix this →

import type { Citation } from '../../agent/citation';
import { deriveDomain, deriveMonogram, monogramColor, citationTypeLabel } from '../../agent/citation-display';
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
import { CHAT_CITATIONS_PANEL_STYLES } from '../../styles/chat-citations.styles';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style bloat: card component imports the full panel stylesheet.

CHAT_CITATIONS_PANEL_STYLES contains two unrelated rule-sets — the panel header/list selectors (.chat-citations__*) and the card selectors (.chat-citations-card*). ChatCitationsCardComponent only needs the card rules, but it injects the entire string into its shadow DOM. Since Angular component styles are scoped by the view encapsulation emulation prefix, none of the panel selectors will match anything in the card's template, but they add unnecessary bytes to every card instance's encapsulated stylesheet.

Consider splitting the export into CHAT_CITATIONS_PANEL_STYLES (header + list) and CHAT_CITATIONS_CARD_STYLES (card rules only), so each component imports only what it uses.

Fix this →

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 3m 56s —— View job


Code Review

  • Read CLAUDE.md and gather context
  • Read changed source files
  • Review for correctness bugs, type safety, Angular/RxJS pitfalls
  • Review public API surface and test coverage
  • Post inline findings

The PR is well-structured overall — display helpers are pure and well-tested, the pointer-adaptive model is sound, and the tap-gesture regression test is a good explicit contract. Five issues need addressing before merge; three of them are the same bugs flagged in the prior automated review and remain unaddressed in the current HEAD.

Bugs (blocking)

  1. Event listener leak (markdown-citation-reference.component.ts:175) — close() sets this.pane = null without calling removeEventListener. Each open/close cycle adds two more mouseenter/mouseleave listeners that are never removed. The DestroyRef.onDestroy callback only calls clearTimers(), so listeners are also orphaned if the component is destroyed while the overlay is open. See inline comment.

  2. onBlur spurious close (markdown-citation-reference.component.ts:137) — document.activeElement can transiently be document.body during a blur→focus transition, causing the pane-contains check to fail and closing the preview when focus moves into the card. FocusEvent.relatedTarget is synchronously reliable. Note: the template binding also needs to change from (blur)="onBlur()" to (blur)="onBlur($event)" for this fix to work. See inline comment.

Accessibility (blocking)

  1. aria-haspopup="dialog" mismatch (markdown-citation-reference.component.ts:45) — the popup has role="group", not role="dialog". Screen readers that honour aria-haspopup="dialog" will misrepresent the interaction (implying focus-trapped dialog). aria-expanded alone is sufficient for this disclosure pattern. See inline comment.

Minor (non-blocking)

  1. Style bloat (chat-citations-card.component.ts:8) — the card component imports CHAT_CITATIONS_PANEL_STYLES, which bundles both panel-header rules and card rules. The card only needs the card rules; the panel selectors add dead bytes to every card's encapsulated stylesheet. See inline comment.

  2. cursor: pointer on non-interactive <div> card (chat-citations.styles.ts:179) — the base .chat-citations-card rule applies cursor: pointer to both the <a> (url) and <div> (no url) variants. The <div> has no interaction, so the pointer cursor misleads users. Scoping it to a.chat-citations-card fixes it. See inline comment.

Public API surface looks good — the three new Citation fields (sourceType, iconUrl, publishedAt) are additive and non-breaking. New exports (ChatCitationPreviewComponent, display helpers) are appropriate. All target="_blank" anchors carry rel="noopener noreferrer".

@blove blove enabled auto-merge (squash) July 7, 2026 05:38
this.justOpenedByFocus = false;
this.open.set(false);
this.pane = null; // directive removes the pane element on detach
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (still open from prior review): event listener leak.

close() nulls this.pane without first calling removeEventListener, so the mouseenter/mouseleave handlers accumulate on the pane element across open/close cycles. The DestroyRef.onDestroy callback at construction time also only calls clearTimers() — if the component is destroyed while the overlay is open, the listeners are orphaned.

Suggested change
}
protected close(): void {
this.clearTimers();
this.justOpenedByFocus = false;
this.open.set(false);
if (this.pane) {
this.pane.removeEventListener('mouseenter', this.onPaneEnter);
this.pane.removeEventListener('mouseleave', this.onPaneLeave);
this.pane = null;
}
}

And update the DestroyRef.onDestroy callback in the constructor:

inject(DestroyRef).onDestroy(() => {
  this.clearTimers();
  if (this.pane) {
    this.pane.removeEventListener('mouseenter', this.onPaneEnter);
    this.pane.removeEventListener('mouseleave', this.onPaneLeave);
  }
});

Fix this →

const active = this.document.activeElement;
if (this.pane && active && this.pane.contains(active)) return; // focus moved into card
this.close();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (still open from prior review): document.activeElement is unreliable in onBlur.

During a blur→focus transition, document.activeElement can transiently be document.body before the next element receives focus, causing the pane-contains check to return false and spuriously closing the preview when the user tabs into it.

FocusEvent.relatedTarget is set synchronously to the element receiving focus and is reliable. The template binding also needs to pass $event:

Template fix (line 51): change (blur)="onBlur()"(blur)="onBlur($event)"

Suggested change
}
protected onBlur(e: FocusEvent): void {
this.justOpenedByFocus = false;
const relatedTarget = e.relatedTarget as Element | null;
if (this.pane && relatedTarget && this.pane.contains(relatedTarget)) return;
this.close();
}

Fix this →

[attr.rel]="r.citation.url ? 'noopener noreferrer' : null"
[attr.role]="r.citation.url ? null : 'button'"
[attr.tabindex]="r.citation.url ? null : '0'"
aria-haspopup="dialog"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessibility (still open from prior review): aria-haspopup="dialog" doesn't match the popup.

The preview card uses role="group", not role="dialog". Screen readers that honour aria-haspopup="dialog" expect focus to be trapped inside a dialog with an explicit close action; a hover/focus preview card is not that. aria-expanded alone is sufficient for this disclosure pattern.

Suggested change
aria-haspopup="dialog"
[attr.aria-expanded]="open()"

Fix this →

import type { Citation } from '../../agent/citation';
import { deriveDomain, deriveMonogram, monogramColor, citationTypeLabel } from '../../agent/citation-display';
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
import { CHAT_CITATIONS_PANEL_STYLES } from '../../styles/chat-citations.styles';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (still open from prior review): card component imports the full panel stylesheet.

CHAT_CITATIONS_PANEL_STYLES contains both the panel header/list selectors (.chat-citations__*) and the card selectors (.chat-citations-card*). The card only needs the latter, but injects the full string. Splitting the export into CHAT_CITATIONS_PANEL_STYLES and CHAT_CITATIONS_CARD_STYLES removes unused rules from every card's encapsulated stylesheet.

Fix this →

border-radius: var(--tplane-chat-radius-card);
text-decoration: none; color: inherit;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: cursor: pointer applies to the non-interactive no-URL <div> card.

.chat-citations-card { cursor: pointer } is shared by both the <a> (url present) and the <div> (no url) variants rendered in ChatCitationsCardComponent. The <div> has no click handler and no interactive role, so pointer misleads users.

Suggested change
transition: border-color 120ms ease, background 120ms ease;
.chat-citations-card {
display: flex; gap: 10px;
padding: 10px 11px;
background: var(--tplane-chat-surface);
border: 1px solid var(--tplane-chat-separator);
border-radius: var(--tplane-chat-radius-card);
text-decoration: none; color: inherit;
transition: border-color 120ms ease, background 120ms ease;
}
a.chat-citations-card { cursor: pointer; }

Fix this →

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant