@townco/ui 0.1.109 → 0.1.110

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.
@@ -21,7 +21,7 @@ export function useChatMessages(client, startSession) {
21
21
  const addToolCallToCurrentMessage = useChatStore((state) => state.addToolCallToCurrentMessage);
22
22
  const updateToolCallInCurrentMessage = useChatStore((state) => state.updateToolCallInCurrentMessage);
23
23
  const addHookNotificationToCurrentMessage = useChatStore((state) => state.addHookNotificationToCurrentMessage);
24
- const addSourcesToCurrentMessage = useChatStore((state) => state.addSourcesToCurrentMessage);
24
+ const addSourcesByToolCallId = useChatStore((state) => state.addSourcesByToolCallId);
25
25
  const truncateMessagesFrom = useChatStore((state) => state.truncateMessagesFrom);
26
26
  // Track the current assistant message ID for cancellation
27
27
  const currentAssistantMessageIdRef = useRef(null);
@@ -206,7 +206,7 @@ export function useChatMessages(client, startSession) {
206
206
  // Sources chunk - citation sources from tool calls
207
207
  logger.debug("Received sources chunk", { chunk });
208
208
  // Add sources to current assistant message for citation rendering
209
- addSourcesToCurrentMessage(chunk.sources);
209
+ addSourcesByToolCallId(chunk.sources);
210
210
  }
211
211
  }
212
212
  // Ensure streaming state is cleared even if no explicit isComplete was received
@@ -259,7 +259,7 @@ export function useChatMessages(client, startSession) {
259
259
  addToolCallToCurrentMessage,
260
260
  updateToolCallInCurrentMessage,
261
261
  addHookNotificationToCurrentMessage,
262
- addSourcesToCurrentMessage,
262
+ addSourcesByToolCallId,
263
263
  ]);
264
264
  /**
265
265
  * Cancel the current agent turn
@@ -446,7 +446,7 @@ export function useChatMessages(client, startSession) {
446
446
  }
447
447
  else if (chunk.type === "sources") {
448
448
  logger.debug("Received sources chunk", { chunk });
449
- addSourcesToCurrentMessage(chunk.sources);
449
+ addSourcesByToolCallId(chunk.sources);
450
450
  }
451
451
  }
452
452
  if (!streamCompleted) {
@@ -497,7 +497,7 @@ export function useChatMessages(client, startSession) {
497
497
  addToolCallToCurrentMessage,
498
498
  updateToolCallInCurrentMessage,
499
499
  addHookNotificationToCurrentMessage,
500
- addSourcesToCurrentMessage,
500
+ addSourcesByToolCallId,
501
501
  ]);
502
502
  return {
503
503
  messages,
@@ -1,7 +1,7 @@
1
1
  import { createLogger } from "@townco/core";
2
2
  import { useEffect } from "react";
3
3
  import { useChatStore } from "../store/chat-store.js";
4
- const _logger = createLogger("use-tool-calls", "debug");
4
+ const logger = createLogger("use-tool-calls", "debug");
5
5
  /**
6
6
  * Hook to track and manage tool calls from ACP sessions
7
7
  *
@@ -16,23 +16,46 @@ export function useToolCalls(client) {
16
16
  const updateToolCall = useChatStore((state) => state.updateToolCall);
17
17
  const addToolCallToCurrentMessage = useChatStore((state) => state.addToolCallToCurrentMessage);
18
18
  const updateToolCallInCurrentMessage = useChatStore((state) => state.updateToolCallInCurrentMessage);
19
+ const addSourcesByToolCallId = useChatStore((state) => state.addSourcesByToolCallId);
19
20
  useEffect(() => {
20
21
  if (!client)
21
22
  return;
23
+ logger.info("useToolCalls: subscribing to session updates");
22
24
  // Subscribe to session updates for tool calls
23
25
  const unsubscribe = client.onSessionUpdate((update) => {
26
+ logger.info("useToolCalls: received session update", {
27
+ type: update.type,
28
+ sessionId: update.sessionId,
29
+ });
24
30
  if (update.type === "tool_call") {
31
+ logger.info("useToolCalls: processing tool_call", {
32
+ toolCallId: update.toolCall.id,
33
+ title: update.toolCall.title,
34
+ });
25
35
  // Add to session-level tool calls (for sidebar)
26
36
  addToolCall(update.sessionId, update.toolCall);
27
37
  // Also add to current assistant message (for inline display)
28
38
  addToolCallToCurrentMessage(update.toolCall);
29
39
  }
30
40
  else if (update.type === "tool_call_update") {
41
+ logger.info("useToolCalls: processing tool_call_update", {
42
+ toolCallId: update.toolCallUpdate.id,
43
+ });
31
44
  // Update session-level tool calls (for sidebar)
32
45
  updateToolCall(update.sessionId, update.toolCallUpdate);
33
46
  // Also update in current assistant message (for inline display)
34
47
  updateToolCallInCurrentMessage(update.toolCallUpdate);
35
48
  }
49
+ else if (update.type === "sources") {
50
+ logger.info("useToolCalls: processing sources", {
51
+ sourcesCount: update.sources.length,
52
+ sourceIds: update.sources.map((s) => s.id),
53
+ toolCallIds: update.sources.map((s) => s.toolCallId),
54
+ });
55
+ // Distribute sources to the correct messages based on toolCallId
56
+ // This handles sources restored during session replay
57
+ addSourcesByToolCallId(update.sources);
58
+ }
36
59
  });
37
60
  return () => {
38
61
  unsubscribe();
@@ -43,6 +66,7 @@ export function useToolCalls(client) {
43
66
  updateToolCall,
44
67
  addToolCallToCurrentMessage,
45
68
  updateToolCallInCurrentMessage,
69
+ addSourcesByToolCallId,
46
70
  ]);
47
71
  return {
48
72
  toolCalls,
@@ -33,6 +33,7 @@ export interface ChatStore {
33
33
  messages: DisplayMessage[];
34
34
  isStreaming: boolean;
35
35
  streamingStartTime: number | null;
36
+ sources: Source[];
36
37
  toolCalls: Record<string, ToolCall[]>;
37
38
  totalBilled: {
38
39
  inputTokens: number;
@@ -56,6 +57,7 @@ export interface ChatStore {
56
57
  addMessage: (message: DisplayMessage) => void;
57
58
  updateMessage: (id: string, updates: Partial<DisplayMessage>) => void;
58
59
  clearMessages: () => void;
60
+ addSources: (sources: Source[]) => void;
59
61
  setIsStreaming: (streaming: boolean) => void;
60
62
  setStreamingStartTime: (time: number | null) => void;
61
63
  addToolCall: (sessionId: string, toolCall: ToolCall) => void;
@@ -64,6 +66,7 @@ export interface ChatStore {
64
66
  updateToolCallInCurrentMessage: (update: ToolCallUpdate) => void;
65
67
  addHookNotificationToCurrentMessage: (notification: HookNotification) => void;
66
68
  addSourcesToCurrentMessage: (sources: Source[]) => void;
69
+ addSourcesByToolCallId: (sources: Source[]) => void;
67
70
  setInputValue: (value: string) => void;
68
71
  setInputSubmitting: (submitting: boolean) => void;
69
72
  addFileAttachment: (file: InputState["attachedFiles"][number]) => void;
@@ -99,6 +99,7 @@ export const useChatStore = create((set) => ({
99
99
  messages: [],
100
100
  isStreaming: false,
101
101
  streamingStartTime: null,
102
+ sources: [],
102
103
  toolCalls: {},
103
104
  totalBilled: {
104
105
  inputTokens: 0,
@@ -229,7 +230,18 @@ export const useChatStore = create((set) => ({
229
230
  currentContext: newCurrentContext,
230
231
  };
231
232
  }),
232
- clearMessages: () => set({ messages: [] }),
233
+ clearMessages: () => set({ messages: [], sources: [] }),
234
+ addSources: (incomingSources) => set((state) => {
235
+ if (!incomingSources || incomingSources.length === 0)
236
+ return state;
237
+ // Merge/dedupe by source id (ids are globally unique within a session)
238
+ const byId = new Map();
239
+ for (const s of state.sources)
240
+ byId.set(s.id, s);
241
+ for (const s of incomingSources)
242
+ byId.set(s.id, s);
243
+ return { sources: Array.from(byId.values()) };
244
+ }),
233
245
  setIsStreaming: (streaming) => set({ isStreaming: streaming }),
234
246
  setStreamingStartTime: (time) => set({ streamingStartTime: time }),
235
247
  addToolCall: (sessionId, toolCall) => set((state) => ({
@@ -421,7 +433,16 @@ export const useChatStore = create((set) => ({
421
433
  isStreaming: false,
422
434
  sources,
423
435
  };
424
- return { messages: [...state.messages, newMessage] };
436
+ // Also store in session-level sources
437
+ const byId = new Map();
438
+ for (const s of state.sources)
439
+ byId.set(s.id, s);
440
+ for (const s of sources)
441
+ byId.set(s.id, s);
442
+ return {
443
+ messages: [...state.messages, newMessage],
444
+ sources: [...byId.values()],
445
+ };
425
446
  }
426
447
  const messages = [...state.messages];
427
448
  const lastAssistantMsg = messages[lastAssistantIndex];
@@ -436,7 +457,117 @@ export const useChatStore = create((set) => ({
436
457
  sourcesCount: sources.length,
437
458
  totalSources: (lastAssistantMsg.sources?.length || 0) + sources.length,
438
459
  });
439
- return { messages };
460
+ // Also store in session-level sources
461
+ const byId = new Map();
462
+ for (const s of state.sources)
463
+ byId.set(s.id, s);
464
+ for (const s of sources)
465
+ byId.set(s.id, s);
466
+ return { messages, sources: [...byId.values()] };
467
+ }),
468
+ addSourcesByToolCallId: (sources) => set((state) => {
469
+ logger.info("addSourcesByToolCallId called", {
470
+ sourcesCount: sources.length,
471
+ messagesCount: state.messages.length,
472
+ sourceDetails: sources.map((s) => ({
473
+ id: s.id,
474
+ toolCallId: s.toolCallId,
475
+ title: s.title?.slice(0, 30),
476
+ })),
477
+ });
478
+ // Log all messages and their tool calls for debugging
479
+ logger.info("Current messages state", {
480
+ messages: state.messages.map((m, i) => ({
481
+ index: i,
482
+ role: m.role,
483
+ contentLength: m.content?.length || 0,
484
+ toolCallIds: m.toolCalls?.map((tc) => tc.id) || [],
485
+ existingSources: m.sources?.length || 0,
486
+ })),
487
+ });
488
+ // Group sources by toolCallId
489
+ const sourcesByToolCallId = new Map();
490
+ for (const source of sources) {
491
+ if (!source.toolCallId) {
492
+ logger.warn("Source missing toolCallId", { sourceId: source.id });
493
+ continue;
494
+ }
495
+ const existing = sourcesByToolCallId.get(source.toolCallId) || [];
496
+ existing.push(source);
497
+ sourcesByToolCallId.set(source.toolCallId, existing);
498
+ }
499
+ if (sourcesByToolCallId.size === 0) {
500
+ logger.warn("No sources with toolCallId to distribute");
501
+ return state;
502
+ }
503
+ // Find messages containing tool calls with matching IDs and add sources
504
+ const messages = [...state.messages];
505
+ let sourcesAdded = 0;
506
+ for (const [toolCallId, toolCallSources] of sourcesByToolCallId) {
507
+ // Find the message containing this tool call
508
+ const messageIndex = messages.findIndex((msg) => msg.role === "assistant" &&
509
+ msg.toolCalls?.some((tc) => tc.id === toolCallId));
510
+ logger.info("Looking for message with toolCallId", {
511
+ toolCallId,
512
+ foundIndex: messageIndex,
513
+ sourcesCount: toolCallSources.length,
514
+ });
515
+ if (messageIndex !== -1) {
516
+ const msg = messages[messageIndex];
517
+ if (msg) {
518
+ messages[messageIndex] = {
519
+ ...msg,
520
+ sources: [...(msg.sources || []), ...toolCallSources],
521
+ };
522
+ sourcesAdded += toolCallSources.length;
523
+ logger.info("Added sources to message", {
524
+ messageIndex,
525
+ messageId: msg.id,
526
+ toolCallId,
527
+ sourcesAdded: toolCallSources.length,
528
+ totalSourcesNow: messages[messageIndex]?.sources?.length,
529
+ messageContentPreview: msg.content.slice(0, 100),
530
+ });
531
+ }
532
+ }
533
+ else {
534
+ // Tool call not found in any message - add to last assistant message as fallback
535
+ logger.warn("Tool call not found in any message, using fallback", {
536
+ toolCallId,
537
+ });
538
+ const lastAssistantIndex = messages.findLastIndex((msg) => msg.role === "assistant");
539
+ if (lastAssistantIndex !== -1) {
540
+ const msg = messages[lastAssistantIndex];
541
+ if (msg) {
542
+ messages[lastAssistantIndex] = {
543
+ ...msg,
544
+ sources: [...(msg.sources || []), ...toolCallSources],
545
+ };
546
+ sourcesAdded += toolCallSources.length;
547
+ logger.info("Added sources to last assistant message (fallback)", {
548
+ lastAssistantIndex,
549
+ sourcesAdded: toolCallSources.length,
550
+ });
551
+ }
552
+ }
553
+ else {
554
+ logger.error("No assistant message found to add sources to");
555
+ }
556
+ }
557
+ }
558
+ logger.info("Finished distributing sources", {
559
+ totalSources: sources.length,
560
+ sourcesAdded,
561
+ toolCallIds: Array.from(sourcesByToolCallId.keys()),
562
+ });
563
+ // Always keep a session-level registry as well, so inline citations can resolve
564
+ // even if we couldn't associate sources to a specific message during replay.
565
+ const byId = new Map();
566
+ for (const s of state.sources)
567
+ byId.set(s.id, s);
568
+ for (const s of sources)
569
+ byId.set(s.id, s);
570
+ return { messages, sources: [...byId.values()] };
440
571
  }),
441
572
  updateToolCall: (sessionId, update) => set((state) => {
442
573
  const sessionToolCalls = state.toolCalls[sessionId] || [];
@@ -5,6 +5,7 @@ import { cn } from "../lib/utils.js";
5
5
  import { SandboxFileSystemProvider } from "../providers/SandboxFileSystemProvider.js";
6
6
  import { FileSystemView } from "./FileSystemView.js";
7
7
  import { SourceListItem } from "./SourceListItem.js";
8
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs.js";
8
9
  import { TodoList } from "./TodoList.js";
9
10
  export const TodoTabContent = React.forwardRef(({ todos = [], className, ...props }, ref) => {
10
11
  return (_jsx("div", { ref: ref, className: cn("h-full", className), ...props, children: _jsx(TodoList, { todos: todos, className: "h-full" }) }));
@@ -76,13 +77,34 @@ export const FilesTabContent = React.forwardRef(({ files = [], provider, session
76
77
  });
77
78
  FilesTabContent.displayName = "FilesTabContent";
78
79
  export const SourcesTabContent = React.forwardRef(({ sources = [], highlightedSourceId, className, ...props }, ref) => {
80
+ const { usedSources, consultedSources } = React.useMemo(() => {
81
+ const used = [];
82
+ const consulted = [];
83
+ for (const s of sources) {
84
+ if (s.usedIn && s.usedIn.length > 0)
85
+ used.push(s);
86
+ else
87
+ consulted.push(s);
88
+ }
89
+ return { usedSources: used, consultedSources: consulted };
90
+ }, [sources]);
91
+ const defaultSourcesTab = usedSources.length > 0 ? "used" : "consulted";
92
+ const [activeSourcesTab, setActiveSourcesTab] = React.useState(defaultSourcesTab);
93
+ // Keep tab selection sensible as sources stream in.
94
+ React.useEffect(() => {
95
+ setActiveSourcesTab(defaultSourcesTab);
96
+ }, [defaultSourcesTab]);
79
97
  // Show empty state if no sources
80
98
  if (sources.length === 0) {
81
99
  return (_jsxs("div", { ref: ref, className: cn("flex flex-col items-center justify-center h-full text-center py-8 max-w-sm mx-auto", className), ...props, children: [
82
100
  _jsx(Globe, { className: "size-8 text-muted-foreground opacity-50 mb-3" }), _jsx("p", { className: "text-paragraph text-muted-foreground", children: "No sources yet" }), _jsx("p", { className: "text-paragraph-sm text-muted-foreground/70 mt-1", children: "Sources will appear when your agent searches the web or fetches data." })
83
101
  ] }));
84
102
  }
85
- return (_jsx("div", { ref: ref, className: cn("space-y-2", className), ...props, children: sources.map((source) => (_jsx(SourceListItem, { source: source, isSelected: source.id === highlightedSourceId }, source.id))) }));
103
+ return (_jsxs(Tabs, { value: activeSourcesTab, onValueChange: (v) => setActiveSourcesTab(v), className: cn("flex flex-col gap-2", className), children: [
104
+ _jsx("div", { className: cn("sticky top-0 z-10 -mx-4 -mt-4 mb-4 px-4 bg-card border-b border-border", "before:content-[''] before:absolute before:left-0 before:right-0 before:-top-4 before:h-4 before:bg-card before:pointer-events-none"), children: _jsxs(TabsList, { className: "w-full h-auto p-0 bg-transparent rounded-none justify-start", children: [
105
+ _jsxs(TabsTrigger, { value: "used", disabled: usedSources.length === 0, className: cn("flex-1 !text-xs !px-2 !py-2 rounded-none", "bg-transparent shadow-none", "border-b-2 border-transparent -mb-px", "text-muted-foreground hover:text-foreground", "data-[state=active]:bg-transparent data-[state=active]:shadow-none", "data-[state=active]:text-foreground data-[state=active]:border-foreground"), children: ["Referenced (", usedSources.length, ")"] }), _jsxs(TabsTrigger, { value: "consulted", disabled: consultedSources.length === 0, className: cn("flex-1 !text-xs !px-2 !py-2 rounded-none", "bg-transparent shadow-none", "border-b-2 border-transparent -mb-px", "text-muted-foreground hover:text-foreground", "data-[state=active]:bg-transparent data-[state=active]:shadow-none", "data-[state=active]:text-foreground data-[state=active]:border-foreground"), children: ["Reviewed (", consultedSources.length, ")"] })
106
+ ] }) }), _jsx(TabsContent, { value: "used", className: "mt-0", children: usedSources.length > 0 ? (_jsx("div", { ref: ref, className: "space-y-2", ...props, children: usedSources.map((source) => (_jsx(SourceListItem, { source: source, isSelected: source.id === highlightedSourceId }, source.id))) })) : (_jsx("div", { ref: ref, className: "text-xs text-muted-foreground", ...props, children: "No referenced sources yet." })) }), _jsx(TabsContent, { value: "consulted", className: "mt-0", children: consultedSources.length > 0 ? (_jsx("div", { ref: ref, className: "space-y-2", ...props, children: consultedSources.map((source) => (_jsx(SourceListItem, { source: source, isSelected: source.id === highlightedSourceId }, source.id))) })) : (_jsx("div", { ref: ref, className: "text-xs text-muted-foreground", ...props, children: "No reviewed sources." })) })
107
+ ] }));
86
108
  });
87
109
  SourcesTabContent.displayName = "SourcesTabContent";
88
110
  export const DatabaseTabContent = React.forwardRef(({ data, className, ...props }, ref) => {
@@ -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
@@ -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";
@@ -637,6 +637,74 @@ export declare const SessionUpdate: z.ZodUnion<readonly [z.ZodObject<{
637
637
  rawOutput: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
638
638
  }, z.core.$strip>;
639
639
  messageId: z.ZodOptional<z.ZodString>;
640
+ }, z.core.$strip>, z.ZodObject<{
641
+ sessionId: z.ZodString;
642
+ status: z.ZodOptional<z.ZodEnum<{
643
+ active: "active";
644
+ connected: "connected";
645
+ connecting: "connecting";
646
+ disconnected: "disconnected";
647
+ error: "error";
648
+ idle: "idle";
649
+ streaming: "streaming";
650
+ }>>;
651
+ message: z.ZodOptional<z.ZodObject<{
652
+ id: z.ZodString;
653
+ role: z.ZodEnum<{
654
+ assistant: "assistant";
655
+ system: "system";
656
+ tool: "tool";
657
+ user: "user";
658
+ }>;
659
+ content: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
660
+ type: z.ZodLiteral<"text">;
661
+ text: z.ZodString;
662
+ }, z.core.$strip>, z.ZodObject<{
663
+ type: z.ZodLiteral<"image">;
664
+ url: z.ZodOptional<z.ZodString>;
665
+ source: z.ZodOptional<z.ZodObject<{
666
+ type: z.ZodLiteral<"base64">;
667
+ media_type: z.ZodEnum<{
668
+ "image/gif": "image/gif";
669
+ "image/jpeg": "image/jpeg";
670
+ "image/png": "image/png";
671
+ "image/webp": "image/webp";
672
+ }>;
673
+ data: z.ZodString;
674
+ }, z.core.$strip>>;
675
+ }, z.core.$strip>, z.ZodObject<{
676
+ type: z.ZodLiteral<"file">;
677
+ name: z.ZodString;
678
+ path: z.ZodOptional<z.ZodString>;
679
+ url: z.ZodOptional<z.ZodString>;
680
+ mimeType: z.ZodString;
681
+ size: z.ZodOptional<z.ZodNumber>;
682
+ }, z.core.$strip>, z.ZodObject<{
683
+ type: z.ZodLiteral<"tool_call">;
684
+ id: z.ZodString;
685
+ name: z.ZodString;
686
+ arguments: z.ZodRecord<z.ZodString, z.ZodUnknown>;
687
+ }, z.core.$strip>, z.ZodObject<{
688
+ type: z.ZodLiteral<"tool_result">;
689
+ callId: z.ZodString;
690
+ result: z.ZodUnknown;
691
+ error: z.ZodOptional<z.ZodString>;
692
+ }, z.core.$strip>], "type">>;
693
+ timestamp: z.ZodISODateTime;
694
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
695
+ }, z.core.$strip>>;
696
+ error: z.ZodOptional<z.ZodString>;
697
+ _meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
698
+ type: z.ZodLiteral<"sources">;
699
+ sources: z.ZodArray<z.ZodObject<{
700
+ id: z.ZodString;
701
+ url: z.ZodString;
702
+ title: z.ZodString;
703
+ snippet: z.ZodOptional<z.ZodString>;
704
+ favicon: z.ZodOptional<z.ZodString>;
705
+ toolCallId: z.ZodString;
706
+ sourceName: z.ZodOptional<z.ZodString>;
707
+ }, z.core.$strip>>;
640
708
  }, z.core.$strip>, z.ZodObject<{
641
709
  sessionId: z.ZodString;
642
710
  status: z.ZodOptional<z.ZodEnum<{
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ToolCallSchema, ToolCallUpdateSchema, } from "../../core/schemas/tool-call.js";
3
- import { Message } from "./message.js";
3
+ import { CitationSource, Message } from "./message.js";
4
4
  /**
5
5
  * Session status
6
6
  */
@@ -83,6 +83,14 @@ const ToolOutputSessionUpdate = BaseSessionUpdate.extend({
83
83
  }),
84
84
  messageId: z.string().optional(),
85
85
  });
86
+ /**
87
+ * Session update with sources (sessionUpdate: "sources")
88
+ * Sent during replay to restore persisted citation sources
89
+ */
90
+ const SourcesSessionUpdate = BaseSessionUpdate.extend({
91
+ type: z.literal("sources"),
92
+ sources: z.array(CitationSource),
93
+ });
86
94
  /**
87
95
  * Generic session update
88
96
  */
@@ -96,5 +104,6 @@ export const SessionUpdate = z.union([
96
104
  ToolCallSessionUpdate,
97
105
  ToolCallUpdateSessionUpdate,
98
106
  ToolOutputSessionUpdate,
107
+ SourcesSessionUpdate,
99
108
  GenericSessionUpdate,
100
109
  ]);
@@ -22,6 +22,7 @@ export declare class HttpTransport implements Transport {
22
22
  private options;
23
23
  private isReceivingMessages;
24
24
  private isInReplayMode;
25
+ private pendingReplayUpdates;
25
26
  private agentInfo?;
26
27
  constructor(options: HttpTransportOptions);
27
28
  connect(): Promise<void>;
@@ -25,6 +25,7 @@ export class HttpTransport {
25
25
  options;
26
26
  isReceivingMessages = false;
27
27
  isInReplayMode = false; // True during session replay, ignores non-replay streaming
28
+ pendingReplayUpdates = []; // Queue session updates during replay for late-subscribing callbacks
28
29
  agentInfo;
29
30
  constructor(options) {
30
31
  // Validate options at the boundary using Zod
@@ -565,6 +566,61 @@ export class HttpTransport {
565
566
  }
566
567
  onSessionUpdate(callback) {
567
568
  this.sessionUpdateCallbacks.add(callback);
569
+ // Replay any queued session updates from replay that arrived before callbacks were registered
570
+ // IMPORTANT: Use notifySessionUpdate to send to ALL registered callbacks, not just this one.
571
+ // This ensures that when multiple hooks register (e.g., message handler, useToolCalls),
572
+ // they ALL receive the queued updates, not just the first one to register.
573
+ if (this.pendingReplayUpdates.length > 0) {
574
+ logger.info("Replaying queued session updates to ALL callbacks", {
575
+ count: this.pendingReplayUpdates.length,
576
+ callbackCount: this.sessionUpdateCallbacks.size,
577
+ updateTypes: this.pendingReplayUpdates.map((u) => u.type),
578
+ updateDetails: this.pendingReplayUpdates.map((u) => {
579
+ if (u.type === "tool_call") {
580
+ return { type: "tool_call", toolCallId: u.toolCall?.id };
581
+ }
582
+ if (u.type === "sources") {
583
+ return {
584
+ type: "sources",
585
+ count: u.sources?.length,
586
+ ids: u.sources?.map((s) => s.id),
587
+ };
588
+ }
589
+ if (u.type === "generic" && u.message) {
590
+ const firstContent = u.message.content?.[0];
591
+ return {
592
+ type: "message",
593
+ role: u.message.role,
594
+ contentLength: firstContent?.type === "text" ? firstContent.text?.length : 0,
595
+ };
596
+ }
597
+ return { type: u.type };
598
+ }),
599
+ });
600
+ // Copy and clear the queue immediately to prevent re-triggering when other callbacks register
601
+ const updatesToReplay = [...this.pendingReplayUpdates];
602
+ this.pendingReplayUpdates = [];
603
+ // Use setTimeout to ensure all callbacks have a chance to register before replaying
604
+ setTimeout(() => {
605
+ logger.info("Starting replay of queued updates to all callbacks", {
606
+ updateCount: updatesToReplay.length,
607
+ callbackCount: this.sessionUpdateCallbacks.size,
608
+ });
609
+ for (const update of updatesToReplay) {
610
+ try {
611
+ logger.info("Replaying update to all callbacks", {
612
+ type: update.type,
613
+ });
614
+ // Notify ALL registered callbacks, not just the one that triggered this replay
615
+ this.notifySessionUpdate(update);
616
+ }
617
+ catch (error) {
618
+ logger.error("Error replaying session update", { error });
619
+ }
620
+ }
621
+ logger.info("Finished replay of queued updates");
622
+ }, 0);
623
+ }
568
624
  return () => {
569
625
  this.sessionUpdateCallbacks.delete(callback);
570
626
  };
@@ -810,6 +866,28 @@ export class HttpTransport {
810
866
  try {
811
867
  const message = JSON.parse(data);
812
868
  logger.debug("Received SSE message", { message });
869
+ // Check if this is a sources message (custom extension to ACP)
870
+ const isSourcesMessage = message &&
871
+ typeof message === "object" &&
872
+ message.method === "session/update" &&
873
+ message.params?.update?.sessionUpdate === "sources";
874
+ if (isSourcesMessage) {
875
+ // Use console.warn directly for gui-console capture
876
+ console.warn("🟢 RECEIVED SOURCES SSE MESSAGE", {
877
+ sourcesCount: message.params?.update?.sources?.length,
878
+ isInReplayMode: this.isInReplayMode,
879
+ callbackCount: this.sessionUpdateCallbacks.size,
880
+ });
881
+ // Handle sources directly without ACP schema validation
882
+ try {
883
+ this.handleSessionNotification(message.params);
884
+ console.warn("🟢 AFTER handleSessionNotification for sources");
885
+ }
886
+ catch (error) {
887
+ console.error("🔴 ERROR in handleSessionNotification for sources", error);
888
+ }
889
+ return;
890
+ }
813
891
  // Validate the message is an ACP agent outgoing message
814
892
  const parseResult = acp.agentOutgoingMessageSchema.safeParse(message);
815
893
  if (!parseResult.success) {
@@ -843,18 +921,29 @@ export class HttpTransport {
843
921
  * Handle a session notification from the agent
844
922
  */
845
923
  handleSessionNotification(params) {
924
+ // Extract update type early to check if this is a sources notification
925
+ const paramsAny = params;
926
+ const updateAny = paramsAny.update;
927
+ const isSourcesNotification = updateAny?.sessionUpdate === "sources";
846
928
  // Skip processing if stream has been cancelled/completed
847
- if (this.streamComplete) {
929
+ // BUT always allow sources through - they arrive after stream completion during replay
930
+ if (this.streamComplete && !isSourcesNotification) {
848
931
  logger.debug("Skipping session notification - stream complete/cancelled");
849
932
  return;
850
933
  }
934
+ if (this.streamComplete && isSourcesNotification) {
935
+ console.warn("🟢 Processing sources notification after stream complete");
936
+ }
851
937
  logger.debug("handleSessionNotification called", { params });
852
938
  // Extract content from the update
853
939
  const paramsExtended = params;
854
940
  const update = paramsExtended.update;
855
941
  const sessionId = this.currentSessionId || params.sessionId;
856
- logger.debug("Update session type", {
942
+ logger.warn("📥 SSE UPDATE RECEIVED", {
857
943
  sessionUpdate: update?.sessionUpdate,
944
+ hasUpdate: !!update,
945
+ isInReplayMode: this.isInReplayMode,
946
+ callbackCount: this.sessionUpdateCallbacks.size,
858
947
  });
859
948
  // Handle sandbox file changes
860
949
  // Type assertion needed because TypeScript doesn't recognize this as a valid session update type
@@ -1064,9 +1153,18 @@ export class HttpTransport {
1064
1153
  isReplay,
1065
1154
  isInReplayMode: this.isInReplayMode,
1066
1155
  });
1067
- // During replay, notify directly since there's no active receive() consumer
1156
+ // During replay, handle specially
1068
1157
  if (isReplay || this.isInReplayMode) {
1069
- this.notifySessionUpdate(sessionUpdate);
1158
+ // If no callbacks registered yet, queue for late-subscribing hooks
1159
+ if (this.sessionUpdateCallbacks.size === 0) {
1160
+ logger.debug("Queueing tool_call for late-subscribing callbacks", {
1161
+ toolCallId: toolCall.id,
1162
+ });
1163
+ this.pendingReplayUpdates.push(sessionUpdate);
1164
+ }
1165
+ else {
1166
+ this.notifySessionUpdate(sessionUpdate);
1167
+ }
1070
1168
  }
1071
1169
  else {
1072
1170
  // Queue tool call as a chunk for ordered processing during live streaming
@@ -1273,8 +1371,16 @@ export class HttpTransport {
1273
1371
  // Check if this is replay (tool_call_update doesn't have isReplay in _meta,
1274
1372
  // but we can check if we're in replay mode)
1275
1373
  if (this.isInReplayMode) {
1276
- // During replay, notify directly since there's no active receive() consumer
1277
- this.notifySessionUpdate(sessionUpdate);
1374
+ // If no callbacks registered yet, queue for late-subscribing hooks
1375
+ if (this.sessionUpdateCallbacks.size === 0) {
1376
+ logger.debug("Queueing tool_call_update for late-subscribing callbacks", {
1377
+ toolCallId: toolCallUpdate.id,
1378
+ });
1379
+ this.pendingReplayUpdates.push(sessionUpdate);
1380
+ }
1381
+ else {
1382
+ this.notifySessionUpdate(sessionUpdate);
1383
+ }
1278
1384
  }
1279
1385
  else {
1280
1386
  // Queue tool call update as a chunk for ordered processing
@@ -1406,7 +1512,16 @@ export class HttpTransport {
1406
1512
  };
1407
1513
  // During replay, notify directly; otherwise queue for ordered processing
1408
1514
  if (this.isInReplayMode) {
1409
- this.notifySessionUpdate(sessionUpdate);
1515
+ // If no callbacks registered yet, queue for late-subscribing hooks
1516
+ if (this.sessionUpdateCallbacks.size === 0) {
1517
+ logger.debug("Queueing tool_output for late-subscribing callbacks", {
1518
+ toolCallId: toolOutput.id,
1519
+ });
1520
+ this.pendingReplayUpdates.push(sessionUpdate);
1521
+ }
1522
+ else {
1523
+ this.notifySessionUpdate(sessionUpdate);
1524
+ }
1410
1525
  }
1411
1526
  else {
1412
1527
  // Queue tool output as a chunk for ordered processing
@@ -1433,21 +1548,56 @@ export class HttpTransport {
1433
1548
  update.sessionUpdate === "sources") {
1434
1549
  // Sources notification - citation sources from tool calls
1435
1550
  const sourcesUpdate = update;
1436
- logger.debug("Received sources notification", {
1551
+ console.warn("🔵 SOURCES in handleSessionNotification", {
1437
1552
  sourcesCount: sourcesUpdate.sources.length,
1553
+ isInReplayMode: this.isInReplayMode,
1554
+ callbackCount: this.sessionUpdateCallbacks.size,
1555
+ firstSourceId: sourcesUpdate.sources[0]?.id,
1556
+ firstSourceToolCallId: sourcesUpdate.sources[0]?.toolCallId,
1438
1557
  });
1439
- // Create a sources chunk for the message queue
1440
- const sourcesChunk = {
1558
+ // Create a sources session update
1559
+ const sessionUpdate = {
1441
1560
  type: "sources",
1561
+ sessionId,
1562
+ status: "active",
1442
1563
  sources: sourcesUpdate.sources,
1443
1564
  };
1444
- // Queue for ordered processing
1445
- const resolver = this.chunkResolvers.shift();
1446
- if (resolver) {
1447
- resolver(sourcesChunk);
1565
+ // During replay, handle sources specially
1566
+ if (this.isInReplayMode) {
1567
+ // If no callbacks are registered yet (React hooks haven't subscribed),
1568
+ // queue the sources to be replayed when they do subscribe
1569
+ if (this.sessionUpdateCallbacks.size === 0) {
1570
+ console.warn("🔵 QUEUEING sources for late-subscribing callbacks", {
1571
+ sourcesCount: sourcesUpdate.sources.length,
1572
+ queueLengthBefore: this.pendingReplayUpdates.length,
1573
+ });
1574
+ this.pendingReplayUpdates.push(sessionUpdate);
1575
+ console.warn("🔵 Queue length after:", {
1576
+ queueLengthAfter: this.pendingReplayUpdates.length,
1577
+ });
1578
+ }
1579
+ else {
1580
+ console.warn("🔵 NOTIFYING sources immediately (callbacks registered)", {
1581
+ sourcesCount: sourcesUpdate.sources.length,
1582
+ callbackCount: this.sessionUpdateCallbacks.size,
1583
+ });
1584
+ this.notifySessionUpdate(sessionUpdate);
1585
+ }
1448
1586
  }
1449
1587
  else {
1450
- this.messageQueue.push(sourcesChunk);
1588
+ // Create a sources chunk for the message queue
1589
+ const sourcesChunk = {
1590
+ type: "sources",
1591
+ sources: sourcesUpdate.sources,
1592
+ };
1593
+ // Queue for ordered processing
1594
+ const resolver = this.chunkResolvers.shift();
1595
+ if (resolver) {
1596
+ resolver(sourcesChunk);
1597
+ }
1598
+ else {
1599
+ this.messageQueue.push(sourcesChunk);
1600
+ }
1451
1601
  }
1452
1602
  }
1453
1603
  else if (update?.sessionUpdate === "agent_message_chunk") {
@@ -1539,8 +1689,18 @@ export class HttpTransport {
1539
1689
  timestamp: new Date().toISOString(),
1540
1690
  },
1541
1691
  };
1542
- // Notify as a complete message (for session replay or initial message)
1543
- this.notifySessionUpdate(messageSessionUpdate);
1692
+ // During replay, queue messages along with tool_calls and sources
1693
+ // so everything is processed in the correct order
1694
+ if (isReplay && this.sessionUpdateCallbacks.size === 0) {
1695
+ logger.debug("Queueing assistant message for late-subscribing callbacks", {
1696
+ textLength: contentObj.text.length,
1697
+ });
1698
+ this.pendingReplayUpdates.push(messageSessionUpdate);
1699
+ }
1700
+ else {
1701
+ // Notify as a complete message (for session replay or initial message)
1702
+ this.notifySessionUpdate(messageSessionUpdate);
1703
+ }
1544
1704
  }
1545
1705
  }
1546
1706
  // Send session update for:
@@ -1554,6 +1714,11 @@ export class HttpTransport {
1554
1714
  else if (update?.sessionUpdate === "user_message_chunk") {
1555
1715
  // Handle user message chunks (could be from replay or new messages)
1556
1716
  logger.debug("Received user_message_chunk", { update });
1717
+ // Check if this is a replay
1718
+ const isReplay = update._meta &&
1719
+ typeof update._meta === "object" &&
1720
+ "isReplay" in update._meta &&
1721
+ update._meta.isReplay === true;
1557
1722
  const content = update.content;
1558
1723
  if (content && typeof content === "object") {
1559
1724
  const contentObj = content;
@@ -1575,13 +1740,28 @@ export class HttpTransport {
1575
1740
  timestamp: new Date().toISOString(),
1576
1741
  },
1577
1742
  };
1578
- logger.debug("Notifying session update for user message");
1579
- this.notifySessionUpdate(sessionUpdate);
1743
+ // During replay, queue messages along with tool_calls and sources
1744
+ // so everything is processed in the correct order
1745
+ if (isReplay && this.sessionUpdateCallbacks.size === 0) {
1746
+ logger.debug("Queueing user message for late-subscribing callbacks", {
1747
+ textLength: contentObj.text.length,
1748
+ });
1749
+ this.pendingReplayUpdates.push(sessionUpdate);
1750
+ }
1751
+ else {
1752
+ logger.debug("Notifying session update for user message");
1753
+ this.notifySessionUpdate(sessionUpdate);
1754
+ }
1580
1755
  }
1581
1756
  }
1582
1757
  }
1583
1758
  else {
1584
1759
  // Handle other session updates
1760
+ logger.warn("⚠️ UNHANDLED SESSION UPDATE - falling through to generic", {
1761
+ sessionUpdate: update?.sessionUpdate,
1762
+ updateKeys: update ? Object.keys(update) : [],
1763
+ hasSources: update && "sources" in update,
1764
+ });
1585
1765
  const sessionUpdate = {
1586
1766
  type: "generic",
1587
1767
  sessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/ui",
3
- "version": "0.1.109",
3
+ "version": "0.1.110",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -49,7 +49,7 @@
49
49
  "@radix-ui/react-slot": "^1.2.4",
50
50
  "@radix-ui/react-tabs": "^1.1.13",
51
51
  "@radix-ui/react-tooltip": "^1.2.8",
52
- "@townco/core": "0.0.87",
52
+ "@townco/core": "0.0.88",
53
53
  "@types/mdast": "^4.0.4",
54
54
  "@uiw/react-json-view": "^2.0.0-alpha.39",
55
55
  "class-variance-authority": "^0.7.1",
@@ -67,7 +67,7 @@
67
67
  "zustand": "^5.0.8"
68
68
  },
69
69
  "devDependencies": {
70
- "@townco/tsconfig": "0.1.106",
70
+ "@townco/tsconfig": "0.1.107",
71
71
  "@types/node": "^24.10.0",
72
72
  "@types/react": "^19.2.2",
73
73
  "@types/unist": "^3.0.3",