@elabs-ai/components-ai 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
package/src/image.tsx ADDED
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import { Skeleton, useLocale } from "@elabs-ai/components-ui";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import type { Experimental_GeneratedImage } from "ai";
6
+ import { ImageOff } from "lucide-react";
7
+ import type { ImgHTMLAttributes } from "react";
8
+ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
9
+
10
+ export type ImageProps = Experimental_GeneratedImage &
11
+ Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
12
+ /**
13
+ * Show a Skeleton placeholder (loading-states.md) until the img element
14
+ * finishes decoding, so the base64 payload does not pop in. Requires
15
+ * explicit `width`+`height` so the reserved box matches the final size
16
+ * (interaction-guidelines.md Images section — prevents CLS); without
17
+ * both there is no box to reserve, so the default is `false` in that
18
+ * case rather than mounting a skeleton that collapses to nothing.
19
+ * @default Boolean(width && height)
20
+ */
21
+ showSkeleton?: boolean;
22
+ };
23
+
24
+ /** `<img>` wrapper for an AI-SDK generated image, with a decode-aware skeleton. */
25
+ export const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
26
+ {
27
+ base64,
28
+ uint8Array: _uint8Array,
29
+ mediaType,
30
+ className,
31
+ showSkeleton,
32
+ onLoad,
33
+ onError,
34
+ width,
35
+ height,
36
+ alt,
37
+ ...props
38
+ },
39
+ forwardedRef,
40
+ ) {
41
+ const { t } = useLocale();
42
+ const shouldSkeleton = showSkeleton ?? Boolean(width && height);
43
+ const [loaded, setLoaded] = useState(false);
44
+ const [failed, setFailed] = useState(false);
45
+ const imgRef = useRef<HTMLImageElement>(null);
46
+ useImperativeHandle(forwardedRef, () => imgRef.current as HTMLImageElement);
47
+
48
+ const pending = shouldSkeleton && !loaded && !failed;
49
+
50
+ // The live region must exist BEFORE its text appears (ARIA22), so it is
51
+ // always mounted (while a skeleton can render at all) and its label is set
52
+ // one tick later via this effect.
53
+ const [announce, setAnnounce] = useState(false);
54
+ useEffect(() => setAnnounce(pending), [pending]);
55
+
56
+ // A cached / instant data-URI decode can finish before React attaches
57
+ // `onLoad` — read `complete` once mounted (gallery.tsx's GalleryImg has the
58
+ // same guard).
59
+ useEffect(() => {
60
+ if (imgRef.current?.complete) setLoaded(true);
61
+ }, []);
62
+
63
+ if (failed) {
64
+ return (
65
+ <span
66
+ className={cn(
67
+ "flex flex-col items-center justify-center gap-1 rounded-md bg-muted text-muted-foreground",
68
+ className,
69
+ )}
70
+ style={{ height, width }}
71
+ {...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true })}
72
+ >
73
+ <ImageOff className="size-6" aria-hidden="true" />
74
+ </span>
75
+ );
76
+ }
77
+
78
+ const img = (
79
+ <img
80
+ {...props}
81
+ ref={imgRef}
82
+ alt={alt ?? ""}
83
+ height={height}
84
+ width={width}
85
+ className={cn("h-auto max-w-full overflow-hidden rounded-md", className)}
86
+ src={`data:${mediaType};base64,${base64}`}
87
+ onLoad={(event) => {
88
+ setLoaded(true);
89
+ onLoad?.(event);
90
+ }}
91
+ onError={(event) => {
92
+ setFailed(true);
93
+ onError?.(event);
94
+ }}
95
+ />
96
+ );
97
+
98
+ // No skeleton to reserve a box for — render the bare <img> exactly as
99
+ // before this feature landed (a `className` like "w-full" keeps resolving
100
+ // against the real parent, not a shrink-to-fit wrapper), and mount no
101
+ // (permanently empty) live region.
102
+ if (!shouldSkeleton) return img;
103
+
104
+ return (
105
+ <span className="relative inline-block max-w-full" style={{ height, width }}>
106
+ <span className="sr-only" role="status" aria-live="polite">
107
+ {announce ? t("loading") : ""}
108
+ </span>
109
+ {pending ? <Skeleton className="absolute inset-0 size-full rounded-md" /> : null}
110
+ {img}
111
+ </span>
112
+ );
113
+ });
114
+
115
+ Image.displayName = "Image";
package/src/index.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @elabs-ai/components-ai — AI Elements ported onto the brand-ui design system.
3
+ *
4
+ * Components are presentational and render the Vercel AI SDK's `UIMessage`
5
+ * data model; the consuming app still owns model calls (e.g. `useChat`).
6
+ *
7
+ * ATTRIBUTION. Vendored from AI Elements (https://github.com/vercel/ai-elements,
8
+ * https://elements.ai-sdk.dev):
9
+ *
10
+ * Copyright 2023 Vercel, Inc.
11
+ * Licensed under the Apache License, Version 2.0.
12
+ * https://www.apache.org/licenses/LICENSE-2.0
13
+ *
14
+ * MODIFICATIONS: these files have been changed from the originals — rewired to
15
+ * import primitives from `@elabs-ai/components-ui` and tokens from
16
+ * `@elabs-ai/components-tokens`, re-tokenized, and extended with surfaces that do
17
+ * not exist upstream. Stating that is an Apache-2.0 §4(b) obligation, not a
18
+ * courtesy — this is the notice that discharges it for the whole package.
19
+ *
20
+ * See ATTRIBUTION.md and .claude/rules/attribution.md.
21
+ */
22
+ export * from "./agent";
23
+ export * from "./agent-timeline";
24
+ export * from "./artifact";
25
+ export * from "./asset-preview";
26
+ export * from "./attachments";
27
+ export * from "./audio-player";
28
+ export * from "./canvas";
29
+ export * from "./chain-of-thought";
30
+ export * from "./chat-greeting";
31
+ export * from "./checkpoint";
32
+ export * from "./code-block";
33
+ export * from "./commit";
34
+ export * from "./composer";
35
+ export * from "./confirmation";
36
+ export * from "./connection";
37
+ export * from "./context";
38
+ export * from "./context-panel";
39
+ export * from "./controls";
40
+ export * from "./conversation";
41
+ export * from "./edge";
42
+ export * from "./environment-variables";
43
+ export * from "./file-tree";
44
+ export * from "./gallery";
45
+ export * from "./grouped-parts";
46
+ export * from "./image";
47
+ export * from "./inline-citation";
48
+ export * from "./interactive-terminal";
49
+ export * from "./jsx-preview";
50
+ // Mermaid loads on first diagram render, not in the entry chunk. `preloadMermaid`
51
+ // warms it early when a surface is known to render diagrams.
52
+ export { createLazyMermaidPlugin, lazyMermaid, preloadMermaid } from "./_lazy-mermaid";
53
+ export * from "./markdown-view";
54
+ export * from "./message";
55
+ export * from "./message-edit";
56
+ export * from "./message-feedback";
57
+ export * from "./message-form";
58
+ export * from "./message-form-spec";
59
+ export * from "./message-table";
60
+ export * from "./message-table-spec";
61
+ export * from "./mic-selector";
62
+ export * from "./model-selector";
63
+ export * from "./motion-config";
64
+ export * from "./node";
65
+ export * from "./open-in-chat";
66
+ export * from "./package-info";
67
+ export * from "./panel";
68
+ export * from "./part-groups";
69
+ export * from "./persona";
70
+ export * from "./plan";
71
+ export * from "./prompt-input";
72
+ export * from "./queue";
73
+ export * from "./reasoning";
74
+ export * from "./sandbox";
75
+ export * from "./schema-display";
76
+ export * from "./selection-toolbar";
77
+ export * from "./shimmer";
78
+ export * from "./snippet";
79
+ export * from "./sources";
80
+ export * from "./speech-input";
81
+ export * from "./stack-trace";
82
+ export * from "./suggestion";
83
+ export * from "./task";
84
+ export * from "./terminal";
85
+ export * from "./test-results";
86
+ export * from "./tool";
87
+ export * from "./tool-result-card";
88
+ export * from "./toolbar";
89
+ export * from "./transcription";
90
+ export * from "./voice-selector";
91
+ export * from "./web-preview";
92
+
93
+ export * from "./chat-shell";
@@ -0,0 +1,91 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import {
3
+ EvidenceChip,
4
+ InlineCitation,
5
+ InlineCitationCard,
6
+ InlineCitationCardBody,
7
+ InlineCitationCardTrigger,
8
+ InlineCitationText,
9
+ } from "./inline-citation";
10
+ const meta = {
11
+ title: "AI/InlineCitation",
12
+ component: InlineCitation,
13
+ parameters: { layout: "padded" },
14
+ } satisfies Meta<typeof InlineCitation>;
15
+ export default meta;
16
+ type Story = StoryObj<typeof meta>;
17
+ export const Default: Story = {
18
+ render: () => (
19
+ <p className="max-w-prose text-body">
20
+ The nightly deploy succeeded
21
+ <InlineCitation>
22
+ <InlineCitationText> on the main branch</InlineCitationText>
23
+ <InlineCitationCard>
24
+ <InlineCitationCardTrigger sources={["https://ci.acme.com/run/42"]} />
25
+ <InlineCitationCardBody>
26
+ <div className="p-3 text-meta text-muted-foreground">
27
+ CI run #42 — all checks passed.
28
+ </div>
29
+ </InlineCitationCardBody>
30
+ </InlineCitationCard>
31
+ </InlineCitation>
32
+ .
33
+ </p>
34
+ ),
35
+ };
36
+ /**
37
+ * Sources that are not URLs — the enterprise case.
38
+ *
39
+ * A warehouse table, a document id or a filing has no hostname to show, so
40
+ * `sources` accepts an opaque string or a `{ id, label, url }` object. A bare
41
+ * non-URL string renders verbatim; `label` wins over a hostname. Nothing here
42
+ * has to synthesize a fake URL to satisfy the chip.
43
+ */
44
+ export const NonUrlSources: Story = {
45
+ render: () => (
46
+ <p className="max-w-prose text-body">
47
+ Q3 bookings finished 4% ahead of plan
48
+ <InlineCitation>
49
+ <InlineCitationText> in the EMEA segment</InlineCitationText>
50
+ <InlineCitationCard>
51
+ <EvidenceChip
52
+ sources={[
53
+ { id: "warehouse.public.bookings_q3", label: "Bookings (Q3 snapshot)" },
54
+ "Q3 FY25 Board Deck.pdf",
55
+ { id: "doc-4821" },
56
+ ]}
57
+ />
58
+ <InlineCitationCardBody>
59
+ <div className="p-3 text-meta text-muted-foreground">
60
+ Internal warehouse table + the Q3 board deck. Neither has a URL.
61
+ </div>
62
+ </InlineCitationCardBody>
63
+ </InlineCitationCard>
64
+ </InlineCitation>
65
+ .
66
+ </p>
67
+ ),
68
+ };
69
+
70
+ // EvidenceChip — the named green "grounded" front door (#191, research 11 §B.4).
71
+ export const Evidence: Story = {
72
+ render: () => (
73
+ <p className="max-w-prose text-body">
74
+ Churn improved 0.4pp quarter-over-quarter
75
+ <InlineCitation>
76
+ <InlineCitationText> across enterprise accounts</InlineCitationText>
77
+ <InlineCitationCard>
78
+ <EvidenceChip
79
+ sources={["https://warehouse.acme.com/q3-retention", "https://crm.acme.com/cohorts"]}
80
+ />
81
+ <InlineCitationCardBody>
82
+ <div className="p-3 text-meta text-muted-foreground">
83
+ Q3 retention cohort, refreshed nightly.
84
+ </div>
85
+ </InlineCitationCardBody>
86
+ </InlineCitationCard>
87
+ </InlineCitation>
88
+ .
89
+ </p>
90
+ ),
91
+ };
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+
4
+ import {
5
+ EvidenceChip,
6
+ InlineCitationCard,
7
+ InlineCitationCardTrigger,
8
+ inlineCitationSourceLabel,
9
+ type InlineCitationSourceRef,
10
+ } from "./inline-citation";
11
+
12
+ /**
13
+ * `InlineCitationCardTrigger` renders a `HoverCardTrigger`, which Radix throws
14
+ * on outside a `HoverCard` — every case must be wrapped.
15
+ */
16
+ const renderTrigger = (sources: InlineCitationSourceRef[]) =>
17
+ render(
18
+ <InlineCitationCard>
19
+ <InlineCitationCardTrigger sources={sources} />
20
+ </InlineCitationCard>,
21
+ );
22
+
23
+ describe("InlineCitationCardTrigger — source labels", () => {
24
+ it("shows the hostname for a URL source", () => {
25
+ renderTrigger(["https://ci.acme.com/run/42"]);
26
+ expect(screen.getByText(/ci\.acme\.com/)).toBeInTheDocument();
27
+ });
28
+
29
+ it("renders a NON-URL source verbatim instead of throwing", () => {
30
+ // The P0: `new URL("Q3 filing, FY25")` throws TypeError, and with no error
31
+ // boundary in @elabs-ai/components-ai that took down the entire message subtree. An agent
32
+ // citing a document id or a table name must not have to fake a URL.
33
+ expect(() => renderTrigger(["Q3 filing, FY25"])).not.toThrow();
34
+ expect(screen.getByText(/Q3 filing, FY25/)).toBeInTheDocument();
35
+ });
36
+
37
+ it.each([
38
+ ["a bare document id", "doc-4821"],
39
+ ["a scheme-less host", "ci.acme.com/run/42"],
40
+ ["a root-relative path", "/reports/q3.pdf"],
41
+ ["a human title with punctuation", "Internal Wiki › Pricing"],
42
+ ["a filename", "Q3 Revenue Report.pdf"],
43
+ ])("does not throw on %s", (_label, source) => {
44
+ expect(() => renderTrigger([source])).not.toThrow();
45
+ });
46
+
47
+ it("prefers an explicit label over the URL hostname", () => {
48
+ renderTrigger([{ label: "Q3 Revenue Report", url: "https://docs.acme.com/q3" }]);
49
+ expect(screen.getByText(/Q3 Revenue Report/)).toBeInTheDocument();
50
+ expect(screen.queryByText(/docs\.acme\.com/)).not.toBeInTheDocument();
51
+ });
52
+
53
+ it("falls back to the hostname when an object has only a url", () => {
54
+ renderTrigger([{ url: "https://docs.acme.com/q3" }]);
55
+ expect(screen.getByText(/docs\.acme\.com/)).toBeInTheDocument();
56
+ });
57
+
58
+ it("falls back to the opaque id when an object has neither label nor url", () => {
59
+ renderTrigger([{ id: "warehouse.public.orders" }]);
60
+ expect(screen.getByText(/warehouse\.public\.orders/)).toBeInTheDocument();
61
+ });
62
+
63
+ it("counts the remaining sources", () => {
64
+ renderTrigger(["https://a.example.com", "doc-2", { id: "doc-3" }]);
65
+ expect(screen.getByText(/\+2/)).toBeInTheDocument();
66
+ });
67
+
68
+ it("renders 'unknown' for an empty source list", () => {
69
+ renderTrigger([]);
70
+ expect(screen.getByText("unknown")).toBeInTheDocument();
71
+ });
72
+
73
+ it("EvidenceChip inherits the same tolerance (it is an alias)", () => {
74
+ expect(() =>
75
+ render(
76
+ <InlineCitationCard>
77
+ <EvidenceChip sources={["internal-doc-4821"]} />
78
+ </InlineCitationCard>,
79
+ ),
80
+ ).not.toThrow();
81
+ expect(screen.getByText(/internal-doc-4821/)).toBeInTheDocument();
82
+ });
83
+ });
84
+
85
+ describe("inlineCitationSourceLabel", () => {
86
+ it.each<[InlineCitationSourceRef, string]>([
87
+ ["https://ci.acme.com/run/42", "ci.acme.com"],
88
+ ["doc-4821", "doc-4821"],
89
+ [{ label: "Pricing wiki", url: "https://w.acme.com" }, "Pricing wiki"],
90
+ [{ url: "https://w.acme.com/x" }, "w.acme.com"],
91
+ [{ id: "orders" }, "orders"],
92
+ [{}, "unknown"],
93
+ ])("%o → %s", (source, expected) => {
94
+ expect(inlineCitationSourceLabel(source)).toBe(expected);
95
+ });
96
+ });