@townco/ui 0.1.108 → 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.
- package/dist/core/hooks/use-chat-messages.js +5 -5
- package/dist/core/hooks/use-tool-calls.js +25 -1
- package/dist/core/store/chat-store.d.ts +3 -0
- package/dist/core/store/chat-store.js +134 -3
- package/dist/gui/components/ChatPanelTabContent.js +23 -1
- package/dist/gui/components/ChatView.js +47 -4
- package/dist/gui/components/Message.js +1 -1
- package/dist/gui/components/MessageContent.js +32 -5
- package/dist/gui/components/Response.d.ts +11 -0
- package/dist/gui/components/Response.js +60 -19
- package/dist/gui/components/SourceListItem.d.ts +10 -0
- package/dist/gui/components/SourceListItem.js +52 -4
- package/dist/sdk/schemas/session.d.ts +68 -0
- package/dist/sdk/schemas/session.js +10 -1
- package/dist/sdk/transports/http.d.ts +1 -0
- package/dist/sdk/transports/http.js +199 -19
- package/package.json +3 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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: () =>
|
|
14
|
-
|
|
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
|
-
] }),
|
|
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
|
-
|
|
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.
|
|
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,
|
|
1156
|
+
// During replay, handle specially
|
|
1068
1157
|
if (isReplay || this.isInReplayMode) {
|
|
1069
|
-
|
|
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
|
-
//
|
|
1277
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1440
|
-
const
|
|
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
|
-
//
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1543
|
-
|
|
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
|
-
|
|
1579
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|