@townco/ui 0.1.72 → 0.1.74
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.d.ts +23 -0
- package/dist/core/hooks/use-chat-messages.js +8 -0
- package/dist/core/schemas/chat.d.ts +93 -0
- package/dist/core/schemas/chat.js +38 -0
- package/dist/core/store/chat-store.d.ts +3 -0
- package/dist/core/store/chat-store.js +133 -0
- package/dist/gui/components/ChatLayout.js +12 -50
- package/dist/gui/components/ChatView.js +3 -4
- package/dist/gui/components/ContextUsageButton.d.ts +1 -1
- package/dist/gui/components/ContextUsageButton.js +6 -2
- package/dist/gui/components/HookNotification.d.ts +9 -0
- package/dist/gui/components/HookNotification.js +97 -0
- package/dist/gui/components/MessageContent.js +130 -29
- package/dist/gui/components/index.d.ts +1 -0
- package/dist/gui/components/index.js +1 -0
- package/dist/gui/hooks/index.d.ts +1 -0
- package/dist/gui/hooks/index.js +1 -0
- package/dist/gui/hooks/use-scroll-to-bottom.d.ts +18 -0
- package/dist/gui/hooks/use-scroll-to-bottom.js +120 -0
- package/dist/sdk/schemas/message.d.ts +173 -2
- package/dist/sdk/schemas/message.js +60 -0
- package/dist/sdk/schemas/session.d.ts +6 -6
- package/dist/sdk/transports/http.js +28 -0
- package/package.json +3 -3
|
@@ -159,6 +159,29 @@ export declare function useChatMessages(client: AcpClient | null, startSession:
|
|
|
159
159
|
}[] | undefined;
|
|
160
160
|
subagentStreaming?: boolean | undefined;
|
|
161
161
|
}[] | undefined;
|
|
162
|
+
hookNotifications?: {
|
|
163
|
+
id: string;
|
|
164
|
+
hookType: "context_size" | "tool_response";
|
|
165
|
+
callback: string;
|
|
166
|
+
status: "error" | "completed" | "triggered";
|
|
167
|
+
threshold?: number | undefined;
|
|
168
|
+
currentPercentage?: number | undefined;
|
|
169
|
+
metadata?: {
|
|
170
|
+
[x: string]: unknown;
|
|
171
|
+
action?: string | undefined;
|
|
172
|
+
messagesRemoved?: number | undefined;
|
|
173
|
+
tokensSaved?: number | undefined;
|
|
174
|
+
tokensBeforeCompaction?: number | undefined;
|
|
175
|
+
summaryTokens?: number | undefined;
|
|
176
|
+
originalTokens?: number | undefined;
|
|
177
|
+
finalTokens?: number | undefined;
|
|
178
|
+
truncationWarning?: string | undefined;
|
|
179
|
+
} | undefined;
|
|
180
|
+
error?: string | undefined;
|
|
181
|
+
triggeredAt?: number | undefined;
|
|
182
|
+
completedAt?: number | undefined;
|
|
183
|
+
contentPosition?: number | undefined;
|
|
184
|
+
}[] | undefined;
|
|
162
185
|
tokenUsage?: {
|
|
163
186
|
inputTokens?: number | undefined;
|
|
164
187
|
outputTokens?: number | undefined;
|
|
@@ -19,6 +19,7 @@ export function useChatMessages(client, startSession) {
|
|
|
19
19
|
const updateToolCall = useChatStore((state) => state.updateToolCall);
|
|
20
20
|
const addToolCallToCurrentMessage = useChatStore((state) => state.addToolCallToCurrentMessage);
|
|
21
21
|
const updateToolCallInCurrentMessage = useChatStore((state) => state.updateToolCallInCurrentMessage);
|
|
22
|
+
const addHookNotificationToCurrentMessage = useChatStore((state) => state.addHookNotificationToCurrentMessage);
|
|
22
23
|
/**
|
|
23
24
|
* Send a message to the agent
|
|
24
25
|
*/
|
|
@@ -167,6 +168,12 @@ export function useChatMessages(client, startSession) {
|
|
|
167
168
|
// Also update in current assistant message (for inline display)
|
|
168
169
|
updateToolCallInCurrentMessage(chunk.toolCallUpdate);
|
|
169
170
|
}
|
|
171
|
+
else if (chunk.type === "hook_notification") {
|
|
172
|
+
// Hook notification chunk - hook lifecycle events
|
|
173
|
+
logger.debug("Received hook_notification chunk", { chunk });
|
|
174
|
+
// Add/update hook notification in current assistant message
|
|
175
|
+
addHookNotificationToCurrentMessage(chunk.notification);
|
|
176
|
+
}
|
|
170
177
|
}
|
|
171
178
|
// Ensure streaming state is cleared even if no explicit isComplete was received
|
|
172
179
|
if (!streamCompleted) {
|
|
@@ -204,6 +211,7 @@ export function useChatMessages(client, startSession) {
|
|
|
204
211
|
updateToolCall,
|
|
205
212
|
addToolCallToCurrentMessage,
|
|
206
213
|
updateToolCallInCurrentMessage,
|
|
214
|
+
addHookNotificationToCurrentMessage,
|
|
207
215
|
]);
|
|
208
216
|
return {
|
|
209
217
|
messages,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { HookNotification, HookType } from "../../sdk/schemas/message.js";
|
|
2
3
|
/**
|
|
3
4
|
* Chat UI state schemas
|
|
4
5
|
*/
|
|
@@ -10,6 +11,40 @@ export declare const DisplayImageAttachment: z.ZodObject<{
|
|
|
10
11
|
data: z.ZodString;
|
|
11
12
|
}, z.core.$strip>;
|
|
12
13
|
export type DisplayImageAttachment = z.infer<typeof DisplayImageAttachment>;
|
|
14
|
+
/**
|
|
15
|
+
* Hook notification display state (tracks triggered -> completed/error lifecycle)
|
|
16
|
+
*/
|
|
17
|
+
export declare const HookNotificationDisplay: z.ZodObject<{
|
|
18
|
+
id: z.ZodString;
|
|
19
|
+
hookType: z.ZodEnum<{
|
|
20
|
+
context_size: "context_size";
|
|
21
|
+
tool_response: "tool_response";
|
|
22
|
+
}>;
|
|
23
|
+
callback: z.ZodString;
|
|
24
|
+
status: z.ZodEnum<{
|
|
25
|
+
error: "error";
|
|
26
|
+
completed: "completed";
|
|
27
|
+
triggered: "triggered";
|
|
28
|
+
}>;
|
|
29
|
+
threshold: z.ZodOptional<z.ZodNumber>;
|
|
30
|
+
currentPercentage: z.ZodOptional<z.ZodNumber>;
|
|
31
|
+
metadata: z.ZodOptional<z.ZodObject<{
|
|
32
|
+
action: z.ZodOptional<z.ZodString>;
|
|
33
|
+
messagesRemoved: z.ZodOptional<z.ZodNumber>;
|
|
34
|
+
tokensSaved: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
tokensBeforeCompaction: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
summaryTokens: z.ZodOptional<z.ZodNumber>;
|
|
37
|
+
originalTokens: z.ZodOptional<z.ZodNumber>;
|
|
38
|
+
finalTokens: z.ZodOptional<z.ZodNumber>;
|
|
39
|
+
truncationWarning: z.ZodOptional<z.ZodString>;
|
|
40
|
+
}, z.core.$loose>>;
|
|
41
|
+
error: z.ZodOptional<z.ZodString>;
|
|
42
|
+
triggeredAt: z.ZodOptional<z.ZodNumber>;
|
|
43
|
+
completedAt: z.ZodOptional<z.ZodNumber>;
|
|
44
|
+
contentPosition: z.ZodOptional<z.ZodNumber>;
|
|
45
|
+
}, z.core.$strip>;
|
|
46
|
+
export type HookNotificationDisplay = z.infer<typeof HookNotificationDisplay>;
|
|
47
|
+
export { HookType, HookNotification };
|
|
13
48
|
/**
|
|
14
49
|
* Display message schema (UI representation of messages)
|
|
15
50
|
*/
|
|
@@ -202,6 +237,35 @@ export declare const DisplayMessage: z.ZodObject<{
|
|
|
202
237
|
}, z.core.$strip>>>;
|
|
203
238
|
subagentStreaming: z.ZodOptional<z.ZodBoolean>;
|
|
204
239
|
}, z.core.$strip>>>;
|
|
240
|
+
hookNotifications: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
241
|
+
id: z.ZodString;
|
|
242
|
+
hookType: z.ZodEnum<{
|
|
243
|
+
context_size: "context_size";
|
|
244
|
+
tool_response: "tool_response";
|
|
245
|
+
}>;
|
|
246
|
+
callback: z.ZodString;
|
|
247
|
+
status: z.ZodEnum<{
|
|
248
|
+
error: "error";
|
|
249
|
+
completed: "completed";
|
|
250
|
+
triggered: "triggered";
|
|
251
|
+
}>;
|
|
252
|
+
threshold: z.ZodOptional<z.ZodNumber>;
|
|
253
|
+
currentPercentage: z.ZodOptional<z.ZodNumber>;
|
|
254
|
+
metadata: z.ZodOptional<z.ZodObject<{
|
|
255
|
+
action: z.ZodOptional<z.ZodString>;
|
|
256
|
+
messagesRemoved: z.ZodOptional<z.ZodNumber>;
|
|
257
|
+
tokensSaved: z.ZodOptional<z.ZodNumber>;
|
|
258
|
+
tokensBeforeCompaction: z.ZodOptional<z.ZodNumber>;
|
|
259
|
+
summaryTokens: z.ZodOptional<z.ZodNumber>;
|
|
260
|
+
originalTokens: z.ZodOptional<z.ZodNumber>;
|
|
261
|
+
finalTokens: z.ZodOptional<z.ZodNumber>;
|
|
262
|
+
truncationWarning: z.ZodOptional<z.ZodString>;
|
|
263
|
+
}, z.core.$loose>>;
|
|
264
|
+
error: z.ZodOptional<z.ZodString>;
|
|
265
|
+
triggeredAt: z.ZodOptional<z.ZodNumber>;
|
|
266
|
+
completedAt: z.ZodOptional<z.ZodNumber>;
|
|
267
|
+
contentPosition: z.ZodOptional<z.ZodNumber>;
|
|
268
|
+
}, z.core.$strip>>>;
|
|
205
269
|
tokenUsage: z.ZodOptional<z.ZodObject<{
|
|
206
270
|
inputTokens: z.ZodOptional<z.ZodNumber>;
|
|
207
271
|
outputTokens: z.ZodOptional<z.ZodNumber>;
|
|
@@ -424,6 +488,35 @@ export declare const ChatSessionState: z.ZodObject<{
|
|
|
424
488
|
}, z.core.$strip>>>;
|
|
425
489
|
subagentStreaming: z.ZodOptional<z.ZodBoolean>;
|
|
426
490
|
}, z.core.$strip>>>;
|
|
491
|
+
hookNotifications: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
492
|
+
id: z.ZodString;
|
|
493
|
+
hookType: z.ZodEnum<{
|
|
494
|
+
context_size: "context_size";
|
|
495
|
+
tool_response: "tool_response";
|
|
496
|
+
}>;
|
|
497
|
+
callback: z.ZodString;
|
|
498
|
+
status: z.ZodEnum<{
|
|
499
|
+
error: "error";
|
|
500
|
+
completed: "completed";
|
|
501
|
+
triggered: "triggered";
|
|
502
|
+
}>;
|
|
503
|
+
threshold: z.ZodOptional<z.ZodNumber>;
|
|
504
|
+
currentPercentage: z.ZodOptional<z.ZodNumber>;
|
|
505
|
+
metadata: z.ZodOptional<z.ZodObject<{
|
|
506
|
+
action: z.ZodOptional<z.ZodString>;
|
|
507
|
+
messagesRemoved: z.ZodOptional<z.ZodNumber>;
|
|
508
|
+
tokensSaved: z.ZodOptional<z.ZodNumber>;
|
|
509
|
+
tokensBeforeCompaction: z.ZodOptional<z.ZodNumber>;
|
|
510
|
+
summaryTokens: z.ZodOptional<z.ZodNumber>;
|
|
511
|
+
originalTokens: z.ZodOptional<z.ZodNumber>;
|
|
512
|
+
finalTokens: z.ZodOptional<z.ZodNumber>;
|
|
513
|
+
truncationWarning: z.ZodOptional<z.ZodString>;
|
|
514
|
+
}, z.core.$loose>>;
|
|
515
|
+
error: z.ZodOptional<z.ZodString>;
|
|
516
|
+
triggeredAt: z.ZodOptional<z.ZodNumber>;
|
|
517
|
+
completedAt: z.ZodOptional<z.ZodNumber>;
|
|
518
|
+
contentPosition: z.ZodOptional<z.ZodNumber>;
|
|
519
|
+
}, z.core.$strip>>>;
|
|
427
520
|
tokenUsage: z.ZodOptional<z.ZodObject<{
|
|
428
521
|
inputTokens: z.ZodOptional<z.ZodNumber>;
|
|
429
522
|
outputTokens: z.ZodOptional<z.ZodNumber>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { HookNotification, HookType } from "../../sdk/schemas/message.js";
|
|
2
3
|
import { TokenUsageSchema, ToolCallSchema } from "./tool-call.js";
|
|
3
4
|
/**
|
|
4
5
|
* Chat UI state schemas
|
|
@@ -10,6 +11,42 @@ export const DisplayImageAttachment = z.object({
|
|
|
10
11
|
mimeType: z.string(),
|
|
11
12
|
data: z.string(), // base64 encoded
|
|
12
13
|
});
|
|
14
|
+
/**
|
|
15
|
+
* Hook notification display state (tracks triggered -> completed/error lifecycle)
|
|
16
|
+
*/
|
|
17
|
+
export const HookNotificationDisplay = z.object({
|
|
18
|
+
id: z.string(),
|
|
19
|
+
hookType: HookType,
|
|
20
|
+
callback: z.string(),
|
|
21
|
+
status: z.enum(["triggered", "completed", "error"]),
|
|
22
|
+
// From triggered notification
|
|
23
|
+
threshold: z.number().optional(),
|
|
24
|
+
currentPercentage: z.number().optional(),
|
|
25
|
+
// From completed notification
|
|
26
|
+
metadata: z
|
|
27
|
+
.object({
|
|
28
|
+
action: z.string().optional(),
|
|
29
|
+
messagesRemoved: z.number().optional(),
|
|
30
|
+
tokensSaved: z.number().optional(),
|
|
31
|
+
// Context compaction metadata
|
|
32
|
+
tokensBeforeCompaction: z.number().optional(),
|
|
33
|
+
summaryTokens: z.number().optional(),
|
|
34
|
+
// Tool response compaction metadata
|
|
35
|
+
originalTokens: z.number().optional(),
|
|
36
|
+
finalTokens: z.number().optional(),
|
|
37
|
+
truncationWarning: z.string().optional(),
|
|
38
|
+
})
|
|
39
|
+
.passthrough()
|
|
40
|
+
.optional(),
|
|
41
|
+
// From error notification
|
|
42
|
+
error: z.string().optional(),
|
|
43
|
+
// Timestamps
|
|
44
|
+
triggeredAt: z.number().optional(),
|
|
45
|
+
completedAt: z.number().optional(),
|
|
46
|
+
// Position in content where the hook was triggered (for inline rendering)
|
|
47
|
+
contentPosition: z.number().optional(),
|
|
48
|
+
});
|
|
49
|
+
export { HookType, HookNotification };
|
|
13
50
|
/**
|
|
14
51
|
* Display message schema (UI representation of messages)
|
|
15
52
|
*/
|
|
@@ -22,6 +59,7 @@ export const DisplayMessage = z.object({
|
|
|
22
59
|
streamingStartTime: z.number().optional(), // Unix timestamp when streaming started
|
|
23
60
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
24
61
|
toolCalls: z.array(ToolCallSchema).optional(),
|
|
62
|
+
hookNotifications: z.array(HookNotificationDisplay).optional(), // Hook notifications for this message
|
|
25
63
|
tokenUsage: TokenUsageSchema.optional(), // Token usage for this message
|
|
26
64
|
images: z.array(DisplayImageAttachment).optional(), // Image attachments for user messages
|
|
27
65
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type LogEntry } from "@townco/core";
|
|
2
2
|
import type { TodoItem } from "../../gui/components/TodoListItem.js";
|
|
3
|
+
import type { HookNotification } from "../../sdk/schemas/message.js";
|
|
3
4
|
import type { ConnectionStatus, DisplayMessage, InputState } from "../schemas/index.js";
|
|
4
5
|
import type { ToolCall, ToolCallUpdate } from "../schemas/tool-call.js";
|
|
5
6
|
/**
|
|
@@ -19,6 +20,7 @@ export interface ContextSize {
|
|
|
19
20
|
toolResultsTokens: number;
|
|
20
21
|
totalEstimated: number;
|
|
21
22
|
llmReportedInputTokens?: number;
|
|
23
|
+
modelContextWindow?: number;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* Chat store state
|
|
@@ -59,6 +61,7 @@ export interface ChatStore {
|
|
|
59
61
|
updateToolCall: (sessionId: string, update: ToolCallUpdate) => void;
|
|
60
62
|
addToolCallToCurrentMessage: (toolCall: ToolCall) => void;
|
|
61
63
|
updateToolCallInCurrentMessage: (update: ToolCallUpdate) => void;
|
|
64
|
+
addHookNotificationToCurrentMessage: (notification: HookNotification) => void;
|
|
62
65
|
setInputValue: (value: string) => void;
|
|
63
66
|
setInputSubmitting: (submitting: boolean) => void;
|
|
64
67
|
addFileAttachment: (file: InputState["attachedFiles"][number]) => void;
|
|
@@ -4,6 +4,43 @@ import { mergeToolCallUpdate } from "../schemas/tool-call.js";
|
|
|
4
4
|
const logger = createLogger("chat-store", "debug");
|
|
5
5
|
// Constants to avoid creating new empty arrays
|
|
6
6
|
const EMPTY_TODOS = [];
|
|
7
|
+
/**
|
|
8
|
+
* Helper to create a HookNotificationDisplay from a HookNotification
|
|
9
|
+
*/
|
|
10
|
+
function createHookNotificationDisplay(notification) {
|
|
11
|
+
const base = {
|
|
12
|
+
id: `hook_${Date.now()}_${notification.hookType}_${notification.callback}`,
|
|
13
|
+
hookType: notification.hookType,
|
|
14
|
+
callback: notification.callback,
|
|
15
|
+
};
|
|
16
|
+
switch (notification.type) {
|
|
17
|
+
case "hook_triggered":
|
|
18
|
+
return {
|
|
19
|
+
...base,
|
|
20
|
+
status: "triggered",
|
|
21
|
+
threshold: notification.threshold,
|
|
22
|
+
currentPercentage: notification.currentPercentage,
|
|
23
|
+
// Use timestamp from backend if available, fallback to Date.now()
|
|
24
|
+
triggeredAt: notification.triggeredAt ?? Date.now(),
|
|
25
|
+
};
|
|
26
|
+
case "hook_completed":
|
|
27
|
+
return {
|
|
28
|
+
...base,
|
|
29
|
+
status: "completed",
|
|
30
|
+
metadata: notification.metadata,
|
|
31
|
+
// Use timestamp from backend if available, fallback to Date.now()
|
|
32
|
+
completedAt: notification.completedAt ?? Date.now(),
|
|
33
|
+
};
|
|
34
|
+
case "hook_error":
|
|
35
|
+
return {
|
|
36
|
+
...base,
|
|
37
|
+
status: "error",
|
|
38
|
+
error: notification.error,
|
|
39
|
+
// Use timestamp from backend if available, fallback to Date.now()
|
|
40
|
+
completedAt: notification.completedAt ?? Date.now(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
7
44
|
// Cache for memoized todos to prevent infinite re-render loops
|
|
8
45
|
let cachedTodos = {
|
|
9
46
|
sessionId: null,
|
|
@@ -267,6 +304,102 @@ export const useChatStore = create((set) => ({
|
|
|
267
304
|
};
|
|
268
305
|
return { messages };
|
|
269
306
|
}),
|
|
307
|
+
addHookNotificationToCurrentMessage: (notification) => set((state) => {
|
|
308
|
+
// Find the most recent assistant message
|
|
309
|
+
const lastAssistantIndex = state.messages.findLastIndex((msg) => msg.role === "assistant");
|
|
310
|
+
if (lastAssistantIndex === -1) {
|
|
311
|
+
// No assistant message exists yet - create one with the hook notification
|
|
312
|
+
logger.debug("No assistant message found, creating one for hook notification");
|
|
313
|
+
const hookDisplay = {
|
|
314
|
+
...createHookNotificationDisplay(notification),
|
|
315
|
+
contentPosition: 0, // Hook at the start of the message
|
|
316
|
+
};
|
|
317
|
+
const newMessage = {
|
|
318
|
+
id: `msg_${Date.now()}_assistant`,
|
|
319
|
+
role: "assistant",
|
|
320
|
+
content: "",
|
|
321
|
+
timestamp: new Date().toISOString(),
|
|
322
|
+
isStreaming: false,
|
|
323
|
+
hookNotifications: [hookDisplay],
|
|
324
|
+
};
|
|
325
|
+
return { messages: [...state.messages, newMessage] };
|
|
326
|
+
}
|
|
327
|
+
const messages = [...state.messages];
|
|
328
|
+
const lastAssistantMsg = messages[lastAssistantIndex];
|
|
329
|
+
if (!lastAssistantMsg)
|
|
330
|
+
return state;
|
|
331
|
+
const existingNotifications = lastAssistantMsg.hookNotifications || [];
|
|
332
|
+
// Track the content position where this hook was triggered (for inline rendering)
|
|
333
|
+
const contentPosition = lastAssistantMsg.content.length;
|
|
334
|
+
if (notification.type === "hook_triggered") {
|
|
335
|
+
// Store triggered notification immediately to show loading state
|
|
336
|
+
logger.debug("Adding hook_triggered notification for loading state", {
|
|
337
|
+
hookType: notification.hookType,
|
|
338
|
+
callback: notification.callback,
|
|
339
|
+
contentPosition,
|
|
340
|
+
});
|
|
341
|
+
const hookDisplay = {
|
|
342
|
+
...createHookNotificationDisplay(notification),
|
|
343
|
+
contentPosition,
|
|
344
|
+
};
|
|
345
|
+
const updatedNotifications = [...existingNotifications, hookDisplay];
|
|
346
|
+
messages[lastAssistantIndex] = {
|
|
347
|
+
...lastAssistantMsg,
|
|
348
|
+
hookNotifications: updatedNotifications,
|
|
349
|
+
};
|
|
350
|
+
return { messages };
|
|
351
|
+
}
|
|
352
|
+
// For completed/error: find and merge with existing triggered notification
|
|
353
|
+
let updatedNotifications;
|
|
354
|
+
const existingIndex = existingNotifications.findIndex((n) => n.hookType === notification.hookType &&
|
|
355
|
+
n.callback === notification.callback &&
|
|
356
|
+
n.status === "triggered");
|
|
357
|
+
if (existingIndex !== -1) {
|
|
358
|
+
// Merge: preserve triggered data (threshold, currentPercentage, triggeredAt),
|
|
359
|
+
// overlay completion data
|
|
360
|
+
const existing = existingNotifications[existingIndex];
|
|
361
|
+
updatedNotifications = [...existingNotifications];
|
|
362
|
+
// Use backend timestamp if available, fallback to Date.now()
|
|
363
|
+
const completedAt = notification.type === "hook_completed" ||
|
|
364
|
+
notification.type === "hook_error"
|
|
365
|
+
? (notification.completedAt ?? Date.now())
|
|
366
|
+
: Date.now();
|
|
367
|
+
updatedNotifications[existingIndex] = {
|
|
368
|
+
...existing, // Preserves threshold, currentPercentage, triggeredAt
|
|
369
|
+
status: notification.type === "hook_completed" ? "completed" : "error",
|
|
370
|
+
completedAt,
|
|
371
|
+
...(notification.type === "hook_completed" && notification.metadata
|
|
372
|
+
? { metadata: notification.metadata }
|
|
373
|
+
: {}),
|
|
374
|
+
...(notification.type === "hook_error"
|
|
375
|
+
? { error: notification.error }
|
|
376
|
+
: {}),
|
|
377
|
+
};
|
|
378
|
+
logger.debug("Merged hook notification with triggered state", {
|
|
379
|
+
hookType: notification.hookType,
|
|
380
|
+
status: updatedNotifications[existingIndex]?.status,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
// No triggered notification found - add completed/error directly
|
|
385
|
+
// This handles cases where triggered was missed or hooks complete very fast
|
|
386
|
+
const hookDisplay = {
|
|
387
|
+
...createHookNotificationDisplay(notification),
|
|
388
|
+
contentPosition,
|
|
389
|
+
};
|
|
390
|
+
updatedNotifications = [...existingNotifications, hookDisplay];
|
|
391
|
+
logger.debug("Added hook notification without prior triggered state", {
|
|
392
|
+
hookType: notification.hookType,
|
|
393
|
+
callback: notification.callback,
|
|
394
|
+
contentPosition,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
messages[lastAssistantIndex] = {
|
|
398
|
+
...lastAssistantMsg,
|
|
399
|
+
hookNotifications: updatedNotifications,
|
|
400
|
+
};
|
|
401
|
+
return { messages };
|
|
402
|
+
}),
|
|
270
403
|
updateToolCall: (sessionId, update) => set((state) => {
|
|
271
404
|
const sessionToolCalls = state.toolCalls[sessionId] || [];
|
|
272
405
|
const existingIndex = sessionToolCalls.findIndex((tc) => tc.id === update.id);
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { AnimatePresence, motion, useMotionValue } from "framer-motion";
|
|
3
3
|
import { ArrowDown } from "lucide-react";
|
|
4
4
|
import * as React from "react";
|
|
5
|
+
import { useScrollToBottom } from "../hooks/use-scroll-to-bottom.js";
|
|
5
6
|
import { motionEasing } from "../lib/motion.js";
|
|
6
7
|
import { cn } from "../lib/utils.js";
|
|
7
8
|
import { Toaster } from "./Sonner.js";
|
|
@@ -60,60 +61,19 @@ const ChatLayoutBody = React.forwardRef(({ showToaster = true, className, childr
|
|
|
60
61
|
});
|
|
61
62
|
ChatLayoutBody.displayName = "ChatLayout.Body";
|
|
62
63
|
const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChange, showScrollToBottom = true, initialScrollToBottom = true, ...props }, ref) => {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const hasInitialScrolledRef = React.useRef(false); // Track if initial scroll has happened
|
|
64
|
+
const { containerRef, endRef, isAtBottom, scrollToBottom } = useScrollToBottom();
|
|
65
|
+
const hasInitialScrolledRef = React.useRef(false);
|
|
66
66
|
// Merge refs
|
|
67
|
-
React.useImperativeHandle(ref, () =>
|
|
68
|
-
//
|
|
69
|
-
const checkScrollPosition = React.useCallback(() => {
|
|
70
|
-
const container = scrollContainerRef.current;
|
|
71
|
-
if (!container)
|
|
72
|
-
return false;
|
|
73
|
-
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
74
|
-
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
75
|
-
const isAtBottom = distanceFromBottom < 100; // 100px threshold
|
|
76
|
-
setShowScrollButton(!isAtBottom && showScrollToBottom);
|
|
77
|
-
onScrollChange?.(isAtBottom);
|
|
78
|
-
return isAtBottom;
|
|
79
|
-
}, [onScrollChange, showScrollToBottom]);
|
|
80
|
-
// Handle scroll events - update button visibility
|
|
81
|
-
const handleScroll = React.useCallback(() => {
|
|
82
|
-
checkScrollPosition();
|
|
83
|
-
}, [checkScrollPosition]);
|
|
84
|
-
// Scroll to bottom function (for button click)
|
|
85
|
-
const scrollToBottom = React.useCallback((smooth = true) => {
|
|
86
|
-
const container = scrollContainerRef.current;
|
|
87
|
-
if (!container)
|
|
88
|
-
return;
|
|
89
|
-
container.scrollTo({
|
|
90
|
-
top: container.scrollHeight,
|
|
91
|
-
behavior: smooth ? "smooth" : "auto",
|
|
92
|
-
});
|
|
93
|
-
}, []);
|
|
94
|
-
// Auto-scroll when content changes ONLY if user is currently at the bottom
|
|
67
|
+
React.useImperativeHandle(ref, () => containerRef.current);
|
|
68
|
+
// Notify parent of scroll position changes
|
|
95
69
|
React.useEffect(() => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return;
|
|
99
|
-
// Check if user is CURRENTLY at the bottom (not just "was" at bottom)
|
|
100
|
-
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
101
|
-
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
102
|
-
const isCurrentlyAtBottom = distanceFromBottom < 100;
|
|
103
|
-
// Only auto-scroll if user is at the bottom right now
|
|
104
|
-
if (isCurrentlyAtBottom) {
|
|
105
|
-
requestAnimationFrame(() => {
|
|
106
|
-
container.scrollTop = container.scrollHeight;
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
// Update the scroll button visibility
|
|
110
|
-
setShowScrollButton(!isCurrentlyAtBottom && showScrollToBottom);
|
|
111
|
-
}, [children, showScrollToBottom]);
|
|
70
|
+
onScrollChange?.(isAtBottom);
|
|
71
|
+
}, [isAtBottom, onScrollChange]);
|
|
112
72
|
// Scroll to bottom on initial mount only (for session replay)
|
|
113
73
|
React.useEffect(() => {
|
|
114
74
|
if (!initialScrollToBottom)
|
|
115
75
|
return undefined;
|
|
116
|
-
const container =
|
|
76
|
+
const container = containerRef.current;
|
|
117
77
|
if (!container)
|
|
118
78
|
return undefined;
|
|
119
79
|
// Only scroll on initial mount, not on subsequent renders
|
|
@@ -126,8 +86,10 @@ const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChan
|
|
|
126
86
|
return () => clearTimeout(timeout);
|
|
127
87
|
}
|
|
128
88
|
return undefined;
|
|
129
|
-
}, [initialScrollToBottom]);
|
|
130
|
-
|
|
89
|
+
}, [initialScrollToBottom, containerRef]);
|
|
90
|
+
// Show scroll button when not at bottom
|
|
91
|
+
const showScrollButton = !isAtBottom && showScrollToBottom;
|
|
92
|
+
return (_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsxs("div", { ref: containerRef, className: cn("h-full overflow-y-auto flex flex-col", className), ...props, children: [_jsx("div", { className: "mx-auto max-w-chat flex-1 w-full flex flex-col", children: children }), _jsx("div", { ref: endRef, className: "min-h-[24px] min-w-[24px] shrink-0" })] }), showScrollButton && (_jsx("button", { type: "button", onClick: () => scrollToBottom("smooth"), className: cn("absolute bottom-4 left-1/2 -translate-x-1/2 z-10", "flex items-center justify-center p-2 rounded-full", "bg-card border border-border shadow-lg", "text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-all duration-200 ease-in-out", "animate-in fade-in slide-in-from-bottom-2"), "aria-label": "Scroll to bottom", children: _jsx(ArrowDown, { className: "size-4" }) }))] }));
|
|
131
93
|
});
|
|
132
94
|
ChatLayoutMessages.displayName = "ChatLayout.Messages";
|
|
133
95
|
const ChatLayoutFooter = React.forwardRef(({ className, children, ...props }, ref) => {
|
|
@@ -33,7 +33,7 @@ function OpenFilesButton({ children, }) {
|
|
|
33
33
|
// Note: Keyboard shortcut (Cmd+B / Ctrl+B) for toggling the right panel
|
|
34
34
|
// is now handled internally by ChatLayout.Root
|
|
35
35
|
// Chat input with attachment handling
|
|
36
|
-
function ChatInputWithAttachments({ client, startSession, placeholder, latestContextSize,
|
|
36
|
+
function ChatInputWithAttachments({ client, startSession, placeholder, latestContextSize, commandMenuItems, }) {
|
|
37
37
|
const attachedFiles = useChatStore((state) => state.input.attachedFiles);
|
|
38
38
|
const addFileAttachment = useChatStore((state) => state.addFileAttachment);
|
|
39
39
|
const removeFileAttachment = useChatStore((state) => state.removeFileAttachment);
|
|
@@ -42,7 +42,7 @@ function ChatInputWithAttachments({ client, startSession, placeholder, latestCon
|
|
|
42
42
|
addFileAttachment(file);
|
|
43
43
|
}
|
|
44
44
|
};
|
|
45
|
-
return (_jsxs(ChatInputRoot, { client: client, startSession: startSession, children: [_jsx(ChatInputCommandMenu, { commands: commandMenuItems }), attachedFiles.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 p-3 border-b border-border", children: attachedFiles.map((file, index) => (_jsxs("div", { className: "relative group rounded-md overflow-hidden border border-border", children: [_jsx("img", { src: `data:${file.mimeType};base64,${file.data}`, alt: file.name, className: "h-20 w-20 object-cover" }), _jsx("button", { type: "button", onClick: () => removeFileAttachment(index), className: "absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-background opacity-0 group-hover:opacity-100 transition-opacity", children: _jsx(X, { className: "size-3" }) })] }, index))) })), _jsx(ChatInputField, { placeholder: placeholder, autoFocus: true, onFilesDropped: handleFilesSelected }), _jsxs(ChatInputToolbar, { children: [_jsxs("div", { className: "flex items-baseline gap-1", children: [_jsx(ChatInputActions, {}), _jsx(ChatInputAttachment, { onFilesSelected: handleFilesSelected }), latestContextSize != null && (_jsx(ContextUsageButton, { contextSize: latestContextSize
|
|
45
|
+
return (_jsxs(ChatInputRoot, { client: client, startSession: startSession, children: [_jsx(ChatInputCommandMenu, { commands: commandMenuItems }), attachedFiles.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 p-3 border-b border-border", children: attachedFiles.map((file, index) => (_jsxs("div", { className: "relative group rounded-md overflow-hidden border border-border", children: [_jsx("img", { src: `data:${file.mimeType};base64,${file.data}`, alt: file.name, className: "h-20 w-20 object-cover" }), _jsx("button", { type: "button", onClick: () => removeFileAttachment(index), className: "absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-background opacity-0 group-hover:opacity-100 transition-opacity", children: _jsx(X, { className: "size-3" }) })] }, index))) })), _jsx(ChatInputField, { placeholder: placeholder, autoFocus: true, onFilesDropped: handleFilesSelected }), _jsxs(ChatInputToolbar, { children: [_jsxs("div", { className: "flex items-baseline gap-1", children: [_jsx(ChatInputActions, {}), _jsx(ChatInputAttachment, { onFilesSelected: handleFilesSelected }), latestContextSize != null && (_jsx(ContextUsageButton, { contextSize: latestContextSize }))] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(ChatInputVoiceInput, {}), _jsx(ChatInputSubmit, { children: _jsx(ArrowUp, { className: "size-4" }) })] })] })] }));
|
|
46
46
|
}
|
|
47
47
|
// Controlled Tabs component for the aside panel
|
|
48
48
|
function AsideTabs({ todos, tools, mcps, subagents, }) {
|
|
@@ -70,7 +70,6 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
|
|
|
70
70
|
const { messages, sendMessage } = useChatMessages(client, startSession);
|
|
71
71
|
useToolCalls(client); // Still need to subscribe to tool call events
|
|
72
72
|
const error = useChatStore((state) => state.error);
|
|
73
|
-
const currentModel = useChatStore((state) => state.currentModel);
|
|
74
73
|
const [agentName, setAgentName] = useState("Agent");
|
|
75
74
|
const [agentDescription, setAgentDescription] = useState("This research agent can help you find and summarize information, analyze sources, track tasks, and answer questions about your research. Start by typing a message below to begin your investigation.");
|
|
76
75
|
const [suggestedPrompts, setSuggestedPrompts] = useState([
|
|
@@ -242,5 +241,5 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
|
|
|
242
241
|
: "mt-6";
|
|
243
242
|
}
|
|
244
243
|
return (_jsx(Message, { message: message, className: spacingClass, isLastMessage: index === messages.length - 1, children: _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }) }, message.id));
|
|
245
|
-
}) })) }), _jsx(ChatLayout.Footer, { children: _jsx(ChatInputWithAttachments, { client: client, startSession: startSession, placeholder: placeholder, latestContextSize: latestContextSize,
|
|
244
|
+
}) })) }), _jsx(ChatLayout.Footer, { children: _jsx(ChatInputWithAttachments, { client: client, startSession: startSession, placeholder: placeholder, latestContextSize: latestContextSize, commandMenuItems: commandMenuItems }) })] })] }), isLargeScreen && (_jsx(ChatLayout.Aside, { breakpoint: "lg", children: _jsx(AsideTabs, { todos: todos, tools: agentTools, mcps: agentMcps, subagents: agentSubagents }) }))] }) })] }));
|
|
246
245
|
}
|
|
@@ -9,9 +9,9 @@ export interface ContextSize {
|
|
|
9
9
|
toolResultsTokens: number;
|
|
10
10
|
totalEstimated: number;
|
|
11
11
|
llmReportedInputTokens?: number;
|
|
12
|
+
modelContextWindow?: number;
|
|
12
13
|
}
|
|
13
14
|
export interface ContextUsageButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
14
15
|
contextSize: ContextSize;
|
|
15
|
-
modelContextWindow: number;
|
|
16
16
|
}
|
|
17
17
|
export declare const ContextUsageButton: React.ForwardRefExoticComponent<ContextUsageButtonProps & React.RefAttributes<HTMLButtonElement>>;
|
|
@@ -3,9 +3,13 @@ import * as React from "react";
|
|
|
3
3
|
import { cn } from "../lib/utils.js";
|
|
4
4
|
import { Button } from "./Button.js";
|
|
5
5
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "./Tooltip.js";
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// Default context window for backward compatibility (should not be used in practice)
|
|
7
|
+
const DEFAULT_MODEL_CONTEXT_WINDOW = 200000;
|
|
8
|
+
export const ContextUsageButton = React.forwardRef(({ contextSize, className, ...props }, ref) => {
|
|
9
|
+
// Use max of estimated and LLM-reported tokens (same logic as backend hook executor)
|
|
8
10
|
const actualTokens = Math.max(contextSize.totalEstimated, contextSize.llmReportedInputTokens ?? 0);
|
|
11
|
+
// Use model context window from backend, or default for backward compatibility
|
|
12
|
+
const modelContextWindow = contextSize.modelContextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
|
|
9
13
|
const percentage = (actualTokens / modelContextWindow) * 100;
|
|
10
14
|
const formattedPercentage = `${percentage.toFixed(1)}%`;
|
|
11
15
|
// Clamp percentage between 0 and 100 for display
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HookNotificationDisplay } from "../../core/schemas/chat.js";
|
|
2
|
+
export interface HookNotificationProps {
|
|
3
|
+
notification: HookNotificationDisplay;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* HookNotification component - displays a hook notification inline with messages
|
|
7
|
+
* Shows triggered (loading), completed, or error states
|
|
8
|
+
*/
|
|
9
|
+
export declare function HookNotification({ notification }: HookNotificationProps): import("react/jsx-runtime").JSX.Element;
|