@townco/ui 0.1.109 → 0.1.111

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.
@@ -99,13 +99,49 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
99
99
  const todos = useChatStore(selectTodosForCurrentSession);
100
100
  const _latestContextSize = useChatStore((state) => state.latestContextSize);
101
101
  const { resolvedTheme, setTheme } = useTheme();
102
+ const citationUsageBySourceId = React.useMemo(() => {
103
+ const map = new Map();
104
+ // Match both [[N]] and [N] (while avoiding markdown links/refs), mirroring remark-citations.
105
+ const citationRegex = /\[\[(\d+)\]\]|\[(\d+)\](?![:(])/g;
106
+ const stripCode = (markdown) => markdown
107
+ // fenced code blocks
108
+ .replace(/```[\s\S]*?```/g, "")
109
+ // inline code
110
+ .replace(/`[^`]*`/g, "");
111
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
112
+ const message = messages[messageIndex];
113
+ if (!message?.content)
114
+ continue;
115
+ const content = stripCode(message.content);
116
+ citationRegex.lastIndex = 0;
117
+ let match;
118
+ let citationIndex = 0; // global citation index within THIS message
119
+ // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex exec loop pattern
120
+ while ((match = citationRegex.exec(content)) !== null) {
121
+ const sourceId = match[1] ?? match[2];
122
+ if (!sourceId)
123
+ continue;
124
+ const existing = map.get(sourceId) ?? [];
125
+ existing.push({
126
+ messageId: message.id,
127
+ messageIndex,
128
+ citationIndex,
129
+ });
130
+ map.set(sourceId, existing);
131
+ citationIndex++;
132
+ }
133
+ }
134
+ return map;
135
+ }, [messages]);
102
136
  // Collect all sources from all messages for the sources panel
103
137
  const allSources = React.useMemo(() => {
104
- const sources = [];
138
+ const byId = new Map();
105
139
  for (const message of messages) {
106
140
  if (message.sources) {
107
141
  for (const source of message.sources) {
108
- sources.push({
142
+ if (byId.has(source.id))
143
+ continue;
144
+ byId.set(source.id, {
109
145
  id: source.id,
110
146
  title: source.title,
111
147
  url: source.url,
@@ -116,8 +152,15 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
116
152
  }
117
153
  }
118
154
  }
119
- return sources;
120
- }, [messages]);
155
+ // Attach citation usage info for the Sources sidebar.
156
+ for (const [sourceId, usedIn] of citationUsageBySourceId.entries()) {
157
+ const existing = byId.get(sourceId);
158
+ if (existing) {
159
+ existing.usedIn = usedIn;
160
+ }
161
+ }
162
+ return Array.from(byId.values());
163
+ }, [messages, citationUsageBySourceId]);
121
164
  // Update document title with agent name and animated dots when streaming
122
165
  useDocumentTitle(agentName);
123
166
  // Initialize browser logger to capture console output and send to server
@@ -0,0 +1,9 @@
1
+ import type { ToolCall as ToolCallType } from "../../core/schemas/tool-call.js";
2
+ export interface InvokingGroupProps {
3
+ toolCalls: ToolCallType[];
4
+ }
5
+ /**
6
+ * InvokingGroup component - displays a group of preliminary (invoking) tool calls
7
+ * Shows as "Invoking parallel operation (N)" with a summary of unique tool names
8
+ */
9
+ export declare function InvokingGroup({ toolCalls }: InvokingGroupProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ListVideo } from "lucide-react";
3
+ import React from "react";
4
+ /**
5
+ * InvokingGroup component - displays a group of preliminary (invoking) tool calls
6
+ * Shows as "Invoking parallel operation (N)" with a summary of unique tool names
7
+ */
8
+ export function InvokingGroup({ toolCalls }) {
9
+ // Get unique display names for the summary
10
+ const displayNames = toolCalls.map((tc) => tc.prettyName || tc.title);
11
+ const uniqueNames = [...new Set(displayNames)];
12
+ const summary = uniqueNames.length <= 2
13
+ ? uniqueNames.join(", ")
14
+ : `${uniqueNames.slice(0, 2).join(", ")} +${uniqueNames.length - 2} more`;
15
+ return (_jsxs("div", { className: "flex flex-col my-4", children: [_jsxs("div", { className: "flex items-center gap-1.5 text-paragraph-sm text-muted-foreground/50", children: [_jsx(ListVideo, { className: "h-3 w-3" }), _jsx("span", { children: "Invoking parallel operation" }), _jsx("span", { className: "text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground/50", children: toolCalls.length })] }), _jsx("span", { className: "text-paragraph-sm text-muted-foreground/50 pl-4.5", children: summary })] }));
16
+ }
@@ -100,7 +100,7 @@ export const Message = React.forwardRef(({ message, role: roleProp, layout, clas
100
100
  }
101
101
  return undefined;
102
102
  }, [role, autoScroll]);
103
- return (_jsx("article", { ref: messageRef, "aria-label": `${role} message`, "data-message-id": messageId, className: cn(messageVariants({ role, layout }), className), style: {
103
+ return (_jsx("article", { ref: messageRef, id: messageId ? `message-${messageId}` : undefined, "aria-label": `${role} message`, "data-message-id": messageId, className: cn(messageVariants({ role, layout }), className), style: {
104
104
  minHeight: minHeight !== undefined ? `${minHeight}px` : undefined,
105
105
  }, ...props, children: children }));
106
106
  });
@@ -31,10 +31,31 @@ const messageContentVariants = cva("w-full rounded-2xl text-[var(--font-size)] f
31
31
  variant: "default",
32
32
  },
33
33
  });
34
+ // Match both [[N]] and [N] (while avoiding markdown links/refs), mirroring `remark-citations`.
35
+ const CITATION_REGEX = /\[\[(\d+)\]\]|\[(\d+)\](?![:(])/g;
36
+ function countCitations(text) {
37
+ CITATION_REGEX.lastIndex = 0;
38
+ let count = 0;
39
+ while (CITATION_REGEX.exec(text) !== null)
40
+ count++;
41
+ return count;
42
+ }
34
43
  export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStreaming: isStreamingProp, message, thinkingDisplayStyle = "collapsible", className, children, ...props }, ref) => {
35
44
  // Get streaming start time and current model from store
36
45
  const streamingStartTime = useChatStore((state) => state.streamingStartTime);
37
46
  const _currentModel = useChatStore((state) => state.currentModel);
47
+ const sessionSources = useChatStore((state) => state.sources);
48
+ const sourcesForResponse = React.useMemo(() => {
49
+ // Merge/dedupe global + message sources by id so inline citations can resolve after reload.
50
+ const byId = new Map();
51
+ for (const s of sessionSources)
52
+ byId.set(s.id, s);
53
+ if (message?.sources) {
54
+ for (const s of message.sources)
55
+ byId.set(s.id, s);
56
+ }
57
+ return Array.from(byId.values());
58
+ }, [sessionSources, message?.sources]);
38
59
  // Use smart rendering if message is provided and no custom children
39
60
  const useSmartRendering = message && !children;
40
61
  // Derive props from message if using smart rendering
@@ -189,18 +210,20 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
189
210
  const hasHookPositions = hookNotifications.some((n) => n.contentPosition !== undefined);
190
211
  if (!hasHookPositions) {
191
212
  // No positions - render hooks at top, then content, then tool calls
192
- return (_jsxs(_Fragment, { children: [hookNotifications.length > 0 && (_jsx("div", { className: "flex flex-col gap-2 mb-1", children: hookNotifications.map((notification) => (_jsx(HookNotification, { notification: notification }, notification.id))) })), _jsx("div", { children: _jsx(Response, { content: message.content, isStreaming: message.isStreaming, showEmpty: false, sources: message.sources ?? [] }) }), groupedToolCalls.length > 0 && (_jsx("div", { className: "flex flex-col gap-2 mb-1", children: groupedToolCalls.map((item, index) => renderToolCallOrGroup(item, index)) })), shouldShowThinkingIndicator && (_jsx("div", { className: "flex flex-col my-4 rounded-md px-1 -mx-1 w-fit", children: _jsx("div", { className: "flex items-center gap-1.5", children: _jsx("span", { className: "text-paragraph-sm text-text-secondary/70", children: "Thinking..." }) }) }))] }));
213
+ return (_jsxs(_Fragment, { children: [hookNotifications.length > 0 && (_jsx("div", { className: "flex flex-col gap-2 mb-1", children: hookNotifications.map((notification) => (_jsx(HookNotification, { notification: notification }, notification.id))) })), _jsx("div", { children: _jsx(Response, { content: message.content, isStreaming: message.isStreaming, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: 0 }) }), groupedToolCalls.length > 0 && (_jsx("div", { className: "flex flex-col gap-2 mb-1", children: groupedToolCalls.map((item, index) => renderToolCallOrGroup(item, index)) })), shouldShowThinkingIndicator && (_jsx("div", { className: "flex flex-col my-4 rounded-md px-1 -mx-1 w-fit", children: _jsx("div", { className: "flex items-center gap-1.5", children: _jsx("span", { className: "text-paragraph-sm text-text-secondary/70", children: "Thinking..." }) }) }))] }));
193
214
  }
194
215
  // Hooks have positions - render them inline with content
195
216
  const elements = [];
196
217
  let currentPosition = 0;
218
+ let citationOffset = 0;
197
219
  hookNotifications.forEach((notification) => {
198
220
  const position = notification.contentPosition ?? message.content.length;
199
221
  // Add text before this hook notification
200
222
  if (position > currentPosition) {
201
223
  const textChunk = message.content.slice(currentPosition, position);
202
224
  if (textChunk) {
203
- elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false, sources: message.sources ?? [] }) }, `text-before-hook-${notification.id}`));
225
+ elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, `text-before-hook-${notification.id}`));
226
+ citationOffset += countCitations(textChunk);
204
227
  }
205
228
  }
206
229
  // Add hook notification
@@ -211,7 +234,8 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
211
234
  if (currentPosition < message.content.length) {
212
235
  const remainingText = message.content.slice(currentPosition);
213
236
  if (remainingText) {
214
- elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false, sources: message.sources ?? [] }) }, "text-end-hooks"));
237
+ elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, "text-end-hooks"));
238
+ citationOffset += countCitations(remainingText);
215
239
  }
216
240
  }
217
241
  // Add tool calls at the end
@@ -247,6 +271,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
247
271
  // Sort by position
248
272
  positionedItems.sort((a, b) => a.position - b.position);
249
273
  let currentPosition = 0;
274
+ let citationOffset = 0;
250
275
  let currentBatch = [];
251
276
  let currentBatchId;
252
277
  let currentBatchTitle;
@@ -278,7 +303,8 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
278
303
  const itemId = positionedItem.type === "toolCall"
279
304
  ? positionedItem.item.id
280
305
  : positionedItem.item.id;
281
- elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false, sources: message.sources ?? [] }) }, `text-before-${itemId}`));
306
+ elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, `text-before-${itemId}`));
307
+ citationOffset += countCitations(textChunk);
282
308
  }
283
309
  }
284
310
  if (positionedItem.type === "hookNotification") {
@@ -332,7 +358,8 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
332
358
  if (currentPosition < message.content.length) {
333
359
  const remainingText = message.content.slice(currentPosition);
334
360
  if (remainingText) {
335
- elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false, sources: message.sources ?? [] }) }, "text-end"));
361
+ elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, "text-end"));
362
+ citationOffset += countCitations(remainingText);
336
363
  }
337
364
  }
338
365
  // Render preliminary (selecting) tool calls at the end, grouped
@@ -15,5 +15,16 @@ export interface ResponseProps extends React.HTMLAttributes<HTMLDivElement> {
15
15
  emptyMessage?: string;
16
16
  /** Citation sources for rendering inline citations */
17
17
  sources?: Source[];
18
+ /**
19
+ * Optional message id. If provided, we add stable per-citation anchors so the
20
+ * Sources sidebar can scroll to the exact cited spot.
21
+ */
22
+ messageId?: string;
23
+ /**
24
+ * When a message is rendered in multiple `Response` chunks (due to tool calls/hooks),
25
+ * citations need a global index across the full message. This is the starting index
26
+ * for citations rendered by THIS chunk.
27
+ */
28
+ citationIndexOffset?: number;
18
29
  }
19
30
  export declare const Response: React.NamedExoticComponent<ResponseProps & React.RefAttributes<HTMLDivElement>>;
@@ -1,35 +1,76 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createLogger } from "@townco/core";
3
4
  import * as React from "react";
4
5
  import remarkGfm from "remark-gfm";
5
6
  import { Streamdown } from "streamdown";
6
7
  import { remarkCitations } from "../lib/remark-citations.js";
7
8
  import { cn } from "../lib/utils.js";
8
9
  import { CitationChip } from "./CitationChip.js";
9
- export const Response = React.memo(React.forwardRef(({ content, isStreaming = false, showEmpty = true, emptyMessage = "", sources = [], className, ...props }, ref) => {
10
+ const logger = createLogger("Response", "debug");
11
+ export const Response = React.memo(React.forwardRef(({ content, isStreaming = false, showEmpty = true, emptyMessage = "", sources = [], messageId, citationIndexOffset = 0, className, ...props }, ref) => {
12
+ // State to force remount when sources change
13
+ const [remountKey, setRemountKey] = React.useState(0);
14
+ // Log every render to track sources changes
15
+ logger.info("Response render", {
16
+ contentLength: content.length,
17
+ contentPreview: content.slice(0, 100),
18
+ sourcesCount: sources.length,
19
+ sourceIds: sources.map((s) => s.id),
20
+ remountKey,
21
+ });
22
+ // Increment remount key when sources change to force Streamdown remount
23
+ React.useEffect(() => {
24
+ setRemountKey((prev) => {
25
+ logger.info("Sources changed, incrementing remountKey", {
26
+ sourcesCount: sources.length,
27
+ sourceIds: sources.map((s) => s.id),
28
+ oldRemountKey: prev,
29
+ });
30
+ return prev + 1;
31
+ });
32
+ }, [sources]);
10
33
  // Memoize the remark plugins array to prevent re-renders
11
34
  // remarkGfm adds support for tables, strikethrough, autolinks, task lists
12
35
  const remarkPlugins = React.useMemo(() => [remarkGfm, remarkCitations], []);
13
36
  // Memoize the custom components to prevent re-renders
14
37
  // We use 'as Record<string, unknown>' because Streamdown's Components type
15
38
  // doesn't include all the props we need
16
- const customComponents = React.useMemo(() => ({
17
- // Custom span component that intercepts citation markers
18
- // The remark-citations plugin creates spans with class "citation-marker"
19
- // and data-citation-id attribute
20
- // Note: Streamdown may pass 'class' instead of 'className'
21
- span: (spanProps) => {
22
- const { className, class: classAttr, "data-citation-id": citationId, children, ...restProps } = spanProps;
23
- // Check both className and class (Streamdown may use either)
24
- const cssClass = className || classAttr;
25
- // Check if this span is a citation marker
26
- if (cssClass === "citation-marker" && citationId) {
27
- return _jsx(CitationChip, { sourceId: citationId, sources: sources });
28
- }
29
- // Otherwise render as normal span
30
- return (_jsx("span", { className: cssClass, ...restProps, children: children }));
31
- },
32
- }), [sources]);
39
+ const customComponents = React.useMemo(() => {
40
+ // Each `Response` instance renders citations in-order. We assign anchors using a
41
+ // running local index + the provided chunk offset.
42
+ const citationCounter = { current: 0 };
43
+ return {
44
+ // Custom span component that intercepts citation markers
45
+ // The remark-citations plugin creates spans with class "citation-marker"
46
+ // and data-citation-id attribute
47
+ // Note: Streamdown may pass 'class' instead of 'className'
48
+ span: (spanProps) => {
49
+ const { className, class: classAttr, "data-citation-id": citationId, children, ...restProps } = spanProps;
50
+ // Check both className and class (Streamdown may use either)
51
+ const cssClass = className || classAttr;
52
+ // Check if this span is a citation marker
53
+ if (cssClass === "citation-marker" && citationId) {
54
+ const globalCitationIndex = citationIndexOffset + citationCounter.current++;
55
+ logger.info("Rendering citation marker", {
56
+ citationId,
57
+ sourcesAvailable: sources.length,
58
+ sourceIds: sources.map((s) => s.id),
59
+ foundSource: sources.find((s) => s.id === citationId)
60
+ ? "yes"
61
+ : "no",
62
+ });
63
+ // Anchor wrapper (when messageId is provided)
64
+ if (messageId) {
65
+ return (_jsx("span", { id: `citation-${messageId}-${globalCitationIndex}`, "data-citation-source-id": citationId, "data-citation-anchor": "true", className: "inline-flex rounded-md", children: _jsx(CitationChip, { sourceId: citationId, sources: sources }) }));
66
+ }
67
+ return _jsx(CitationChip, { sourceId: citationId, sources: sources });
68
+ }
69
+ // Otherwise render as normal span
70
+ return (_jsx("span", { className: cssClass, ...restProps, children: children }));
71
+ },
72
+ };
73
+ }, [sources, messageId, citationIndexOffset]);
33
74
  // Show empty state during streaming if no content yet
34
75
  if (!content && isStreaming && showEmpty) {
35
76
  return (_jsx("div", { ref: ref, className: cn("opacity-70 italic text-paragraph-sm", className), ...props, children: emptyMessage }));
@@ -37,7 +78,7 @@ export const Response = React.memo(React.forwardRef(({ content, isStreaming = fa
37
78
  if (!content) {
38
79
  return null;
39
80
  }
40
- return (_jsx("div", { ref: ref, ...props, children: _jsx(Streamdown, { className: cn("size-full", "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "[&_code]:whitespace-pre-wrap [&_code]:break-words", "[&_pre]:max-w-full [&_pre]:overflow-x-auto", className), remarkPlugins: remarkPlugins, components: customComponents, children: content }) }));
81
+ return (_jsx("div", { ref: ref, ...props, children: _jsx(Streamdown, { className: cn("size-full", "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "[&_code]:whitespace-pre-wrap [&_code]:break-words", "[&_pre]:max-w-full [&_pre]:overflow-x-auto", className), remarkPlugins: remarkPlugins, components: customComponents, children: content }, `streamdown-${remountKey}`) }));
41
82
  }), (prevProps, nextProps) => prevProps.content === nextProps.content &&
42
83
  prevProps.sources === nextProps.sources);
43
84
  Response.displayName = "Response";
@@ -7,6 +7,16 @@ export interface SourceItem {
7
7
  sourceName: string;
8
8
  favicon?: string;
9
9
  timestamp?: string;
10
+ /**
11
+ * Where this source is cited in the transcript.
12
+ * `messageIndex` is 0-based within the current rendered message list.
13
+ */
14
+ usedIn?: Array<{
15
+ messageId: string;
16
+ messageIndex: number;
17
+ /** 0-based citation occurrence index within the message */
18
+ citationIndex: number;
19
+ }>;
10
20
  }
11
21
  export interface SourceListItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
12
22
  source: SourceItem;
@@ -2,6 +2,38 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cn } from "../lib/utils.js";
4
4
  export const SourceListItem = React.forwardRef(({ source, isSelected, className, ...props }, ref) => {
5
+ const usedIn = source.usedIn ?? [];
6
+ const highlightCitation = React.useCallback((el) => {
7
+ // Clear any previous highlight
8
+ document
9
+ .querySelectorAll("[data-citation-highlight='true']")
10
+ .forEach((n) => {
11
+ n.removeAttribute("data-citation-highlight");
12
+ n.classList.remove("ring-2", "ring-ring", "ring-offset-2", "ring-offset-background");
13
+ });
14
+ el.setAttribute("data-citation-highlight", "true");
15
+ el.classList.add("ring-2", "ring-ring", "ring-offset-2", "ring-offset-background");
16
+ window.setTimeout(() => {
17
+ // Only remove if it's still the highlighted element.
18
+ if (el.getAttribute("data-citation-highlight") === "true") {
19
+ el.removeAttribute("data-citation-highlight");
20
+ el.classList.remove("ring-2", "ring-ring", "ring-offset-2", "ring-offset-background");
21
+ }
22
+ }, 1400);
23
+ }, []);
24
+ const scrollToCitationOrdinal = React.useCallback((sourceId, ordinal) => {
25
+ const nodes = Array.from(document.querySelectorAll(`[data-citation-anchor="true"][data-citation-source-id="${CSS.escape(sourceId)}"]`));
26
+ const el = nodes[ordinal];
27
+ if (!el)
28
+ return false;
29
+ el.scrollIntoView({
30
+ behavior: "smooth",
31
+ block: "center",
32
+ inline: "nearest",
33
+ });
34
+ window.setTimeout(() => highlightCitation(el), 150);
35
+ return true;
36
+ }, [highlightCitation]);
5
37
  return (_jsxs("button", { ref: ref, type: "button", className: cn(
6
38
  // Base styles matching FileSystemItem
7
39
  "group flex w-full text-left gap-2 items-start p-2 rounded-md cursor-pointer transition-colors text-paragraph-sm",
@@ -10,12 +42,28 @@ export const SourceListItem = React.forwardRef(({ source, isSelected, className,
10
42
  // Focus state - matching FileSystemItem
11
43
  "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-border-dark",
12
44
  // Selected state - matching FileSystemItem
13
- isSelected && "bg-accent", className), onClick: () => window.open(source.url, "_blank"), ...props, children: [
14
- _jsx("div", { className: "shrink-0 flex items-center h-5", children: _jsx("div", { className: "relative rounded-[3px] size-4 overflow-hidden bg-muted", children: source.favicon ? (_jsx("img", { alt: source.sourceName, className: "size-full object-cover", src: source.favicon })) : (_jsx("div", { className: "size-full bg-muted" })) }) }), _jsxs("div", { className: "flex flex-1 flex-col gap-1 min-w-0", children: [
45
+ isSelected && "bg-accent", className), onClick: () => {
46
+ // Always open the source URL. (Scrolling is handled via the usage pills.)
47
+ window.open(source.url, "_blank", "noopener,noreferrer");
48
+ }, ...props, children: [
49
+ _jsx("div", { className: "shrink-0 flex items-center h-5", children: _jsx("div", { className: "relative rounded-[3px] size-4 overflow-hidden bg-muted", children: source.favicon ? (_jsx("img", { alt: source.sourceName, className: "size-full object-cover", src: source.favicon })) : (_jsx("div", { className: "size-full bg-muted" })) }) }), _jsxs("div", { className: "flex flex-1 flex-col gap-0.5 min-w-0", children: [
15
50
  _jsxs("div", { className: "text-paragraph-sm text-foreground truncate", children: [
16
51
  _jsx("span", { className: "font-medium", children: source.sourceName }), _jsxs("span", { className: "text-muted-foreground", children: [" \u00B7 ", source.title] })
17
- ] }), _jsx("p", { className: "text-paragraph-sm text-muted-foreground line-clamp-3 break-all", children: source.snippet })
18
- ] })
52
+ ] }), usedIn.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5 pt-1", children: [
53
+ _jsx("span", { className: "text-xs text-muted-foreground", children: "Referenced" }), Array.from({ length: Math.min(usedIn.length, 12) }, (_, idx) => idx).map((idx) => (
54
+ // Not a <button> (nested buttons are invalid); we emulate button semantics.
55
+ // biome-ignore lint/a11y/useSemanticElements: spans are required to avoid nested buttons
56
+ _jsx("span", { role: "button", tabIndex: 0, className: cn("px-1.5 py-0.5 rounded-md", "text-[10px] leading-none font-medium", "bg-muted text-muted-foreground", "hover:bg-accent hover:text-foreground transition-colors", "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-border-dark", "cursor-pointer select-none"), onClick: (e) => {
57
+ e.preventDefault();
58
+ e.stopPropagation();
59
+ scrollToCitationOrdinal(source.id, idx);
60
+ }, onKeyDown: (e) => {
61
+ if (e.key === "Enter" || e.key === " ") {
62
+ e.preventDefault();
63
+ e.stopPropagation();
64
+ scrollToCitationOrdinal(source.id, idx);
65
+ }
66
+ }, "aria-label": `Scroll to citation ${idx + 1}`, children: idx + 1 }, `${source.id}-use-${idx}`))), usedIn.length > 12 && (_jsxs("span", { className: "text-xs text-muted-foreground", children: ["+", usedIn.length - 12, " more"] }))] }))] })
19
67
  ] }));
20
68
  });
21
69
  SourceListItem.displayName = "SourceListItem";
@@ -0,0 +1,8 @@
1
+ import type { ToolCall as ToolCallType } from "../../core/schemas/tool-call.js";
2
+ export interface ToolCallProps {
3
+ toolCall: ToolCallType;
4
+ }
5
+ /**
6
+ * ToolCall component - displays a single tool call with collapsible details
7
+ */
8
+ export declare function ToolCall({ toolCall }: ToolCallProps): import("react/jsx-runtime").JSX.Element;