From eb1f6fb0a7e32777c6382f1788be41bb7c90ed8d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 7 Jul 2026 22:19:33 -0700 Subject: [PATCH 1/6] docs(spec): add citation source type visual language design --- ...tion-source-type-visual-language-design.md | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-citation-source-type-visual-language-design.md diff --git a/docs/superpowers/specs/2026-07-07-citation-source-type-visual-language-design.md b/docs/superpowers/specs/2026-07-07-citation-source-type-visual-language-design.md new file mode 100644 index 000000000..4d3eb63e2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-citation-source-type-visual-language-design.md @@ -0,0 +1,306 @@ +# Citation Source-Type Visual Language — Design + +**Date:** 2026-07-07 +**Library:** `@threadplane/chat` (`libs/chat`) plus the ag-ui bridge +**Status:** Design approved, ready for implementation plan + +## Problem + +The citations UI already renders inline numbered markers, a hover/tap provenance +preview card, and a collapsible Sources panel. `Citation.sourceType` is already +modeled as an extensible string, but the UI only renders it as a plain text label. +That makes web, file, app, memory, and custom provenance look interchangeable. + +We need a small visual language for source types that improves recognition without +making citation markers noisy or turning the panel into a taxonomy UI. + +## Goals + +- Give canonical source types a distinct glyph and subtle tint across the preview + card and Sources panel. +- Keep source-type logic centralized in pure display helpers so Angular components + do not duplicate branching. +- Preserve the current privacy/CSP rule: no auto-fetched favicons or external icon + assets. +- Keep `sourceType` extensible: unknown/custom strings render gracefully. +- Fix the ag-ui STATE bridge so `sourceType`, `iconUrl`, and `publishedAt` survive + normalization. +- Cover the behavior in unit tests and the chat/ag-ui citation e2e twins. + +## Non-Goals + +- No custom source-type icon registry in this pass. +- No long hardcoded taxonomy beyond a small canonical set. +- No type glyphs inside inline citation markers for v1. +- No new network calls, CDN icons, icon font, or asset pipeline. +- No click-to-scroll marker-to-panel synchronization. + +## Approved Visual Direction + +The chosen direction is **subtle type badges**: + +- Inline citation markers remain number-only pills. +- Preview and panel rows render a small type icon and a compact text badge. +- Type color is present but restrained: enough to distinguish file/app/memory, not + enough to compete with message content. +- The Sources header favicon stack may use type icons when that is the selected + source visual. + +This keeps prose readable while making provenance type visible where users inspect +source details. + +## Canonical Source Types + +Support these canonical visual types: + +| Type | Label | Icon intent | Visual role | +| --- | --- | --- | --- | +| `web` | `Web` | globe/link/document-web glyph | Browser or URL-backed source. | +| `file` | `File` | document glyph | Local/uploaded/project file. | +| `app` | `App` | app/window glyph | External app or integration result. | +| `memory` | `Memory` | memory/spark/brain-like abstract glyph | Persisted assistant or workspace memory. | +| `generic` | capitalized custom label, or no label for unknown | generic source/card glyph | Unknown or custom source type. | + +`sourceType` remains a free-form string. The helper normalizes case and whitespace +for matching, but does not reject custom values. A literal `sourceType: 'generic'` +is treated as a generic visual type with label `Generic`; missing type with no URL +is still `unknown` and has no label. Because `generic` is a recognized visual type, +literal `sourceType: 'generic'` returns `isKnown: true`; custom strings that fall +back to the generic icon return `isKnown: false`. + +## Display Helper Contract + +Extend `libs/chat/src/lib/agent/citation-display.ts` with a pure helper, likely: + +```ts +export type CitationTypeIcon = 'web' | 'file' | 'app' | 'memory' | 'generic'; + +export interface CitationTypeMeta { + type: string; + label: string | null; + icon: CitationTypeIcon; + tone: CitationTypeIcon; + isKnown: boolean; +} + +export function citationTypeMeta(citation: Citation): CitationTypeMeta; +``` + +Expected behavior: + +- `sourceType: 'file'` returns label `File`, icon `file`, tone `file`. +- Missing `sourceType` with a URL returns `Web`, icon `web`, tone `web`. +- Missing `sourceType` without a URL returns `label: null`, icon `generic`, + tone `generic`, `isKnown: false`. +- Custom values such as `company-knowledge` render a readable label such as + `Company knowledge`, generic icon, generic tone, `isKnown: false`. + +Existing helpers such as `deriveSourceType()` and `citationTypeLabel()` can remain +for compatibility, but components should use the richer meta helper for new visual +behavior. + +## Source Visual Precedence + +Use one helper-backed rule wherever the source visual slot appears: + +1. If `Citation.iconUrl` is present, render the provider-supplied image. +2. Else if `citationTypeMeta(c).icon` is a known non-web type, render that type icon. +3. Else if the source is custom/unknown, render the generic type icon. +4. Else render the existing monogram fallback for web sources. + +Rationale: favicons/logos supplied by the provider are the most specific visual. +Non-web types benefit from a stable semantic icon. Web sources without a favicon +still read better as domain monograms, preserving the current behavior. + +## Icons + +Icons are inline SVG, owned by the component templates or a small shared template +helper. They must be browser-safe and CSP-clean: + +- No external assets. +- No icon font. +- No runtime SVG injection from user data. +- Icons use `currentColor` so component styles can drive the tone. +- Icons are `aria-hidden="true"` because the adjacent label carries meaning. + +The implementation should avoid adding a dependency for five simple glyphs. + +## Color Tokens + +Add token defaults under the existing `--tplane-chat-citation-*` namespace. The +exact names can be refined in implementation, but the shape should support: + +- type foreground +- type soft background +- type border + +For example: + +```css +--tplane-chat-citation-type-web-fg +--tplane-chat-citation-type-web-bg +--tplane-chat-citation-type-web-border +--tplane-chat-citation-type-file-fg +--tplane-chat-citation-type-file-bg +--tplane-chat-citation-type-file-border +--tplane-chat-citation-type-app-fg +--tplane-chat-citation-type-app-bg +--tplane-chat-citation-type-app-border +--tplane-chat-citation-type-memory-fg +--tplane-chat-citation-type-memory-bg +--tplane-chat-citation-type-memory-border +--tplane-chat-citation-type-generic-fg +--tplane-chat-citation-type-generic-bg +--tplane-chat-citation-type-generic-border +``` + +All foreground values must meet WCAG AA against their corresponding soft +background and the chat surface in light and dark themes. The palette should stay +understated and avoid a one-note hue family: + +- web: existing citation accent blue +- file: restrained green +- app: restrained amber +- memory: restrained violet +- generic: neutral slate + +## Component Behavior + +### Inline Marker + +`MarkdownCitationReferenceComponent` stays number-only. It may carry type data in +the preview it opens, but the pill itself does not show a glyph or per-type color. +This prevents clustered citations from becoming visually noisy inside prose. + +### Preview Card + +`ChatCitationPreviewComponent` renders: + +- provider icon, type icon, generic icon, or web monogram in the leading visual slot +- domain when present +- compact type badge when `citationTypeMeta().label` is non-null +- existing title, snippet, open-source action, and freshness behavior + +The badge includes the text label. The icon remains decorative to assist visual +recognition. + +### Sources Panel + +`ChatCitationsCardComponent` renders the same visual slot and type badge as the +preview card. `ChatCitationsComponent` updates its header stack helper so the first +three sources preview provider icons, type icons, generic icons, or monograms using +the same precedence. + +Custom card templates remain unaffected: consumers who provide +`chatCitationCard` keep full control of their rendering. + +## ag-ui Bridge + +`libs/ag-ui/src/lib/bridge-citations-state.ts` currently normalizes only +`id/index/title/url/snippet/extra`. Extend `normalizeCitation()` to also carry: + +- `sourceType` +- `iconUrl` +- `publishedAt` + +`publishedAt` should accept the same broad value shapes as `Citation`: string, +number, or `Date` when the value already has that type. Invalid object values should +be omitted rather than coerced. + +## Accessibility + +- Type meaning is conveyed by text and icon, not color alone. +- Type icons are `aria-hidden="true"`. +- Provider-supplied images remain `alt=""` because the source label/title/domain + carries the accessible name. +- Marker labels remain focused on source identity and navigation behavior. +- Contrast must pass WCAG AA in light and dark themes. +- Existing reduced-motion handling remains sufficient; new hover/focus transitions + should use the same short transition pattern and respect the global reduced-motion + rule. + +## Public API + +Export the new display helper types/functions from `libs/chat/src/public-api.ts`. +Because this changes the public API docs, run: + +```bash +npm run generate-api-docs +``` + +Commit the regenerated `apps/website/content/docs/chat/api/api-docs.json`. + +## Testing + +### Unit Tests + +`libs/chat/src/lib/agent/citation-display.spec.ts`: + +- canonical source types map to expected labels/icons/tones +- missing type plus URL maps to web +- missing type without URL maps to unknown/generic with no label +- custom strings render capitalized, readable labels and generic icon/tone +- existing `citationTypeLabel()` behavior remains compatible + +`libs/ag-ui/src/lib/bridge-citations-state.spec.ts`: + +- STATE citation entries preserve `sourceType`, `iconUrl`, and `publishedAt` +- string URL shorthand remains unchanged + +### Component Tests + +`ChatCitationPreviewComponent` and `ChatCitationsCardComponent`: + +- provider `iconUrl` renders an image and suppresses fallback visuals +- non-web known type without `iconUrl` renders the type icon and badge +- custom type renders generic icon and custom label +- web without `iconUrl` keeps the monogram behavior + +`ChatCitationsComponent`: + +- header stack uses the same source visual precedence for non-web citations + +### E2E + +Update both citation twin specs: + +- `examples/chat/angular/e2e/citations.spec.ts` +- `examples/ag-ui/angular/e2e/citations.spec.ts` + +At least one fixture citation should be a non-web type, and the tests should assert +that the preview and expanded Sources panel render the source-type icon/badge. + +If fixtures or examples need richer source types, update the smallest deterministic +fixture/corpus path that feeds both examples without broad demo churn. + +## Verification + +Run the smallest relevant checks first, then broaden: + +```bash +npx nx test chat +npx nx test ag-ui +npx nx lint chat 2>&1 | grep -cE ' error ' +npx nx lint ag-ui 2>&1 | grep -cE ' error ' +npx nx build chat +npx nx build ag-ui +npx playwright test --config=examples/chat/angular/e2e/playwright.config.ts citations +npx playwright test --config=examples/ag-ui/angular/e2e/playwright.config.ts citations +``` + +Also build the relevant example apps before claiming the implementation is green, +because the examples compile library source under different strictness settings. +Name the exact Nx targets in the implementation plan after confirming the current +example project names from `project.json`. + +For final visual confidence, serve the chat example and run a real-browser smoke +with only `OPENAI_API_KEY` supplied, not the whole root `.env`. + +## Open Implementation Notes + +- Keep the implementation DRY: display branching belongs in `citation-display.ts`. +- Keep style additions in `chat-citations.styles.ts` and token defaults in + `chat-tokens.ts`. +- Do not regenerate broad docs or context files unless the touched public surface + requires it. +- Do not commit `.superpowers/brainstorm/` companion mockups. From 8de33b3ccf5f60318de75122e1af37051b83d442 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 7 Jul 2026 22:38:09 -0700 Subject: [PATCH 2/6] docs(plan): add citation source type visual language plan --- ...07-citation-source-type-visual-language.md | 730 ++++++++++++++++++ 1 file changed, 730 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-citation-source-type-visual-language.md diff --git a/docs/superpowers/plans/2026-07-07-citation-source-type-visual-language.md b/docs/superpowers/plans/2026-07-07-citation-source-type-visual-language.md new file mode 100644 index 000000000..6d85d82ad --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-citation-source-type-visual-language.md @@ -0,0 +1,730 @@ +# Citation Source-Type Visual Language Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add token-driven source-type icons and subtle badges for citations across preview cards and the Sources panel, while preserving quiet number-only inline markers and carrying source metadata through the ag-ui bridge. + +**Architecture:** Centralize source-type display decisions in `citation-display.ts`, then have Angular components render one shared visual contract: provider image, type icon, generic icon, or web monogram. Keep styles in the existing `chat-citations.styles.ts` string constants and token defaults in `chat-tokens.ts`; update ag-ui normalization and deterministic e2e fixtures after the library behavior is covered by unit/component tests. + +**Tech Stack:** Angular standalone components, Angular signals, Vitest, Nx, Playwright e2e, inline SVG, existing `--tplane-chat-*` design token system. + +**Design spec:** [docs/superpowers/specs/2026-07-07-citation-source-type-visual-language-design.md](../specs/2026-07-07-citation-source-type-visual-language-design.md) + +--- + +## Coordination Rules + +- Use `superpowers:subagent-driven-development` for execution. +- Fresh subagent per implementation task; review each returned patch before dispatching the next dependent task. +- The worktree may contain other user changes later. Do not revert changes you did not make. +- Repository instruction overrides the generic planning template: do **not** make mid-task commits. Commit only coherent completed work after verification or when explicitly requested. +- Run TDD locally inside each task: write failing tests, run the narrow target, implement, rerun. +- If a worker touches public exports, run `npm run generate-api-docs` in that task or explicitly leave it for Task 4. + +## File Structure + +**Modify:** +- `libs/chat/src/lib/agent/citation-display.ts` — source-type normalization, label formatting, icon/tone metadata, source visual metadata. +- `libs/chat/src/lib/agent/citation-display.spec.ts` — pure helper tests for canonical, generic, custom, unknown, and visual precedence behavior. +- `libs/chat/src/lib/styles/chat-tokens.ts` — light/dark per-type foreground/background/border token defaults. +- `libs/chat/src/lib/styles/chat-tokens.spec.ts` — token presence tests. +- `libs/chat/src/lib/styles/chat-citations.styles.ts` — icon, badge, tone, header-stack, preview, and card styles. +- `libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.ts` — render type-aware source visual slot and badge. +- `libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.spec.ts` — preview rendering tests. +- `libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts` — render type-aware source visual slot and badge. +- `libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.spec.ts` — card rendering tests. +- `libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts` — header stack uses the same source visual precedence. +- `libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts` — header stack type-icon test. +- `libs/chat/src/public-api.ts` — export new helper types/functions. +- `libs/ag-ui/src/lib/bridge-citations-state.ts` — preserve `sourceType`, `iconUrl`, and `publishedAt`. +- `libs/ag-ui/src/lib/bridge-citations-state.spec.ts` — bridge preservation tests. +- `examples/chat/angular/e2e/fixtures/citations.json` — deterministic response may reference a non-web source. +- `examples/ag-ui/angular/e2e/fixtures/citations.json` — twin fixture update. +- `examples/chat/python/src/graph.py` — corpus/attach output includes at least one non-web source type for live and aimock paths. +- `examples/ag-ui/python/src/graph.py` — twin corpus/attach output includes the same non-web source type and STATE metadata. +- `examples/chat/angular/e2e/citations.spec.ts` — assert source type badge/icon in preview and panel. +- `examples/ag-ui/angular/e2e/citations.spec.ts` — twin e2e assertions. +- `apps/website/content/docs/chat/api/api-docs.json` — regenerated if `public-api.ts` exports new helpers. + +--- + +## Task 1: Pure Source-Type Display Contract + +**Owner:** Worker 1 +**Files:** +- Modify: `libs/chat/src/lib/agent/citation-display.ts` +- Modify: `libs/chat/src/lib/agent/citation-display.spec.ts` +- Modify: `libs/chat/src/public-api.ts` +- Later generated by Task 4 unless this task runs docs: `apps/website/content/docs/chat/api/api-docs.json` + +- [ ] **Step 1: Write failing helper tests** + +Add tests to `citation-display.spec.ts` for: + +```ts +import { + citationTypeMeta, + citationSourceVisual, + type CitationTypeIcon, +} from './citation-display'; + +describe('citationTypeMeta', () => { + it('maps canonical file/app/memory/web types to labels, icons, tones, and known=true', () => { + expect(citationTypeMeta(c({ sourceType: 'file' }))).toMatchObject({ + type: 'file', label: 'File', icon: 'file', tone: 'file', isKnown: true, + }); + expect(citationTypeMeta(c({ sourceType: 'app' }))).toMatchObject({ + type: 'app', label: 'App', icon: 'app', tone: 'app', isKnown: true, + }); + expect(citationTypeMeta(c({ sourceType: 'memory' }))).toMatchObject({ + type: 'memory', label: 'Memory', icon: 'memory', tone: 'memory', isKnown: true, + }); + expect(citationTypeMeta(c({ url: 'https://angular.dev' }))).toMatchObject({ + type: 'web', label: 'Web', icon: 'web', tone: 'web', isKnown: true, + }); + }); + + it('treats literal generic as a known generic visual type with a label', () => { + expect(citationTypeMeta(c({ sourceType: 'generic' }))).toMatchObject({ + type: 'generic', label: 'Generic', icon: 'generic', tone: 'generic', isKnown: true, + }); + }); + + it('uses generic visuals and readable labels for custom source types', () => { + expect(citationTypeMeta(c({ sourceType: 'company-knowledge' }))).toMatchObject({ + type: 'company-knowledge', + label: 'Company knowledge', + icon: 'generic', + tone: 'generic', + isKnown: false, + }); + }); + + it('has no label for unknown missing source type without url', () => { + expect(citationTypeMeta(c({}))).toMatchObject({ + type: 'unknown', label: null, icon: 'generic', tone: 'generic', isKnown: false, + }); + }); +}); + +describe('citationSourceVisual', () => { + it('prefers provider iconUrl over all fallbacks', () => { + expect(citationSourceVisual(c({ + sourceType: 'file', + iconUrl: 'data:image/png;base64,AAA', + }))).toMatchObject({ kind: 'image', iconUrl: 'data:image/png;base64,AAA' }); + }); + + it('uses type icons for known non-web and generic/custom types', () => { + expect(citationSourceVisual(c({ sourceType: 'file' }))).toMatchObject({ + kind: 'type-icon', + icon: 'file', + tone: 'file', + }); + expect(citationSourceVisual(c({ sourceType: 'company-knowledge' }))).toMatchObject({ + kind: 'type-icon', + icon: 'generic', + tone: 'generic', + }); + }); + + it('keeps web sources on monogram fallback when no iconUrl is supplied', () => { + expect(citationSourceVisual(c({ url: 'https://angular.dev' }))).toMatchObject({ + kind: 'monogram', + monogram: 'A', + }); + }); +}); +``` + +- [ ] **Step 2: Run the narrow failing tests** + +Run: + +```bash +npx nx test chat -- -t "citationTypeMeta|citationSourceVisual" +``` + +Expected: FAIL because the new helpers are missing. + +- [ ] **Step 3: Implement the helper contract** + +In `citation-display.ts`, add exported types: + +```ts +export type CitationTypeIcon = 'web' | 'file' | 'app' | 'memory' | 'generic'; + +export interface CitationTypeMeta { + type: string; + label: string | null; + icon: CitationTypeIcon; + tone: CitationTypeIcon; + isKnown: boolean; +} + +export type CitationSourceVisual = + | { kind: 'image'; iconUrl: string } + | { kind: 'type-icon'; icon: CitationTypeIcon; tone: CitationTypeIcon; label: string | null } + | { kind: 'monogram'; monogram: string; color: string }; +``` + +Implementation rules: +- Normalize `sourceType` with `trim().toLowerCase()`. +- Collapse internal underscores/hyphens/spaces to a readable label for custom values. +- Known canonical values: `web`, `file`, `app`, `memory`, `generic`. +- Missing type with URL derives `web`; missing type without URL derives `unknown`. +- Keep `citationTypeLabel()` compatible by returning `citationTypeMeta(c).label`. +- `citationSourceVisual(c)` implements the approved precedence: + 1. `iconUrl` image. + 2. known non-web type icon. + 3. generic/custom/unknown type icon. + 4. web monogram. + Literal `sourceType: 'generic'` is handled by the type-icon branch. + +- [ ] **Step 4: Export the new API** + +Update `libs/chat/src/public-api.ts` to export: + +```ts +citationTypeMeta, +citationSourceVisual, +``` + +and types: + +```ts +export type { CitationTypeIcon, CitationTypeMeta, CitationSourceVisual } from './lib/agent/citation-display'; +``` + +If the existing export block becomes unwieldy, split value and type exports clearly without changing unrelated exports. + +- [ ] **Step 5: Verify Task 1** + +Run: + +```bash +npx nx test chat -- -t "citationTypeMeta|citationSourceVisual|citationTypeLabel" +``` + +Expected: PASS. + +--- + +## Task 2: Tokens, Styles, Preview, Card, and Panel Header + +**Owner:** Worker 2 +**Files:** +- Modify: `libs/chat/src/lib/styles/chat-tokens.ts` +- Modify: `libs/chat/src/lib/styles/chat-tokens.spec.ts` +- Modify: `libs/chat/src/lib/styles/chat-citations.styles.ts` +- Modify: `libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.spec.ts` +- Modify: `libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.spec.ts` +- Modify: `libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts` + +- [ ] **Step 1: Write failing token and component tests** + +In `chat-tokens.spec.ts`, extend the citation token test to include all per-type tokens: + +```ts +it.each([ + '--tplane-chat-citation-type-web-fg:', + '--tplane-chat-citation-type-web-bg:', + '--tplane-chat-citation-type-web-border:', + '--tplane-chat-citation-type-file-fg:', + '--tplane-chat-citation-type-file-bg:', + '--tplane-chat-citation-type-file-border:', + '--tplane-chat-citation-type-app-fg:', + '--tplane-chat-citation-type-app-bg:', + '--tplane-chat-citation-type-app-border:', + '--tplane-chat-citation-type-memory-fg:', + '--tplane-chat-citation-type-memory-bg:', + '--tplane-chat-citation-type-memory-border:', + '--tplane-chat-citation-type-generic-fg:', + '--tplane-chat-citation-type-generic-bg:', + '--tplane-chat-citation-type-generic-border:', +])('defines %s', (decl) => { + expect(ROOT_TOKEN_STYLES).toContain(decl); +}); +``` + +In `chat-citation-preview.component.spec.ts`, add: +- non-web known type renders `.chat-citation-source-icon--file` and `.chat-citation-preview__type` text `File` +- custom type renders `.chat-citation-source-icon--generic` and label `Company knowledge` +- web without `iconUrl` still renders `.chat-citation-preview__fav--mono` +- `iconUrl` still renders `img.chat-citation-preview__fav` and no fallback icon + +In `chat-citations-card.component.spec.ts`, add equivalent assertions for the card. + +In `chat-citations.component.spec.ts`, add a collapsed header stack test: + +```ts +it('uses source visual precedence in the header stack', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.message.set(msg([ + { id: 'file', index: 1, title: 'File', sourceType: 'file' }, + { id: 'web', index: 2, title: 'Web', url: 'https://angular.dev' }, + ])); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-citations__source-icon--file')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.chat-citations__fav--mono')?.textContent?.trim()).toBe('A'); +}); +``` + +- [ ] **Step 2: Run narrow tests and confirm failure** + +Run: + +```bash +npx nx test chat -- -t "citation tokens|ChatCitationPreviewComponent|ChatCitationsCardComponent|header stack" +``` + +Expected: FAIL because classes/tokens are missing. + +- [ ] **Step 3: Add token defaults** + +In `chat-tokens.ts`, add light and dark defaults. Use restrained AA-friendly values: + +Light: +- web: existing citation accent blue tokens +- file: fg `#2f684c`, bg `#edf7f1`, border `#c8e4d2` +- app: fg `#7a4d12`, bg `#fff5e3`, border `#f0d69f` +- memory: fg `#67508f`, bg `#f3effb`, border `#ddd2f1` +- generic: fg `#526071`, bg `#f4f6f8`, border `#dfe5ec` + +Dark: +- web: existing citation accent blue tokens +- file: fg `#8bd3a9`, bg `rgba(47, 104, 76, 0.20)`, border `rgba(139, 211, 169, 0.32)` +- app: fg `#f2c36f`, bg `rgba(122, 77, 18, 0.22)`, border `rgba(242, 195, 111, 0.34)` +- memory: fg `#c4b5fd`, bg `rgba(103, 80, 143, 0.26)`, border `rgba(196, 181, 253, 0.32)` +- generic: fg `#c9ccd1`, bg `rgba(255,255,255,0.08)`, border `rgba(255,255,255,0.14)` + +- [ ] **Step 4: Add shared citation icon/badge styles** + +In `chat-citations.styles.ts`, add reusable classes used by preview/card/header: + +```css +.chat-citation-source-icon { ... } +.chat-citation-source-icon--web { color: var(--tplane-chat-citation-type-web-fg); ... } +.chat-citation-source-icon--file { ... } +.chat-citation-source-icon--app { ... } +.chat-citation-source-icon--memory { ... } +.chat-citation-source-icon--generic { ... } +.chat-citation-type-badge { ... } +.chat-citation-type-badge--file { ... } +``` + +Keep dimensions stable: +- preview source icon: 18x18 +- card source icon: 14x14, also class `chat-citation-source-icon--sm` +- header stack source icon: 16x16 + +- [ ] **Step 5: Update components** + +Use `citationTypeMeta()` and `citationSourceVisual()` from Task 1. + +Preview template shape: + +```html +@switch (sourceVisual().kind) { + @case ('image') { + + } + @case ('type-icon') { + + } + @case ('monogram') { + {{ sourceVisual().monogram }} + } +} +@if (typeMeta().label; as t) { + {{ t }} +} +``` + +Angular template type narrowing may not like repeated `sourceVisual()` calls. If so, expose small methods: +- `isImageVisual(v): v is Extract` +- or compute view-model objects with stable fields. + +Do not use external icon assets. Inline SVG paths can live directly in the template under `@switch (sourceVisual().icon)`. + +Update the card and header stack similarly. Header stack entries should be view models containing `visual: CitationSourceVisual`. + +- [ ] **Step 6: Verify Task 2** + +Run: + +```bash +npx nx test chat -- -t "citation tokens|ChatCitationPreviewComponent|ChatCitationsCardComponent|header stack" +``` + +Expected: PASS. + +--- + +## Task 3: ag-ui Citation Metadata Bridge + +**Owner:** Worker 3 +**Files:** +- Modify: `libs/ag-ui/src/lib/bridge-citations-state.ts` +- Modify: `libs/ag-ui/src/lib/bridge-citations-state.spec.ts` + +- [ ] **Step 1: Write failing bridge tests** + +Add tests: + +```ts +it('preserves sourceType, iconUrl and publishedAt object fields', () => { + const result = bridgeCitationsState( + { + state: { + citations: { + m1: [{ + id: 'file1', + title: 'Local file', + sourceType: 'file', + iconUrl: 'data:image/svg+xml;base64,AAA', + publishedAt: '2024-04-10', + }], + }, + }, + }, + [baseMsg('m1')], + ); + expect(result[0].citations?.[0]).toMatchObject({ + id: 'file1', + index: 1, + title: 'Local file', + sourceType: 'file', + iconUrl: 'data:image/svg+xml;base64,AAA', + publishedAt: '2024-04-10', + }); +}); + +it('preserves numeric publishedAt and omits invalid publishedAt objects', () => { + const result = bridgeCitationsState( + { state: { citations: { m1: [{ id: 'n', publishedAt: 1712707200000 }, { id: 'bad', publishedAt: {} }] } } }, + [baseMsg('m1')], + ); + expect(result[0].citations?.[0].publishedAt).toBe(1712707200000); + expect(result[0].citations?.[1].publishedAt).toBeUndefined(); +}); +``` + +- [ ] **Step 2: Run narrow failing test** + +Run: + +```bash +npx nx test ag-ui -- -t "preserves sourceType" +``` + +Expected: FAIL because the bridge drops those fields. + +- [ ] **Step 3: Implement normalization** + +In `normalizeCitation()`: +- keep `str()` and `firstStr()` +- add: + +```ts +const publishedAt = e['publishedAt']; +const normalizedPublishedAt = + typeof publishedAt === 'string' || typeof publishedAt === 'number' || publishedAt instanceof Date + ? publishedAt + : undefined; +``` + +Return: + +```ts +sourceType: str('sourceType'), +iconUrl: str('iconUrl'), +publishedAt: normalizedPublishedAt, +``` + +Do not coerce arbitrary objects. + +- [ ] **Step 4: Verify Task 3** + +Run: + +```bash +npx nx test ag-ui -- -t "bridgeCitationsState" +``` + +Expected: PASS. + +--- + +## Task 4: Deterministic Examples, E2E Twins, and API Docs + +**Owner:** Worker 4 +**Files:** +- Modify: `examples/chat/python/src/graph.py` +- Modify: `examples/ag-ui/python/src/graph.py` +- Modify: `examples/chat/angular/e2e/fixtures/citations.json` +- Modify: `examples/ag-ui/angular/e2e/fixtures/citations.json` +- Modify: `examples/chat/angular/e2e/citations.spec.ts` +- Modify: `examples/ag-ui/angular/e2e/citations.spec.ts` +- Modify: `apps/website/content/docs/chat/api/api-docs.json` + +- [ ] **Step 1: Inspect corpus and attach shape** + +Open both `graph.py` files around: +- `_DOCUMENTS` +- `search_documents()` +- `attach_citations()` + +Keep the twin files structurally aligned. + +- [ ] **Step 2: Add/mark one deterministic non-web citation** + +Use the smallest stable update: +- Assign `sourceType: "file"` to one existing deterministic citation, preferably `ng-control-flow`, or add a corpus field that `attach_citations()` passes through. +- Add `publishedAt` if already natural for the doc, but do not invent broad metadata. +- Do not add `iconUrl` unless the example already has a provider icon source. + +In both `attach_citations()` implementations, include the metadata in each emitted citation: + +```py +if doc.get("sourceType"): + citation["sourceType"] = doc["sourceType"] +if doc.get("iconUrl"): + citation["iconUrl"] = doc["iconUrl"] +if doc.get("publishedAt"): + citation["publishedAt"] = doc["publishedAt"] +``` + +For ag-ui, confirm the STATE path uses the same citation objects. + +- [ ] **Step 3: Keep aimock fixtures ordered** + +If changing response text, preserve the fixture order: +1. `hasToolResult: true` +2. plain `{ "userMessage": "cite your sources on angular signals" }` + +Do not reverse them. + +- [ ] **Step 4: Write failing e2e assertions** + +In both e2e specs, after expanding cards: + +```ts +await expect(cards.nth(2).locator('.chat-citation-type-badge')).toContainText('File'); +await expect(cards.nth(2).locator('.chat-citation-source-icon--file')).toBeVisible(); +``` + +Also add mandatory preview assertions in both e2e specs. Make the deterministic +non-web citation easy to target by keeping `ng-control-flow` as the third unique +source and selecting the inline marker whose text is `3`: + +```ts +const fileMarker = bubble.locator('a.chat-citation-marker').filter({ hasText: '3' }); +await expect(fileMarker).toHaveCount(1); +await fileMarker.focus(); + +const preview = page.locator('.chat-citation-preview'); +await expect(preview).toBeVisible(); +await expect(preview.locator('.chat-citation-type-badge')).toContainText('File'); +await expect(preview.locator('.chat-citation-source-icon--file')).toBeVisible(); +``` + +If implementation changes the fixture so a different deterministic source is non-web, +update the marker text and card index assertions explicitly. Do not leave preview +e2e coverage optional. + +- [ ] **Step 5: Run API docs generator** + +Because Task 1 exports new public helpers, run: + +```bash +npm run generate-api-docs +``` + +Expected: `apps/website/content/docs/chat/api/api-docs.json` updates. + +- [ ] **Step 6: Verify Task 4 narrow surfaces** + +Run: + +```bash +npx nx test chat -- -t "ChatCitationPreviewComponent|ChatCitationsCardComponent|ChatCitationsComponent" +npx nx test ag-ui -- -t "bridgeCitationsState" +``` + +Expected: PASS. + +Do not run full Playwright here unless the implementation reviewer asks for it; Task 5 runs full verification. + +--- + +## Task 5: Full Verification, Browser Smoke, and Final Review + +**Owner:** Main agent after subagent patches are integrated +**Files:** No intended source edits unless verification reveals defects. + +- [ ] **Step 1: Review whole diff** + +Run: + +```bash +git diff --stat +git diff -- libs/chat/src/lib/agent/citation-display.ts +git diff -- libs/chat/src/lib/primitives/chat-citations +git diff -- libs/ag-ui/src/lib/bridge-citations-state.ts +git diff -- examples/chat examples/ag-ui +git diff -- apps/website/content/docs/chat/api/api-docs.json +``` + +Check: +- no `.superpowers/brainstorm/` files +- no `package-lock.json` churn +- no generated plans/reports beyond the approved spec and this plan +- no external icon assets +- no duplicated source-type branching in components + +- [ ] **Step 2: Run library tests** + +Run: + +```bash +npx nx test chat +npx nx test ag-ui +``` + +Expected: both pass. + +- [ ] **Step 3: Run lint error gates** + +Run: + +```bash +npx nx lint chat 2>&1 | tee /tmp/chat-lint.log; grep -cE ' error ' /tmp/chat-lint.log +npx nx lint ag-ui 2>&1 | tee /tmp/ag-ui-lint.log; grep -cE ' error ' /tmp/ag-ui-lint.log +``` + +Expected: both `grep` commands print `0`. + +- [ ] **Step 4: Run library and example builds** + +Run: + +```bash +npx nx build chat +npx nx build ag-ui +npx nx build examples-chat-angular +npx nx build examples-ag-ui-angular +``` + +Expected: all pass. If a fresh worktree misses generated licensing keys, run: + +```bash +node libs/licensing/scripts/generate-public-key.mjs +``` + +If `katex` is missing in this worktree, copy it from the primary clone's `node_modules` rather than regenerating `package-lock.json`. + +- [ ] **Step 5: Run citation e2e twins** + +Run: + +```bash +npx playwright test --config=examples/chat/angular/e2e/playwright.config.ts citations +npx playwright test --config=examples/ag-ui/angular/e2e/playwright.config.ts citations +``` + +Expected: both pass. + +- [ ] **Step 6: Real-browser local smoke** + +Serve the chat example with only the needed API key in the environment. Do not source the entire root `.env`. + +Run: + +```bash +OPENAI_API_KEY="$OPENAI_API_KEY" npx nx serve examples-chat-angular +``` + +Open the served URL in the browser, run the prompt: + +```text +cite your sources on angular signals +``` + +Verify: +- inline markers stay number-only +- preview card shows type badge/icon for the non-web source when that marker is focused/hovered +- Sources panel expands and shows the file badge/icon +- no obvious overlap or clipped badge text + +- [ ] **Step 7: Final adversarial review** + +Before PR: +- Check helper API names and exported types are documented. +- Check literal `sourceType: 'generic'` behavior matches the spec. +- Check unknown missing type has no visible label. +- Check custom type label is readable. +- Check ag-ui STATE metadata reaches `Message.citations`. +- Check e2e fixtures still place `hasToolResult: true` first. + +--- + +## PR, Merge, Monitoring, and Production Verification + +After Task 5 is green: + +- [ ] **Create final commit** + +Run: + +```bash +git status --short +git add +git commit -m "feat(chat): add citation source type visual language" +``` + +Include the already committed spec/plan history as appropriate; do not mention any agent names in commit metadata. + +- [ ] **Push branch** + +Run: + +```bash +git push -u origin blove/citation-source-type-visual-language +``` + +- [ ] **Open PR against `main`** + +Use the GitHub tooling. PR body should include: +- visual direction summary +- ag-ui bridge fix +- tests/builds/e2e run +- note that inline markers remain number-only + +Do not mention agent names. + +- [ ] **Monitor checks** + +Watch PR checks until green. If a check fails, inspect logs, fix locally, rerun the relevant command, push, and keep monitoring. + +- [ ] **Merge on green** + +When required checks are green, merge to `main`. If auto-merge is blocked only by stale mergeability after green checks, use the repository's documented admin squash merge path after confirming head checks are green. + +- [ ] **Verify production** + +After deployment completes, open the production site/docs/demo surface that corresponds to the merged examples. Verify the citation prompt or documented citation demo shows: +- Sources panel works +- non-web source badge/icon renders +- no browser console errors related to citation rendering + +If production does not expose the deterministic non-web fixture, verify the deployed docs/API reflect the new public helper exports and record that live non-web visual verification requires the deployed demo path. From 86224631b712a643bafa6f798ef423df018370a8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 00:02:48 -0700 Subject: [PATCH 3/6] feat(chat): add citation source type visuals --- .../content/docs/chat/api/api-docs.json | 254 +++++++++++++++++- .../docs/chat/concepts/message-model.mdx | 5 + examples/ag-ui/angular/e2e/citations.spec.ts | 8 +- examples/ag-ui/python/src/graph.py | 24 +- examples/chat/angular/e2e/citations.spec.ts | 9 +- examples/chat/python/src/graph.py | 24 +- .../src/lib/bridge-citations-state.spec.ts | 47 ++++ libs/ag-ui/src/lib/bridge-citations-state.ts | 8 + libs/chat/README.md | 4 + .../src/lib/agent/citation-display.spec.ts | 122 ++++++++- libs/chat/src/lib/agent/citation-display.ts | 102 ++++++- .../chat-citation-preview.component.spec.ts | 21 ++ .../chat-citation-preview.component.ts | 77 +++++- .../chat-citations-card.component.spec.ts | 27 ++ .../chat-citations-card.component.ts | 77 +++++- .../chat-citations.component.spec.ts | 27 ++ .../chat-citations.component.ts | 58 +++- .../src/lib/styles/chat-citations.styles.ts | 108 +++++++- libs/chat/src/lib/styles/chat-tokens.spec.ts | 44 +++ libs/chat/src/lib/styles/chat-tokens.ts | 32 ++- libs/chat/src/public-api.ts | 7 +- .../lib/internals/extract-citations.spec.ts | 31 +++ .../src/lib/internals/extract-citations.ts | 8 + 23 files changed, 1062 insertions(+), 62 deletions(-) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index b2f59baa8..f2fabd236 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1614,19 +1614,31 @@ "optional": false }, { - "name": "monoColor", - "type": "Signal", + "name": "published", + "type": "Signal", "description": "", "optional": false }, { - "name": "monogram", - "type": "Signal", + "name": "sourceIcon", + "type": "Signal", "description": "", "optional": false }, { - "name": "published", + "name": "sourceIconUrl", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "sourceMonoColor", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "sourceMonogram", "type": "Signal", "description": "", "optional": false @@ -1636,9 +1648,29 @@ "type": "Signal", "description": "", "optional": false + }, + { + "name": "typeTone", + "type": "Signal", + "description": "", + "optional": false } ], - "methods": [] + "methods": [ + { + "name": "isTypeTone", + "signature": "isTypeTone(tone: CitationTypeIcon): boolean", + "description": "", + "params": [ + { + "name": "tone", + "type": "CitationTypeIcon", + "description": "", + "optional": false + } + ] + } + ] }, { "name": "ChatCitationsCardComponent", @@ -1660,14 +1692,26 @@ "optional": false }, { - "name": "monoColor", - "type": "Signal", + "name": "sourceIcon", + "type": "Signal", "description": "", "optional": false }, { - "name": "monogram", - "type": "Signal", + "name": "sourceIconUrl", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "sourceMonoColor", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "sourceMonogram", + "type": "Signal", "description": "", "optional": false }, @@ -1682,9 +1726,29 @@ "type": "Signal", "description": "", "optional": false + }, + { + "name": "typeTone", + "type": "Signal", + "description": "", + "optional": false } ], - "methods": [] + "methods": [ + { + "name": "isTypeTone", + "signature": "isTypeTone(tone: CitationTypeIcon): boolean", + "description": "", + "params": [ + { + "name": "tone", + "type": "CitationTypeIcon", + "description": "", + "optional": false + } + ] + } + ] }, { "name": "ChatCitationsComponent", @@ -6508,6 +6572,122 @@ ], "examples": [] }, + { + "name": "CitationImageVisual", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "iconUrl", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "kind", + "type": "\"image\"", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "CitationMonogramVisual", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "color", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "kind", + "type": "\"monogram\"", + "description": "", + "optional": false + }, + { + "name": "monogram", + "type": "string", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "CitationTypeIconVisual", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "icon", + "type": "CitationTypeIcon", + "description": "", + "optional": false + }, + { + "name": "kind", + "type": "\"type-icon\"", + "description": "", + "optional": false + }, + { + "name": "label", + "type": "string | null", + "description": "", + "optional": false + }, + { + "name": "tone", + "type": "CitationTypeIcon", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "CitationTypeMeta", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "icon", + "type": "CitationTypeIcon", + "description": "", + "optional": false + }, + { + "name": "isKnown", + "type": "boolean", + "description": "", + "optional": false + }, + { + "name": "label", + "type": "string | null", + "description": "", + "optional": false + }, + { + "name": "tone", + "type": "CitationTypeIcon", + "description": "", + "optional": false + }, + { + "name": "type", + "type": "string", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "ClientToolsCapability", "kind": "interface", @@ -7732,6 +7912,20 @@ "signature": "\"expanded\" | \"collapsed\" | \"drawer\"", "examples": [] }, + { + "name": "CitationSourceVisual", + "kind": "type", + "description": "", + "signature": "CitationImageVisual | CitationTypeIconVisual | CitationMonogramVisual", + "examples": [] + }, + { + "name": "CitationTypeIcon", + "kind": "type", + "description": "", + "signature": "\"web\" | \"file\" | \"app\" | \"memory\" | \"generic\"", + "examples": [] + }, { "name": "ClientToolDef", "kind": "type", @@ -8005,6 +8199,25 @@ }, "examples": [] }, + { + "name": "citationSourceVisual", + "kind": "function", + "description": "", + "signature": "citationSourceVisual(c: Citation): CitationSourceVisual", + "params": [ + { + "name": "c", + "type": "Citation", + "description": "", + "optional": false + } + ], + "returns": { + "type": "CitationSourceVisual", + "description": "" + }, + "examples": [] + }, { "name": "citationTypeLabel", "kind": "function", @@ -8024,6 +8237,25 @@ }, "examples": [] }, + { + "name": "citationTypeMeta", + "kind": "function", + "description": "", + "signature": "citationTypeMeta(c: Citation): CitationTypeMeta", + "params": [ + { + "name": "c", + "type": "Citation", + "description": "", + "optional": false + } + ], + "returns": { + "type": "CitationTypeMeta", + "description": "" + }, + "examples": [] + }, { "name": "createA2uiSurfaceStore", "kind": "function", diff --git a/apps/website/content/docs/chat/concepts/message-model.mdx b/apps/website/content/docs/chat/concepts/message-model.mdx index c21a91630..0c7dc084a 100644 --- a/apps/website/content/docs/chat/concepts/message-model.mdx +++ b/apps/website/content/docs/chat/concepts/message-model.mdx @@ -138,12 +138,17 @@ interface Citation { title?: string; url?: string; snippet?: string; + sourceType?: string; // 'web' | 'file' | 'app' | 'memory' | custom + iconUrl?: string; // provider-supplied favicon/logo URL or data URI + publishedAt?: string | number | Date; extra?: Record; // provider-specific extras } ``` `@threadplane/langgraph` exports `extractCitations()` for advanced adapters that need to normalize nonstandard citation payloads. Chat markdown views can render citation markers against the message citation list. +Citation display derives a type badge from `sourceType`; when it is absent and `url` is present, the source is treated as `web`. `iconUrl` takes precedence over generated source icons and monograms. + The resolve path from a `[^id]` marker to a rendered citation runs through `CitationsResolverService` (exported from `@threadplane/chat`). A custom citation renderer provides the service, sets the current message, then calls `lookup(refId)` — which returns a reactive `Signal` matching the marker id against `message.citations` (falling back to inline markdown definitions): ```ts diff --git a/examples/ag-ui/angular/e2e/citations.spec.ts b/examples/ag-ui/angular/e2e/citations.spec.ts index be2c51647..baf94a0f4 100644 --- a/examples/ag-ui/angular/e2e/citations.spec.ts +++ b/examples/ag-ui/angular/e2e/citations.spec.ts @@ -35,16 +35,22 @@ test('sources panel is collapsed by default and expands to the cited sources', a await expect(cards.nth(0)).toContainText('Signals'); await expect(cards.nth(1)).toContainText('RxJS interop'); await expect(cards.nth(2)).toContainText('control flow'); + await expect(cards.nth(2).locator('.chat-citation-type-badge')).toContainText('File'); + await expect(cards.nth(2).locator('.chat-citation-source-icon--file')).toBeVisible(); await expect(cards.nth(0)).toHaveAttribute('href', /angular\.dev\/guide\/signals/); }); test('focusing a marker opens the portaled provenance preview card', async ({ page }) => { const bubble = await sendPromptAndWait(page, PROMPT); - await bubble.locator('a.chat-citation-marker').first().focus(); + const fileMarker = bubble.locator('a.chat-citation-marker').filter({ hasText: '3' }); + await expect(fileMarker).toHaveCount(1); + await fileMarker.focus(); const preview = page.locator('.chat-citation-preview'); await expect(preview).toBeVisible(); + await expect(preview.locator('.chat-citation-type-badge')).toContainText('File'); + await expect(preview.locator('.chat-citation-source-icon--file')).toBeVisible(); await expect(preview.locator('.chat-citation-preview__domain')).toContainText('angular.dev'); await expect(preview.locator('.chat-citation-preview__open')).toBeVisible(); }); diff --git a/examples/ag-ui/python/src/graph.py b/examples/ag-ui/python/src/graph.py index 5b044763c..ef5afc205 100644 --- a/examples/ag-ui/python/src/graph.py +++ b/examples/ag-ui/python/src/graph.py @@ -217,6 +217,7 @@ def _is_reasoning_model(name: str) -> bool: "id": "ng-control-flow", "title": "Built-in control flow — @if, @for, @switch", "url": "https://angular.dev/guide/templates/control-flow", + "sourceType": "file", "snippet": "Native template control flow replaces structural directives like *ngIf and *ngFor with built-in syntax.", }, { @@ -809,15 +810,20 @@ async def attach_citations(state: State) -> dict: # search_documents tool would never produce. if not any(k in h for k in ("title", "url", "snippet")): continue - citations.append( - { - "id": h.get("id") or f"c{i+1}", - "index": i + 1, - "title": h.get("title"), - "url": h.get("url"), - "snippet": h.get("snippet"), - } - ) + citation = { + "id": h.get("id") or f"c{i+1}", + "index": i + 1, + "title": h.get("title"), + "url": h.get("url"), + "snippet": h.get("snippet"), + } + if "sourceType" in h and h["sourceType"] is not None: + citation["sourceType"] = h["sourceType"] + if "iconUrl" in h and h["iconUrl"] is not None: + citation["iconUrl"] = h["iconUrl"] + if "publishedAt" in h and h["publishedAt"] is not None: + citation["publishedAt"] = h["publishedAt"] + citations.append(citation) break # only the most recent ToolMessage batch elif isinstance(m, AIMessage): break diff --git a/examples/chat/angular/e2e/citations.spec.ts b/examples/chat/angular/e2e/citations.spec.ts index 2a42ec35b..2bb751d36 100644 --- a/examples/chat/angular/e2e/citations.spec.ts +++ b/examples/chat/angular/e2e/citations.spec.ts @@ -35,6 +35,8 @@ test('sources panel is collapsed by default and expands to the cited sources', a await expect(cards.nth(0)).toContainText('Signals'); await expect(cards.nth(1)).toContainText('RxJS interop'); await expect(cards.nth(2)).toContainText('control flow'); + await expect(cards.nth(2).locator('.chat-citation-type-badge')).toContainText('File'); + await expect(cards.nth(2).locator('.chat-citation-source-icon--file')).toBeVisible(); // Cards are links to the source (the card element itself is the ). await expect(cards.nth(0)).toHaveAttribute('href', /angular\.dev\/guide\/signals/); }); @@ -42,12 +44,15 @@ test('sources panel is collapsed by default and expands to the cited sources', a test('focusing a marker opens the portaled provenance preview card', async ({ page }) => { const bubble = await sendPromptAndWait(page, PROMPT); - // Focus (deterministic across pointer types) opens the preview. - await bubble.locator('a.chat-citation-marker').first().focus(); + const fileMarker = bubble.locator('a.chat-citation-marker').filter({ hasText: '3' }); + await expect(fileMarker).toHaveCount(1); + await fileMarker.focus(); // The preview is portaled to the body-level overlay container, not the bubble. const preview = page.locator('.chat-citation-preview'); await expect(preview).toBeVisible(); + await expect(preview.locator('.chat-citation-type-badge')).toContainText('File'); + await expect(preview.locator('.chat-citation-source-icon--file')).toBeVisible(); await expect(preview.locator('.chat-citation-preview__domain')).toContainText('angular.dev'); await expect(preview.locator('.chat-citation-preview__open')).toBeVisible(); }); diff --git a/examples/chat/python/src/graph.py b/examples/chat/python/src/graph.py index 7fe553f1f..b694f995e 100644 --- a/examples/chat/python/src/graph.py +++ b/examples/chat/python/src/graph.py @@ -222,6 +222,7 @@ def _is_reasoning_model(name: str) -> bool: "id": "ng-control-flow", "title": "Built-in control flow — @if, @for, @switch", "url": "https://angular.dev/guide/templates/control-flow", + "sourceType": "file", "snippet": "Native template control flow replaces structural directives like *ngIf and *ngFor with built-in syntax.", }, { @@ -688,15 +689,20 @@ async def attach_citations(state: State) -> dict: # search_documents tool would never produce. if not any(k in h for k in ("title", "url", "snippet")): continue - citations.append( - { - "id": h.get("id") or f"c{i+1}", - "index": i + 1, - "title": h.get("title"), - "url": h.get("url"), - "snippet": h.get("snippet"), - } - ) + citation = { + "id": h.get("id") or f"c{i+1}", + "index": i + 1, + "title": h.get("title"), + "url": h.get("url"), + "snippet": h.get("snippet"), + } + if "sourceType" in h and h["sourceType"] is not None: + citation["sourceType"] = h["sourceType"] + if "iconUrl" in h and h["iconUrl"] is not None: + citation["iconUrl"] = h["iconUrl"] + if "publishedAt" in h and h["publishedAt"] is not None: + citation["publishedAt"] = h["publishedAt"] + citations.append(citation) break # only the most recent ToolMessage batch elif isinstance(m, AIMessage): break diff --git a/libs/ag-ui/src/lib/bridge-citations-state.spec.ts b/libs/ag-ui/src/lib/bridge-citations-state.spec.ts index f706ed33c..77dce908c 100644 --- a/libs/ag-ui/src/lib/bridge-citations-state.spec.ts +++ b/libs/ag-ui/src/lib/bridge-citations-state.spec.ts @@ -39,6 +39,53 @@ describe('bridgeCitationsState', () => { ]); }); + it('preserves sourceType, iconUrl and publishedAt object fields', () => { + const result = bridgeCitationsState( + { + state: { + citations: { + m1: [ + { + id: 'file1', + title: 'Local file', + sourceType: 'file', + iconUrl: 'data:image/svg+xml;base64,AAA', + publishedAt: '2024-04-10', + }, + ], + }, + }, + }, + [baseMsg('m1')], + ); + expect(result[0].citations?.[0]).toMatchObject({ + id: 'file1', + index: 1, + title: 'Local file', + sourceType: 'file', + iconUrl: 'data:image/svg+xml;base64,AAA', + publishedAt: '2024-04-10', + }); + }); + + it('preserves numeric publishedAt and omits invalid publishedAt objects', () => { + const result = bridgeCitationsState( + { + state: { + citations: { + m1: [ + { id: 'n', publishedAt: 1712707200000 }, + { id: 'bad', publishedAt: {} }, + ], + }, + }, + }, + [baseMsg('m1')], + ); + expect(result[0].citations?.[0].publishedAt).toBe(1712707200000); + expect(result[0].citations?.[1].publishedAt).toBeUndefined(); + }); + it('handles string entries', () => { const result = bridgeCitationsState( { state: { citations: { m1: ['https://x'] } } }, diff --git a/libs/ag-ui/src/lib/bridge-citations-state.ts b/libs/ag-ui/src/lib/bridge-citations-state.ts index 11c7b4b47..406d7da5a 100644 --- a/libs/ag-ui/src/lib/bridge-citations-state.ts +++ b/libs/ag-ui/src/lib/bridge-citations-state.ts @@ -46,12 +46,20 @@ function normalizeCitation(entry: unknown, fallbackIndex: number): Citation { } return undefined; }; + const publishedAt = e['publishedAt']; + const normalizedPublishedAt = + typeof publishedAt === 'string' || typeof publishedAt === 'number' || publishedAt instanceof Date + ? publishedAt + : undefined; return { id: str('id') ?? str('refId') ?? `c${fallbackIndex}`, index: typeof e['index'] === 'number' ? (e['index'] as number) : fallbackIndex, title: firstStr('title', 'name'), url: firstStr('url', 'href', 'source'), snippet: firstStr('snippet', 'content', 'excerpt'), + sourceType: str('sourceType'), + iconUrl: str('iconUrl'), + publishedAt: normalizedPublishedAt, extra: typeof e['extra'] === 'object' && e['extra'] !== null ? (e['extra'] as Record) diff --git a/libs/chat/README.md b/libs/chat/README.md index 496beddca..3bf786a89 100644 --- a/libs/chat/README.md +++ b/libs/chat/README.md @@ -149,6 +149,9 @@ interface Citation { title?: string; url?: string; snippet?: string; + sourceType?: string; // 'web' | 'file' | 'app' | 'memory' | custom + iconUrl?: string; // provider-supplied favicon/logo URL or data URI + publishedAt?: string | number | Date; extra?: unknown; // adapter-specific fields } ``` @@ -167,6 +170,7 @@ Use `` to render a collapsible sources panel under assistant mes Inline citation markers are rendered automatically by `MarkdownCitationReferenceComponent` inside streaming markdown output — superscript indices link to the corresponding card in the sources panel. `CitationsResolverService` resolves raw `Citation` references into `ResolvedCitation` objects with full source metadata. +Citation display helpers derive the visible source type badge from `sourceType` and fall back to `web` when a URL is present. **Adapter integration:** - **LangGraph** — reads from `message.additional_kwargs.citations` (preferred) or `.sources` (fallback). diff --git a/libs/chat/src/lib/agent/citation-display.spec.ts b/libs/chat/src/lib/agent/citation-display.spec.ts index 6d39dea09..18a8fb97d 100644 --- a/libs/chat/src/lib/agent/citation-display.spec.ts +++ b/libs/chat/src/lib/agent/citation-display.spec.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest'; import { deriveDomain, deriveSourceType, deriveMonogram, monogramHue, formatPublished, - monogramColor, citationTypeLabel, + monogramColor, citationTypeLabel, citationTypeMeta, citationSourceVisual, } from './citation-display'; +import type { CitationTypeIcon } from './citation-display'; import type { Citation } from './citation'; const c = (over: Partial): Citation => ({ id: 'x', index: 1, ...over }); @@ -22,6 +23,9 @@ describe('deriveSourceType', () => { it('prefers an explicit sourceType', () => { expect(deriveSourceType(c({ sourceType: 'file', url: 'https://a.com' }))).toBe('file'); }); + it('preserves provider casing for custom source types', () => { + expect(deriveSourceType(c({ sourceType: 'S3Bucket', url: 'https://a.com' }))).toBe('S3Bucket'); + }); it('infers web from an http(s) url', () => { expect(deriveSourceType(c({ url: 'https://a.com' }))).toBe('web'); }); @@ -83,3 +87,119 @@ describe('citationTypeLabel', () => { expect(citationTypeLabel(c({}))).toBeNull(); }); }); + +describe('citationTypeMeta', () => { + it('maps canonical file/app/memory/web types to labels, icons, tones, and known=true', () => { + const fileIcon: CitationTypeIcon = 'file'; + + expect(citationTypeMeta(c({ sourceType: 'file' }))).toMatchObject({ + type: 'file', label: 'File', icon: fileIcon, tone: 'file', isKnown: true, + }); + expect(citationTypeMeta(c({ sourceType: ' File ' }))).toMatchObject({ + type: 'file', label: 'File', icon: 'file', tone: 'file', isKnown: true, + }); + expect(citationTypeMeta(c({ sourceType: 'app' }))).toMatchObject({ + type: 'app', label: 'App', icon: 'app', tone: 'app', isKnown: true, + }); + expect(citationTypeMeta(c({ sourceType: 'memory' }))).toMatchObject({ + type: 'memory', label: 'Memory', icon: 'memory', tone: 'memory', isKnown: true, + }); + expect(citationTypeMeta(c({ url: 'https://angular.dev' }))).toMatchObject({ + type: 'web', label: 'Web', icon: 'web', tone: 'web', isKnown: true, + }); + }); + + it('treats literal generic as a known generic visual type with a label', () => { + expect(citationTypeMeta(c({ sourceType: 'generic' }))).toMatchObject({ + type: 'generic', label: 'Generic', icon: 'generic', tone: 'generic', isKnown: true, + }); + }); + + it('uses generic visuals and readable labels for custom source types', () => { + expect(citationTypeMeta(c({ sourceType: 'company-knowledge' }))).toMatchObject({ + type: 'company-knowledge', + label: 'Company knowledge', + icon: 'generic', + tone: 'generic', + isKnown: false, + }); + expect(citationTypeMeta(c({ sourceType: 'S3Bucket' }))).toMatchObject({ + type: 'S3Bucket', + label: 'S3Bucket', + icon: 'generic', + tone: 'generic', + isKnown: false, + }); + expect(citationTypeMeta(c({ sourceType: 'company_knowledge' }))).toMatchObject({ + type: 'company_knowledge', + label: 'Company knowledge', + icon: 'generic', + tone: 'generic', + isKnown: false, + }); + }); + + it('treats object prototype names as custom source types', () => { + expect(citationTypeMeta(c({ sourceType: 'constructor' }))).toMatchObject({ + type: 'constructor', + label: 'Constructor', + icon: 'generic', + tone: 'generic', + isKnown: false, + }); + }); + + it('has no label for separator-only custom source types', () => { + expect(citationTypeMeta(c({ sourceType: '---' }))).toMatchObject({ + type: '---', label: null, icon: 'generic', tone: 'generic', isKnown: false, + }); + expect(citationTypeLabel(c({ sourceType: '___' }))).toBeNull(); + }); + + it('has no label for unknown missing source type without url', () => { + expect(citationTypeMeta(c({}))).toMatchObject({ + type: 'unknown', label: null, icon: 'generic', tone: 'generic', isKnown: false, + }); + }); +}); + +describe('citationSourceVisual', () => { + it('prefers provider iconUrl over all fallbacks', () => { + expect(citationSourceVisual(c({ + sourceType: 'file', + iconUrl: 'data:image/png;base64,AAA', + }))).toMatchObject({ kind: 'image', iconUrl: 'data:image/png;base64,AAA' }); + }); + + it('uses type icons for known non-web and generic/custom types', () => { + expect(citationSourceVisual(c({ sourceType: 'file' }))).toMatchObject({ + kind: 'type-icon', + icon: 'file', + tone: 'file', + }); + expect(citationSourceVisual(c({ sourceType: 'generic' }))).toMatchObject({ + kind: 'type-icon', + icon: 'generic', + tone: 'generic', + label: 'Generic', + }); + expect(citationSourceVisual(c({ sourceType: 'company-knowledge' }))).toMatchObject({ + kind: 'type-icon', + icon: 'generic', + tone: 'generic', + }); + expect(citationSourceVisual(c({}))).toMatchObject({ + kind: 'type-icon', + icon: 'generic', + tone: 'generic', + label: null, + }); + }); + + it('keeps web sources on monogram fallback when no iconUrl is supplied', () => { + expect(citationSourceVisual(c({ url: 'https://angular.dev' }))).toMatchObject({ + kind: 'monogram', + monogram: 'A', + }); + }); +}); diff --git a/libs/chat/src/lib/agent/citation-display.ts b/libs/chat/src/lib/agent/citation-display.ts index a3da2bd2b..8885cb628 100644 --- a/libs/chat/src/lib/agent/citation-display.ts +++ b/libs/chat/src/lib/agent/citation-display.ts @@ -3,6 +3,47 @@ // the preview card, and the sources panel so provenance rendering stays DRY. import type { Citation } from './citation'; +export type CitationTypeIcon = 'web' | 'file' | 'app' | 'memory' | 'generic'; + +export interface CitationTypeMeta { + type: string; + label: string | null; + icon: CitationTypeIcon; + tone: CitationTypeIcon; + isKnown: boolean; +} + +export interface CitationImageVisual { + kind: 'image'; + iconUrl: string; +} + +export interface CitationTypeIconVisual { + kind: 'type-icon'; + icon: CitationTypeIcon; + tone: CitationTypeIcon; + label: string | null; +} + +export interface CitationMonogramVisual { + kind: 'monogram'; + monogram: string; + color: string; +} + +export type CitationSourceVisual = + | CitationImageVisual + | CitationTypeIconVisual + | CitationMonogramVisual; + +const KNOWN_TYPE_LABELS: Record = { + web: 'Web', + file: 'File', + app: 'App', + memory: 'Memory', + generic: 'Generic', +}; + /** Hostname of `url` with a leading `www.` removed; null if absent/malformed. */ export function deriveDomain(url?: string): string | null { if (!url) return null; @@ -15,7 +56,8 @@ export function deriveDomain(url?: string): string | null { /** Explicit `sourceType`, else 'web' inferred from a url, else 'unknown'. */ export function deriveSourceType(c: Citation): string { - if (c.sourceType) return c.sourceType; + const explicit = c.sourceType?.trim(); + if (explicit) return explicit; return c.url ? 'web' : 'unknown'; } @@ -49,9 +91,61 @@ export function monogramColor(c: Citation): string { return `hsl(${monogramHue(seed)} 60% 45%)`; } +function isKnownType(type: string): type is CitationTypeIcon { + return Object.prototype.hasOwnProperty.call(KNOWN_TYPE_LABELS, type); +} + +function readableSourceTypeLabel(type: string): string | null { + const words = type.replace(/[-_\s]+/g, ' ').trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : null; +} + +export function citationTypeMeta(c: Citation): CitationTypeMeta { + const type = deriveSourceType(c); + const canonicalType = type.toLowerCase(); + if (isKnownType(canonicalType)) { + return { + type: canonicalType, + label: KNOWN_TYPE_LABELS[canonicalType], + icon: canonicalType, + tone: canonicalType, + isKnown: true, + }; + } + + return { + type, + label: type === 'unknown' ? null : readableSourceTypeLabel(type), + icon: 'generic', + tone: 'generic', + isKnown: false, + }; +} + +export function citationSourceVisual(c: Citation): CitationSourceVisual { + const iconUrl = c.iconUrl?.trim(); + if (iconUrl) { + return { kind: 'image', iconUrl }; + } + + const meta = citationTypeMeta(c); + if (meta.icon !== 'web') { + return { + kind: 'type-icon', + icon: meta.icon, + tone: meta.tone, + label: meta.label, + }; + } + + return { + kind: 'monogram', + monogram: deriveMonogram(c), + color: monogramColor(c), + }; +} + /** Human label for the source-type badge; null when type is 'unknown'. */ export function citationTypeLabel(c: Citation): string | null { - const t = deriveSourceType(c); - if (t === 'unknown') return null; - return t === 'web' ? 'Web' : t.charAt(0).toUpperCase() + t.slice(1); + return citationTypeMeta(c).label; } diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.spec.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.spec.ts index 3f1e5a702..cc5217a96 100644 --- a/libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.spec.ts @@ -46,10 +46,31 @@ describe('ChatCitationPreviewComponent', () => { expect(mono?.textContent?.trim()).toBe('A'); }); + it('renders a known non-web source type icon and badge', () => { + const el = render({ id: 'a', index: 1, title: 'Local file', sourceType: 'file' }); + const sourceIcon = el.querySelector('.chat-citation-source-icon--file'); + expect(sourceIcon).toBeTruthy(); + expect(sourceIcon?.getAttribute('aria-hidden')).toBe('true'); + expect(el.querySelector('.chat-citation-preview__type')?.textContent?.trim()).toBe('File'); + }); + + it('renders a generic source type icon and readable badge for custom types', () => { + const el = render({ id: 'a', index: 1, title: 'Company memo', sourceType: 'company-knowledge' }); + expect(el.querySelector('.chat-citation-source-icon--generic')).toBeTruthy(); + expect(el.querySelector('.chat-citation-preview__type')?.textContent?.trim()).toBe('Company knowledge'); + }); + + it('keeps web sources without iconUrl on the monogram fallback', () => { + const el = render({ id: 'a', index: 1, url: 'https://angular.dev' }); + expect(el.querySelector('.chat-citation-preview__fav--mono')?.textContent?.trim()).toBe('A'); + expect(el.querySelector('.chat-citation-source-icon--web')).toBeNull(); + }); + it('renders an favicon when iconUrl is supplied', () => { const el = render({ id: 'a', index: 1, url: 'https://angular.dev', iconUrl: 'data:image/png;base64,AAA' }); expect(el.querySelector('img.chat-citation-preview__fav')).toBeTruthy(); expect(el.querySelector('.chat-citation-preview__fav--mono')).toBeNull(); + expect(el.querySelector('.chat-citation-source-icon')).toBeNull(); }); it('shows a freshness label from publishedAt', () => { diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.ts index 843c422a0..de0e61397 100644 --- a/libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.ts +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citation-preview.component.ts @@ -3,8 +3,9 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import type { Citation } from '../../agent/citation'; import { - deriveDomain, deriveMonogram, monogramColor, citationTypeLabel, formatPublished, + citationSourceVisual, citationTypeMeta, deriveDomain, formatPublished, } from '../../agent/citation-display'; +import type { CitationTypeIcon } from '../../agent/citation-display'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_CITATION_PREVIEW_STYLES } from '../../styles/chat-citations.styles'; @@ -21,14 +22,54 @@ import { CHAT_CITATION_PREVIEW_STYLES } from '../../styles/chat-citations.styles template: `
- @if (citation().iconUrl; as icon) { + @if (sourceIconUrl(); as icon) { + } @else if (sourceIcon(); as icon) { + } @else { {{ monogram() }} + [style.background]="sourceMonoColor()">{{ sourceMonogram() }} } @if (domain(); as d) { {{ d }} } - @if (typeLabel(); as t) { {{ t }} } + @if (typeLabel(); as t) { + {{ t }} + }
@if (citation().title; as title) {

{{ title }}

@@ -53,9 +94,31 @@ import { CHAT_CITATION_PREVIEW_STYLES } from '../../styles/chat-citations.styles export class ChatCitationPreviewComponent { readonly citation = input.required(); + private readonly sourceVisual = computed(() => citationSourceVisual(this.citation())); + private readonly typeMeta = computed(() => citationTypeMeta(this.citation())); + protected readonly domain = computed(() => deriveDomain(this.citation().url)); - protected readonly monogram = computed(() => deriveMonogram(this.citation())); - protected readonly monoColor = computed(() => monogramColor(this.citation())); - protected readonly typeLabel = computed(() => citationTypeLabel(this.citation())); + protected readonly sourceIconUrl = computed(() => { + const visual = this.sourceVisual(); + return visual.kind === 'image' ? visual.iconUrl : null; + }); + protected readonly sourceIcon = computed((): CitationTypeIcon | null => { + const visual = this.sourceVisual(); + return visual.kind === 'type-icon' ? visual.icon : null; + }); + protected readonly sourceMonogram = computed(() => { + const visual = this.sourceVisual(); + return visual.kind === 'monogram' ? visual.monogram : null; + }); + protected readonly sourceMonoColor = computed(() => { + const visual = this.sourceVisual(); + return visual.kind === 'monogram' ? visual.color : null; + }); + protected readonly typeLabel = computed(() => this.typeMeta().label); + protected readonly typeTone = computed(() => this.typeMeta().tone); protected readonly published = computed(() => formatPublished(this.citation().publishedAt)); + + protected isTypeTone(tone: CitationTypeIcon): boolean { + return this.typeTone() === tone; + } } diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.spec.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.spec.ts index f5a9734b0..3cc2d9eda 100644 --- a/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.spec.ts @@ -47,4 +47,31 @@ describe('ChatCitationsCardComponent', () => { const el = render({ id: 'a', index: 1, url: 'https://angular.dev' }); expect(el.querySelector('.chat-citations-card__fav--mono')?.textContent?.trim()).toBe('A'); }); + + it('renders a known non-web source type icon and badge', () => { + const el = render({ id: 'a', index: 1, title: 'Local file', sourceType: 'file' }); + const sourceIcon = el.querySelector('.chat-citation-source-icon--file'); + expect(sourceIcon).toBeTruthy(); + expect(sourceIcon?.getAttribute('aria-hidden')).toBe('true'); + expect(el.querySelector('.chat-citations-card__type')?.textContent?.trim()).toBe('File'); + }); + + it('renders a generic source type icon and readable badge for custom types', () => { + const el = render({ id: 'a', index: 1, title: 'Company memo', sourceType: 'company-knowledge' }); + expect(el.querySelector('.chat-citation-source-icon--generic')).toBeTruthy(); + expect(el.querySelector('.chat-citations-card__type')?.textContent?.trim()).toBe('Company knowledge'); + }); + + it('keeps web sources without iconUrl on the monogram fallback', () => { + const el = render({ id: 'a', index: 1, url: 'https://angular.dev' }); + expect(el.querySelector('.chat-citations-card__fav--mono')?.textContent?.trim()).toBe('A'); + expect(el.querySelector('.chat-citation-source-icon--web')).toBeNull(); + }); + + it('renders an favicon when iconUrl is supplied', () => { + const el = render({ id: 'a', index: 1, url: 'https://angular.dev', iconUrl: 'data:image/png;base64,AAA' }); + expect(el.querySelector('img.chat-citations-card__fav')).toBeTruthy(); + expect(el.querySelector('.chat-citations-card__fav--mono')).toBeNull(); + expect(el.querySelector('.chat-citation-source-icon')).toBeNull(); + }); }); diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts index 03928997f..70f038033 100644 --- a/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations-card.component.ts @@ -3,7 +3,8 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import type { Citation } from '../../agent/citation'; -import { deriveDomain, deriveMonogram, monogramColor, citationTypeLabel } from '../../agent/citation-display'; +import { citationSourceVisual, citationTypeMeta, deriveDomain } from '../../agent/citation-display'; +import type { CitationTypeIcon } from '../../agent/citation-display'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_CITATIONS_PANEL_STYLES } from '../../styles/chat-citations.styles'; @@ -34,14 +35,54 @@ import { CHAT_CITATIONS_PANEL_STYLES } from '../../styles/chat-citations.styles' {{ citation().index }} - @if (citation().iconUrl; as icon) { + @if (sourceIconUrl(); as icon) { + } @else if (sourceIcon(); as icon) { + } @else { {{ monogram() }} + [style.background]="sourceMonoColor()">{{ sourceMonogram() }} } @if (domain(); as d) { {{ d }} } - @if (typeLabel(); as t) { {{ t }} } + @if (typeLabel(); as t) { + {{ t }} + } @if (title(); as t) { {{ t }} @@ -56,9 +97,31 @@ import { CHAT_CITATIONS_PANEL_STYLES } from '../../styles/chat-citations.styles' export class ChatCitationsCardComponent { readonly citation = input.required(); + private readonly sourceVisual = computed(() => citationSourceVisual(this.citation())); + private readonly typeMeta = computed(() => citationTypeMeta(this.citation())); + protected readonly domain = computed(() => deriveDomain(this.citation().url)); protected readonly title = computed(() => this.citation().title ?? this.citation().url ?? null); - protected readonly monogram = computed(() => deriveMonogram(this.citation())); - protected readonly monoColor = computed(() => monogramColor(this.citation())); - protected readonly typeLabel = computed(() => citationTypeLabel(this.citation())); + protected readonly sourceIconUrl = computed(() => { + const visual = this.sourceVisual(); + return visual.kind === 'image' ? visual.iconUrl : null; + }); + protected readonly sourceIcon = computed((): CitationTypeIcon | null => { + const visual = this.sourceVisual(); + return visual.kind === 'type-icon' ? visual.icon : null; + }); + protected readonly sourceMonogram = computed(() => { + const visual = this.sourceVisual(); + return visual.kind === 'monogram' ? visual.monogram : null; + }); + protected readonly sourceMonoColor = computed(() => { + const visual = this.sourceVisual(); + return visual.kind === 'monogram' ? visual.color : null; + }); + protected readonly typeLabel = computed(() => this.typeMeta().label); + protected readonly typeTone = computed(() => this.typeMeta().tone); + + protected isTypeTone(tone: CitationTypeIcon): boolean { + return this.typeTone() === tone; + } } diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts index 8f8c59472..0cbdf28fa 100644 --- a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts @@ -154,6 +154,33 @@ describe('ChatCitationsComponent', () => { expect(fixture.nativeElement.querySelector('.chat-citations__count')?.textContent?.trim()).toBe('2'); }); + it('uses source visual precedence in the header stack', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.message.set(msg([ + { id: 'file', index: 1, title: 'File', sourceType: 'file' }, + { id: 'web', index: 2, title: 'Web', url: 'https://angular.dev' }, + ])); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chat-citations__source-icon--file')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.chat-citations__fav--mono')?.textContent?.trim()).toBe('A'); + }); + + it('prefers provider iconUrl over source type icons in the header stack', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.message.set(msg([ + { + id: 'provider-icon', + index: 1, + title: 'File with provider icon', + sourceType: 'file', + iconUrl: 'data:image/png;base64,AAA', + }, + ])); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('img.chat-citations__fav')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.chat-citations__source-icon--file')).toBeNull(); + }); + it('expands and collapses the list when the header is toggled', () => { const fixture = TestBed.createComponent(HostComponent); fixture.componentInstance.message.set(msg([{ id: 'a', index: 1, title: 'A' }])); diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts index 36ca6304d..f6b92acdc 100644 --- a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts @@ -9,7 +9,8 @@ import type { Message } from '../../agent/message'; import type { Citation } from '../../agent/citation'; import { ChatCitationsCardComponent } from './chat-citations-card.component'; import { CitationsResolverService, mdDefToCitation } from '../../markdown/citations-resolver.service'; -import { deriveMonogram, monogramColor } from '../../agent/citation-display'; +import { citationSourceVisual } from '../../agent/citation-display'; +import type { CitationTypeIcon } from '../../agent/citation-display'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_CITATIONS_PANEL_STYLES } from '../../styles/chat-citations.styles'; @@ -22,7 +23,15 @@ export class ChatCitationCardTemplateDirective { readonly tpl = inject>(TemplateRef); } -interface FavEntry { id: string; iconUrl?: string; mono: string; color: string; } +interface FavEntry { + id: string; + kind: 'image' | 'type-icon' | 'monogram'; + iconUrl?: string; + icon?: CitationTypeIcon; + tone?: CitationTypeIcon; + monogram?: string; + color?: string; +} let nextCitationsId = 0; @@ -46,11 +55,48 @@ let nextCitationsId = 0; {{ citations().length }} } } @@ -108,8 +154,6 @@ export class ChatCitationsComponent { /** First 3 sources, mapped to favicon/monogram chips for the header preview. */ protected readonly favstack = computed(() => - this.citations().slice(0, 3).map((c) => ({ - id: c.id, iconUrl: c.iconUrl, mono: deriveMonogram(c), color: monogramColor(c), - })), + this.citations().slice(0, 3).map((c) => ({ id: c.id, ...citationSourceVisual(c) })), ); } diff --git a/libs/chat/src/lib/styles/chat-citations.styles.ts b/libs/chat/src/lib/styles/chat-citations.styles.ts index 65dbb8833..f59e07b3e 100644 --- a/libs/chat/src/lib/styles/chat-citations.styles.ts +++ b/libs/chat/src/lib/styles/chat-citations.styles.ts @@ -1,6 +1,99 @@ // libs/chat/src/lib/styles/chat-citations.styles.ts // SPDX-License-Identifier: MIT +const CHAT_CITATION_SOURCE_VISUAL_STYLES = ` + .chat-citation-source-icon { + width: 18px; + height: 18px; + flex: 0 0 auto; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 5px; + border: 1px solid currentColor; + } + .chat-citation-source-icon svg { + width: 12px; + height: 12px; + display: block; + } + .chat-citation-source-icon--sm { + width: 14px; + height: 14px; + border-radius: 4px; + } + .chat-citation-source-icon--sm svg { + width: 9px; + height: 9px; + } + .chat-citation-source-icon--web { + color: var(--tplane-chat-citation-type-web-fg); + background: var(--tplane-chat-citation-type-web-bg); + border-color: var(--tplane-chat-citation-type-web-border); + } + .chat-citation-source-icon--file { + color: var(--tplane-chat-citation-type-file-fg); + background: var(--tplane-chat-citation-type-file-bg); + border-color: var(--tplane-chat-citation-type-file-border); + } + .chat-citation-source-icon--app { + color: var(--tplane-chat-citation-type-app-fg); + background: var(--tplane-chat-citation-type-app-bg); + border-color: var(--tplane-chat-citation-type-app-border); + } + .chat-citation-source-icon--memory { + color: var(--tplane-chat-citation-type-memory-fg); + background: var(--tplane-chat-citation-type-memory-bg); + border-color: var(--tplane-chat-citation-type-memory-border); + } + .chat-citation-source-icon--generic { + color: var(--tplane-chat-citation-type-generic-fg); + background: var(--tplane-chat-citation-type-generic-bg); + border-color: var(--tplane-chat-citation-type-generic-border); + } + .chat-citation-type-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + max-width: 120px; + box-sizing: border-box; + padding: 1px 6px; + border: 1px solid currentColor; + border-radius: 999px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.35; + } + .chat-citation-type-badge--web { + color: var(--tplane-chat-citation-type-web-fg); + background: var(--tplane-chat-citation-type-web-bg); + border-color: var(--tplane-chat-citation-type-web-border); + } + .chat-citation-type-badge--file { + color: var(--tplane-chat-citation-type-file-fg); + background: var(--tplane-chat-citation-type-file-bg); + border-color: var(--tplane-chat-citation-type-file-border); + } + .chat-citation-type-badge--app { + color: var(--tplane-chat-citation-type-app-fg); + background: var(--tplane-chat-citation-type-app-bg); + border-color: var(--tplane-chat-citation-type-app-border); + } + .chat-citation-type-badge--memory { + color: var(--tplane-chat-citation-type-memory-fg); + background: var(--tplane-chat-citation-type-memory-bg); + border-color: var(--tplane-chat-citation-type-memory-border); + } + .chat-citation-type-badge--generic { + color: var(--tplane-chat-citation-type-generic-fg); + background: var(--tplane-chat-citation-type-generic-bg); + border-color: var(--tplane-chat-citation-type-generic-border); + } +`; + /** Inline pill marker (chat-md-citation-reference). */ export const CHAT_CITATION_MARKER_STYLES = ` :host { display: inline; } @@ -53,6 +146,7 @@ export const CHAT_CITATION_MARKER_STYLES = ` /** Provenance preview card (chat-citation-preview), portaled into the overlay pane. */ export const CHAT_CITATION_PREVIEW_STYLES = ` + ${CHAT_CITATION_SOURCE_VISUAL_STYLES} :host { display: block; } .chat-citation-preview { width: 320px; @@ -90,7 +184,6 @@ export const CHAT_CITATION_PREVIEW_STYLES = ` .chat-citation-preview__type { margin-left: auto; font-size: 11px; - color: var(--tplane-chat-text-muted); flex: 0 0 auto; } .chat-citation-preview__title { @@ -120,6 +213,7 @@ export const CHAT_CITATION_PREVIEW_STYLES = ` /** Sources panel (chat-citations) + detail card (chat-citations-card). */ export const CHAT_CITATIONS_PANEL_STYLES = ` + ${CHAT_CITATION_SOURCE_VISUAL_STYLES} :host { display: block; } .chat-citations { margin-top: var(--tplane-chat-space-5); @@ -156,6 +250,16 @@ export const CHAT_CITATIONS_PANEL_STYLES = ` display: flex; align-items: center; justify-content: center; color: #fff; font-size: 9px; font-weight: 700; } + .chat-citations__source-icon { + width: 16px; height: 16px; + border-radius: 4px; + margin-left: -5px; + border-width: 1.5px; + } + .chat-citations__source-icon:first-child { margin-left: 0; } + .chat-citations__source-icon svg { + width: 10px; height: 10px; + } .chat-citations__chevron { margin-left: auto; color: var(--tplane-chat-text-muted); @@ -206,7 +310,7 @@ export const CHAT_CITATIONS_PANEL_STYLES = ` color: #fff; font-size: 8px; font-weight: 700; } .chat-citations-card__domain { font-size: 11.5px; color: var(--tplane-chat-text-muted); } - .chat-citations-card__type { margin-left: auto; font-size: 10.5px; color: var(--tplane-chat-text-muted); } + .chat-citations-card__type { margin-left: auto; font-size: 10.5px; } .chat-citations-card__title { font-size: 13.5px; font-weight: 600; line-height: 1.35; margin: 0 0 2px; diff --git a/libs/chat/src/lib/styles/chat-tokens.spec.ts b/libs/chat/src/lib/styles/chat-tokens.spec.ts index bb0be8eae..37e931d6e 100644 --- a/libs/chat/src/lib/styles/chat-tokens.spec.ts +++ b/libs/chat/src/lib/styles/chat-tokens.spec.ts @@ -114,11 +114,55 @@ describe('ROOT_TOKEN_STYLES — citation tokens', () => { '--tplane-chat-citation-marker-border:', '--tplane-chat-citation-marker-fg:', '--tplane-chat-citation-radius:', + '--tplane-chat-citation-type-web-fg:', + '--tplane-chat-citation-type-web-bg:', + '--tplane-chat-citation-type-web-border:', + '--tplane-chat-citation-type-file-fg:', + '--tplane-chat-citation-type-file-bg:', + '--tplane-chat-citation-type-file-border:', + '--tplane-chat-citation-type-app-fg:', + '--tplane-chat-citation-type-app-bg:', + '--tplane-chat-citation-type-app-border:', + '--tplane-chat-citation-type-memory-fg:', + '--tplane-chat-citation-type-memory-bg:', + '--tplane-chat-citation-type-memory-border:', + '--tplane-chat-citation-type-generic-fg:', + '--tplane-chat-citation-type-generic-bg:', + '--tplane-chat-citation-type-generic-border:', ])('defines %s', (decl) => { expect(ROOT_TOKEN_STYLES).toContain(decl); }); + + it.each([ + ['web', '#1d4ed8', '#eaf1fd'], + ['file', '#2f684c', '#edf7f1'], + ['app', '#7a4d12', '#fff5e3'], + ['memory', '#67508f', '#f3effb'], + ['generic', '#526071', '#f4f6f8'], + ])('keeps light %s citation type text at AA contrast', (_type, fg, bg) => { + expect(contrastRatio(fg, bg)).toBeGreaterThanOrEqual(4.5); + }); }); +function relativeLuminance(hex: string): number { + const [r, g, b] = hex + .replace('#', '') + .match(/../g)! + .map((part) => { + const channel = parseInt(part, 16) / 255; + return channel <= 0.03928 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrastRatio(foreground: string, background: string): number { + const fg = relativeLuminance(foreground); + const bg = relativeLuminance(background); + return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); +} + describe('ROOT_TOKEN_STYLES — theme attribute selectors', () => { it.each([ '[data-theme="light"]', diff --git a/libs/chat/src/lib/styles/chat-tokens.ts b/libs/chat/src/lib/styles/chat-tokens.ts index 8bfa4f03e..e88e21aac 100644 --- a/libs/chat/src/lib/styles/chat-tokens.ts +++ b/libs/chat/src/lib/styles/chat-tokens.ts @@ -45,9 +45,24 @@ const LIGHT_TOKENS = ` --a2ui-elevation-5: 0 16px 32px rgba(0, 0, 0, 0.18); /* --tplane-chat-citation-* — inline markers, preview card, sources panel */ - --tplane-chat-citation-accent: #2f6fe0; + --tplane-chat-citation-accent: #1d4ed8; --tplane-chat-citation-accent-soft: #eaf1fd; --tplane-chat-citation-accent-border: #c9def8; + --tplane-chat-citation-type-web-fg: var(--tplane-chat-citation-accent); + --tplane-chat-citation-type-web-bg: var(--tplane-chat-citation-accent-soft); + --tplane-chat-citation-type-web-border: var(--tplane-chat-citation-accent-border); + --tplane-chat-citation-type-file-fg: #2f684c; + --tplane-chat-citation-type-file-bg: #edf7f1; + --tplane-chat-citation-type-file-border: #c8e4d2; + --tplane-chat-citation-type-app-fg: #7a4d12; + --tplane-chat-citation-type-app-bg: #fff5e3; + --tplane-chat-citation-type-app-border: #f0d69f; + --tplane-chat-citation-type-memory-fg: #67508f; + --tplane-chat-citation-type-memory-bg: #f3effb; + --tplane-chat-citation-type-memory-border: #ddd2f1; + --tplane-chat-citation-type-generic-fg: #526071; + --tplane-chat-citation-type-generic-bg: #f4f6f8; + --tplane-chat-citation-type-generic-border: #dfe5ec; --tplane-chat-citation-marker-bg: #f1f2f4; --tplane-chat-citation-marker-border: var(--tplane-chat-separator); --tplane-chat-citation-marker-fg: #4b5563; @@ -97,6 +112,21 @@ const DARK_TOKENS = ` --tplane-chat-citation-accent: #6ea8ff; --tplane-chat-citation-accent-soft: rgba(79, 141, 245, 0.16); --tplane-chat-citation-accent-border: rgba(79, 141, 245, 0.38); + --tplane-chat-citation-type-web-fg: var(--tplane-chat-citation-accent); + --tplane-chat-citation-type-web-bg: var(--tplane-chat-citation-accent-soft); + --tplane-chat-citation-type-web-border: var(--tplane-chat-citation-accent-border); + --tplane-chat-citation-type-file-fg: #8bd3a9; + --tplane-chat-citation-type-file-bg: rgba(47, 104, 76, 0.20); + --tplane-chat-citation-type-file-border: rgba(139, 211, 169, 0.32); + --tplane-chat-citation-type-app-fg: #f2c36f; + --tplane-chat-citation-type-app-bg: rgba(122, 77, 18, 0.22); + --tplane-chat-citation-type-app-border: rgba(242, 195, 111, 0.34); + --tplane-chat-citation-type-memory-fg: #c4b5fd; + --tplane-chat-citation-type-memory-bg: rgba(103, 80, 143, 0.26); + --tplane-chat-citation-type-memory-border: rgba(196, 181, 253, 0.32); + --tplane-chat-citation-type-generic-fg: #c9ccd1; + --tplane-chat-citation-type-generic-bg: rgba(255,255,255,0.08); + --tplane-chat-citation-type-generic-border: rgba(255,255,255,0.14); --tplane-chat-citation-marker-bg: rgba(255, 255, 255, 0.08); --tplane-chat-citation-marker-border: var(--tplane-chat-separator); --tplane-chat-citation-marker-fg: #c9ccd1; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 5396a8056..c3048c6e8 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -88,7 +88,12 @@ export { ChatCitationsComponent, ChatCitationCardTemplateDirective } from './lib export { ChatCitationsCardComponent } from './lib/primitives/chat-citations/chat-citations-card.component'; export { ChatCitationPreviewComponent } from './lib/primitives/chat-citations/chat-citation-preview.component'; export { - deriveDomain, deriveSourceType, deriveMonogram, monogramHue, monogramColor, citationTypeLabel, formatPublished, + deriveDomain, deriveSourceType, deriveMonogram, monogramHue, monogramColor, citationTypeLabel, + citationTypeMeta, citationSourceVisual, formatPublished, +} from './lib/agent/citation-display'; +export type { + CitationTypeIcon, CitationTypeMeta, CitationSourceVisual, + CitationImageVisual, CitationTypeIconVisual, CitationMonogramVisual, } from './lib/agent/citation-display'; // DI provider diff --git a/libs/langgraph/src/lib/internals/extract-citations.spec.ts b/libs/langgraph/src/lib/internals/extract-citations.spec.ts index 05aac0b6d..a08bdd255 100644 --- a/libs/langgraph/src/lib/internals/extract-citations.spec.ts +++ b/libs/langgraph/src/lib/internals/extract-citations.spec.ts @@ -47,6 +47,37 @@ describe('extractCitations', () => { })).toEqual([{ id: 'a', index: 5, title: 'A' }]); }); + it('preserves sourceType, iconUrl and primitive publishedAt fields', () => { + expect(extractCitations({ + additional_kwargs: { + citations: [ + { + id: 'a', + title: 'A', + sourceType: 'file', + iconUrl: 'https://example.com/icon.svg', + publishedAt: '2024-04-10', + }, + { + id: 'b', + title: 'B', + publishedAt: { date: '2024-04-10' }, + }, + ], + }, + })).toEqual([ + { + id: 'a', + index: 1, + title: 'A', + sourceType: 'file', + iconUrl: 'https://example.com/icon.svg', + publishedAt: '2024-04-10', + }, + { id: 'b', index: 2, title: 'B', publishedAt: undefined }, + ]); + }); + it('returns undefined for empty array', () => { expect(extractCitations({ additional_kwargs: { citations: [] } })).toBeUndefined(); }); diff --git a/libs/langgraph/src/lib/internals/extract-citations.ts b/libs/langgraph/src/lib/internals/extract-citations.ts index 95a84e9c3..27f8a7536 100644 --- a/libs/langgraph/src/lib/internals/extract-citations.ts +++ b/libs/langgraph/src/lib/internals/extract-citations.ts @@ -39,12 +39,20 @@ function normalizeCitation(entry: unknown, fallbackIndex: number): Citation { } return undefined; }; + const publishedAt = e['publishedAt']; + const normalizedPublishedAt = + typeof publishedAt === 'string' || typeof publishedAt === 'number' || publishedAt instanceof Date + ? publishedAt + : undefined; return { id: str('id') ?? str('refId') ?? `c${fallbackIndex}`, index: typeof e['index'] === 'number' ? (e['index'] as number) : fallbackIndex, title: firstStr('title', 'name'), url: firstStr('url', 'href', 'source'), snippet: firstStr('snippet', 'content', 'excerpt'), + sourceType: str('sourceType'), + iconUrl: str('iconUrl'), + publishedAt: normalizedPublishedAt, extra: typeof e['extra'] === 'object' && e['extra'] !== null ? (e['extra'] as Record) From 93316eed769a353a1cba5ec642e219eedf03d3aa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 00:15:49 -0700 Subject: [PATCH 4/6] docs(chat): document citation display helpers --- libs/chat/src/lib/agent/citation-display.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libs/chat/src/lib/agent/citation-display.ts b/libs/chat/src/lib/agent/citation-display.ts index 8885cb628..6e352e920 100644 --- a/libs/chat/src/lib/agent/citation-display.ts +++ b/libs/chat/src/lib/agent/citation-display.ts @@ -100,6 +100,9 @@ function readableSourceTypeLabel(type: string): string | null { return words ? words.charAt(0).toUpperCase() + words.slice(1) : null; } +/** + * Derive normalized source-type metadata for badges, icons, and tone tokens. + */ export function citationTypeMeta(c: Citation): CitationTypeMeta { const type = deriveSourceType(c); const canonicalType = type.toLowerCase(); @@ -122,6 +125,9 @@ export function citationTypeMeta(c: Citation): CitationTypeMeta { }; } +/** + * Choose the visual source marker for a citation: provider image, type icon, or monogram. + */ export function citationSourceVisual(c: Citation): CitationSourceVisual { const iconUrl = c.iconUrl?.trim(); if (iconUrl) { From aff4f98f45f1ab8e2a5c01da84ee1ddb888ebaeb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:20:29 +0000 Subject: [PATCH 5/6] chore(docs): regenerate api docs --- apps/website/content/docs/chat/api/api-docs.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index f2fabd236..832665b63 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -8202,7 +8202,7 @@ { "name": "citationSourceVisual", "kind": "function", - "description": "", + "description": "Choose the visual source marker for a citation: provider image, type icon, or monogram.", "signature": "citationSourceVisual(c: Citation): CitationSourceVisual", "params": [ { @@ -8240,7 +8240,7 @@ { "name": "citationTypeMeta", "kind": "function", - "description": "", + "description": "Derive normalized source-type metadata for badges, icons, and tone tokens.", "signature": "citationTypeMeta(c: Citation): CitationTypeMeta", "params": [ { From 4b43a2d4c65eae8b3a53c81ea636819a237af8c3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 00:22:14 -0700 Subject: [PATCH 6/6] chore: trigger citation visual CI