@townco/ui 0.1.36 → 0.1.38
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-input.d.ts +1 -1
- package/dist/core/hooks/use-chat-input.js +2 -2
- package/dist/core/hooks/use-chat-messages.d.ts +3 -1
- package/dist/core/hooks/use-chat-messages.js +36 -7
- package/dist/core/hooks/use-chat-session.d.ts +1 -1
- package/dist/core/hooks/use-chat-session.js +4 -7
- package/dist/core/hooks/use-tool-calls.d.ts +2 -0
- package/dist/core/lib/logger.d.ts +24 -0
- package/dist/core/lib/logger.js +108 -0
- package/dist/core/schemas/chat.d.ts +4 -0
- package/dist/core/schemas/tool-call.d.ts +2 -0
- package/dist/core/schemas/tool-call.js +4 -0
- package/dist/gui/components/ChatEmptyState.d.ts +6 -0
- package/dist/gui/components/ChatEmptyState.js +2 -2
- package/dist/gui/components/ChatInput.d.ts +4 -0
- package/dist/gui/components/ChatInput.js +12 -7
- package/dist/gui/components/ChatLayout.js +102 -8
- package/dist/gui/components/ChatPanelTabContent.d.ts +1 -1
- package/dist/gui/components/ChatPanelTabContent.js +10 -3
- package/dist/gui/components/ChatView.js +45 -14
- package/dist/gui/components/ContextUsageButton.d.ts +7 -0
- package/dist/gui/components/ContextUsageButton.js +18 -0
- package/dist/gui/components/FileSystemItem.js +6 -7
- package/dist/gui/components/FileSystemView.js +3 -3
- package/dist/gui/components/InlineToolCallSummary.d.ts +14 -0
- package/dist/gui/components/InlineToolCallSummary.js +110 -0
- package/dist/gui/components/InlineToolCallSummaryACP.d.ts +15 -0
- package/dist/gui/components/InlineToolCallSummaryACP.js +90 -0
- package/dist/gui/components/MessageContent.js +1 -1
- package/dist/gui/components/Response.js +2 -2
- package/dist/gui/components/SourceListItem.js +9 -1
- package/dist/gui/components/TodoListItem.js +19 -2
- package/dist/gui/components/ToolCall.js +21 -2
- package/dist/gui/components/Tooltip.d.ts +7 -0
- package/dist/gui/components/Tooltip.js +10 -0
- package/dist/gui/components/index.d.ts +4 -0
- package/dist/gui/components/index.js +5 -0
- package/dist/gui/components/tool-call-summary.d.ts +44 -0
- package/dist/gui/components/tool-call-summary.js +67 -0
- package/dist/gui/data/mockSourceData.d.ts +10 -0
- package/dist/gui/data/mockSourceData.js +40 -0
- package/dist/gui/data/mockTodoData.d.ts +10 -0
- package/dist/gui/data/mockTodoData.js +35 -0
- package/dist/gui/examples/FileSystemDemo.d.ts +5 -0
- package/dist/gui/examples/FileSystemDemo.js +24 -0
- package/dist/gui/examples/FileSystemExample.d.ts +17 -0
- package/dist/gui/examples/FileSystemExample.js +94 -0
- package/dist/sdk/schemas/session.d.ts +2 -0
- package/dist/sdk/transports/http.d.ts +1 -0
- package/dist/sdk/transports/http.js +40 -8
- package/dist/sdk/transports/stdio.js +13 -0
- package/dist/tui/components/ChatView.js +5 -5
- package/package.json +3 -3
|
@@ -2,7 +2,7 @@ import type { AcpClient } from "../../sdk/client/index.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* Hook for managing chat input
|
|
4
4
|
*/
|
|
5
|
-
export declare function useChatInput(client: AcpClient | null): {
|
|
5
|
+
export declare function useChatInput(client: AcpClient | null, startSession: () => Promise<string | null>): {
|
|
6
6
|
value: string;
|
|
7
7
|
isSubmitting: boolean;
|
|
8
8
|
attachedFiles: {
|
|
@@ -6,13 +6,13 @@ const logger = createLogger("use-chat-input", "debug");
|
|
|
6
6
|
/**
|
|
7
7
|
* Hook for managing chat input
|
|
8
8
|
*/
|
|
9
|
-
export function useChatInput(client) {
|
|
9
|
+
export function useChatInput(client, startSession) {
|
|
10
10
|
const input = useChatStore((state) => state.input);
|
|
11
11
|
const setInputValue = useChatStore((state) => state.setInputValue);
|
|
12
12
|
const setInputSubmitting = useChatStore((state) => state.setInputSubmitting);
|
|
13
13
|
const addFileAttachment = useChatStore((state) => state.addFileAttachment);
|
|
14
14
|
const removeFileAttachment = useChatStore((state) => state.removeFileAttachment);
|
|
15
|
-
const { sendMessage } = useChatMessages(client);
|
|
15
|
+
const { sendMessage } = useChatMessages(client, startSession);
|
|
16
16
|
/**
|
|
17
17
|
* Handle input value change
|
|
18
18
|
*/
|
|
@@ -2,7 +2,7 @@ import type { AcpClient } from "../../sdk/client/index.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* Hook for managing chat messages
|
|
4
4
|
*/
|
|
5
|
-
export declare function useChatMessages(client: AcpClient | null): {
|
|
5
|
+
export declare function useChatMessages(client: AcpClient | null, startSession: () => Promise<string | null>): {
|
|
6
6
|
messages: {
|
|
7
7
|
id: string;
|
|
8
8
|
role: "user" | "assistant" | "system";
|
|
@@ -16,6 +16,8 @@ export declare function useChatMessages(client: AcpClient | null): {
|
|
|
16
16
|
title: string;
|
|
17
17
|
kind: "read" | "edit" | "delete" | "move" | "search" | "execute" | "think" | "fetch" | "switch_mode" | "other";
|
|
18
18
|
status: "pending" | "in_progress" | "completed" | "failed";
|
|
19
|
+
prettyName?: string | undefined;
|
|
20
|
+
icon?: string | undefined;
|
|
19
21
|
contentPosition?: number | undefined;
|
|
20
22
|
locations?: {
|
|
21
23
|
path: string;
|
|
@@ -5,7 +5,7 @@ const logger = createLogger("use-chat-messages", "debug");
|
|
|
5
5
|
/**
|
|
6
6
|
* Hook for managing chat messages
|
|
7
7
|
*/
|
|
8
|
-
export function useChatMessages(client) {
|
|
8
|
+
export function useChatMessages(client, startSession) {
|
|
9
9
|
const messages = useChatStore((state) => state.messages);
|
|
10
10
|
const isStreaming = useChatStore((state) => state.isStreaming);
|
|
11
11
|
const sessionId = useChatStore((state) => state.sessionId);
|
|
@@ -23,11 +23,23 @@ export function useChatMessages(client) {
|
|
|
23
23
|
setError("No client available");
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
|
-
if
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
// Create session lazily if it doesn't exist
|
|
27
|
+
let activeSessionId = sessionId;
|
|
28
|
+
if (!activeSessionId) {
|
|
29
|
+
logger.info("Creating new session before sending first message");
|
|
30
|
+
const newSessionId = await startSession();
|
|
31
|
+
if (!newSessionId) {
|
|
32
|
+
logger.error("Failed to create session");
|
|
33
|
+
setError("Failed to create session");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
activeSessionId = newSessionId;
|
|
37
|
+
logger.info("Session created successfully", {
|
|
38
|
+
sessionId: newSessionId,
|
|
39
|
+
});
|
|
30
40
|
}
|
|
41
|
+
// Create assistant message ID outside try block so it's accessible in catch
|
|
42
|
+
const assistantMessageId = `msg_${Date.now()}_assistant`;
|
|
31
43
|
try {
|
|
32
44
|
// Start streaming and track time immediately
|
|
33
45
|
const startTime = Date.now();
|
|
@@ -43,7 +55,6 @@ export function useChatMessages(client) {
|
|
|
43
55
|
};
|
|
44
56
|
addMessage(userMessage);
|
|
45
57
|
// Create placeholder for assistant message BEFORE sending
|
|
46
|
-
const assistantMessageId = `msg_${Date.now()}_assistant`;
|
|
47
58
|
const assistantMessage = {
|
|
48
59
|
id: assistantMessageId,
|
|
49
60
|
role: "assistant",
|
|
@@ -57,7 +68,7 @@ export function useChatMessages(client) {
|
|
|
57
68
|
const messageStream = client.receiveMessages();
|
|
58
69
|
// Send ONLY the new message (not full history)
|
|
59
70
|
// The agent backend now manages conversation context
|
|
60
|
-
client.sendMessage(content,
|
|
71
|
+
client.sendMessage(content, activeSessionId).catch((error) => {
|
|
61
72
|
const message = error instanceof Error ? error.message : String(error);
|
|
62
73
|
setError(message);
|
|
63
74
|
setIsStreaming(false);
|
|
@@ -65,6 +76,7 @@ export function useChatMessages(client) {
|
|
|
65
76
|
});
|
|
66
77
|
// Listen for streaming chunks
|
|
67
78
|
let accumulatedContent = "";
|
|
79
|
+
let streamCompleted = false;
|
|
68
80
|
for await (const chunk of messageStream) {
|
|
69
81
|
if (chunk.tokenUsage) {
|
|
70
82
|
logger.debug("chunk.tokenUsage", {
|
|
@@ -81,6 +93,7 @@ export function useChatMessages(client) {
|
|
|
81
93
|
});
|
|
82
94
|
setIsStreaming(false);
|
|
83
95
|
setStreamingStartTime(null); // Clear global streaming start time
|
|
96
|
+
streamCompleted = true;
|
|
84
97
|
break;
|
|
85
98
|
}
|
|
86
99
|
else {
|
|
@@ -96,16 +109,32 @@ export function useChatMessages(client) {
|
|
|
96
109
|
}
|
|
97
110
|
}
|
|
98
111
|
}
|
|
112
|
+
// Ensure streaming state is cleared even if no explicit isComplete was received
|
|
113
|
+
if (!streamCompleted) {
|
|
114
|
+
logger.warn("Stream ended without isComplete flag");
|
|
115
|
+
updateMessage(assistantMessageId, {
|
|
116
|
+
isStreaming: false,
|
|
117
|
+
streamingStartTime: undefined,
|
|
118
|
+
});
|
|
119
|
+
setIsStreaming(false);
|
|
120
|
+
setStreamingStartTime(null);
|
|
121
|
+
}
|
|
99
122
|
}
|
|
100
123
|
catch (error) {
|
|
101
124
|
const message = error instanceof Error ? error.message : String(error);
|
|
102
125
|
setError(message);
|
|
103
126
|
setIsStreaming(false);
|
|
104
127
|
setStreamingStartTime(null); // Clear streaming start time on error
|
|
128
|
+
// Ensure the assistant message isStreaming is set to false
|
|
129
|
+
updateMessage(assistantMessageId, {
|
|
130
|
+
isStreaming: false,
|
|
131
|
+
streamingStartTime: undefined,
|
|
132
|
+
});
|
|
105
133
|
}
|
|
106
134
|
}, [
|
|
107
135
|
client,
|
|
108
136
|
sessionId,
|
|
137
|
+
startSession,
|
|
109
138
|
addMessage,
|
|
110
139
|
updateMessage,
|
|
111
140
|
setIsStreaming,
|
|
@@ -7,6 +7,6 @@ export declare function useChatSession(client: AcpClient | null, initialSessionI
|
|
|
7
7
|
sessionId: string | null;
|
|
8
8
|
connect: () => Promise<void>;
|
|
9
9
|
loadSession: (sessionIdToLoad: string) => Promise<void>;
|
|
10
|
-
startSession: () => Promise<
|
|
10
|
+
startSession: () => Promise<string | null>;
|
|
11
11
|
disconnect: () => Promise<void>;
|
|
12
12
|
};
|
|
@@ -149,11 +149,12 @@ export function useChatSession(client, initialSessionId) {
|
|
|
149
149
|
]);
|
|
150
150
|
/**
|
|
151
151
|
* Start a new session
|
|
152
|
+
* @returns The new session ID, or null if creation failed
|
|
152
153
|
*/
|
|
153
154
|
const startSession = useCallback(async () => {
|
|
154
155
|
if (!client) {
|
|
155
156
|
setError("No client available");
|
|
156
|
-
return;
|
|
157
|
+
return null;
|
|
157
158
|
}
|
|
158
159
|
try {
|
|
159
160
|
const id = await client.startSession();
|
|
@@ -166,10 +167,12 @@ export function useChatSession(client, initialSessionId) {
|
|
|
166
167
|
url.searchParams.set("session", id);
|
|
167
168
|
window.history.pushState({}, "", url.toString());
|
|
168
169
|
}
|
|
170
|
+
return id;
|
|
169
171
|
}
|
|
170
172
|
catch (error) {
|
|
171
173
|
const message = error instanceof Error ? error.message : String(error);
|
|
172
174
|
setError(message);
|
|
175
|
+
return null;
|
|
173
176
|
}
|
|
174
177
|
}, [client, setSessionId, setError, clearMessages, resetTokens]);
|
|
175
178
|
/**
|
|
@@ -205,12 +208,6 @@ export function useChatSession(client, initialSessionId) {
|
|
|
205
208
|
connect();
|
|
206
209
|
}
|
|
207
210
|
}, [client, connectionStatus, initialSessionId, connect, loadSession]);
|
|
208
|
-
// Auto-start new session after connecting (only if no initial session)
|
|
209
|
-
useEffect(() => {
|
|
210
|
-
if (connectionStatus === "connected" && !sessionId && !initialSessionId) {
|
|
211
|
-
startSession();
|
|
212
|
-
}
|
|
213
|
-
}, [connectionStatus, sessionId, initialSessionId, startSession]);
|
|
214
211
|
return {
|
|
215
212
|
connectionStatus,
|
|
216
213
|
sessionId,
|
|
@@ -14,6 +14,8 @@ export declare function useToolCalls(client: AcpClient | null): {
|
|
|
14
14
|
title: string;
|
|
15
15
|
kind: "read" | "edit" | "delete" | "move" | "search" | "execute" | "think" | "fetch" | "switch_mode" | "other";
|
|
16
16
|
status: "pending" | "in_progress" | "completed" | "failed";
|
|
17
|
+
prettyName?: string | undefined;
|
|
18
|
+
icon?: string | undefined;
|
|
17
19
|
contentPosition?: number | undefined;
|
|
18
20
|
locations?: {
|
|
19
21
|
path: string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-compatible logger
|
|
3
|
+
* Outputs structured JSON logs to console with color-coding
|
|
4
|
+
*/
|
|
5
|
+
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
6
|
+
export declare class Logger {
|
|
7
|
+
private service;
|
|
8
|
+
private minLevel;
|
|
9
|
+
constructor(service: string, minLevel?: LogLevel);
|
|
10
|
+
private shouldLog;
|
|
11
|
+
private log;
|
|
12
|
+
trace(message: string, metadata?: Record<string, unknown>): void;
|
|
13
|
+
debug(message: string, metadata?: Record<string, unknown>): void;
|
|
14
|
+
info(message: string, metadata?: Record<string, unknown>): void;
|
|
15
|
+
warn(message: string, metadata?: Record<string, unknown>): void;
|
|
16
|
+
error(message: string, metadata?: Record<string, unknown>): void;
|
|
17
|
+
fatal(message: string, metadata?: Record<string, unknown>): void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Create a logger instance for a service
|
|
21
|
+
* @param service - Service name (e.g., "gui", "http-agent", "tui")
|
|
22
|
+
* @param minLevel - Minimum log level to display (default: "debug")
|
|
23
|
+
*/
|
|
24
|
+
export declare function createLogger(service: string, minLevel?: LogLevel): Logger;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-compatible logger
|
|
3
|
+
* Outputs structured JSON logs to console with color-coding
|
|
4
|
+
*/
|
|
5
|
+
const LOG_LEVELS = {
|
|
6
|
+
trace: 0,
|
|
7
|
+
debug: 1,
|
|
8
|
+
info: 2,
|
|
9
|
+
warn: 3,
|
|
10
|
+
error: 4,
|
|
11
|
+
fatal: 5,
|
|
12
|
+
};
|
|
13
|
+
const _LOG_COLORS = {
|
|
14
|
+
trace: "#6B7280", // gray
|
|
15
|
+
debug: "#3B82F6", // blue
|
|
16
|
+
info: "#10B981", // green
|
|
17
|
+
warn: "#F59E0B", // orange
|
|
18
|
+
error: "#EF4444", // red
|
|
19
|
+
fatal: "#DC2626", // dark red
|
|
20
|
+
};
|
|
21
|
+
const LOG_STYLES = {
|
|
22
|
+
trace: "color: #6B7280",
|
|
23
|
+
debug: "color: #3B82F6; font-weight: bold",
|
|
24
|
+
info: "color: #10B981; font-weight: bold",
|
|
25
|
+
warn: "color: #F59E0B; font-weight: bold",
|
|
26
|
+
error: "color: #EF4444; font-weight: bold",
|
|
27
|
+
fatal: "color: #DC2626; font-weight: bold; background: #FEE2E2",
|
|
28
|
+
};
|
|
29
|
+
export class Logger {
|
|
30
|
+
service;
|
|
31
|
+
minLevel;
|
|
32
|
+
constructor(service, minLevel = "debug") {
|
|
33
|
+
this.service = service;
|
|
34
|
+
this.minLevel = minLevel;
|
|
35
|
+
// In production, suppress trace and debug logs
|
|
36
|
+
if (typeof process !== "undefined" &&
|
|
37
|
+
process.env?.NODE_ENV === "production") {
|
|
38
|
+
this.minLevel = "info";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
shouldLog(level) {
|
|
42
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[this.minLevel];
|
|
43
|
+
}
|
|
44
|
+
log(level, message, metadata) {
|
|
45
|
+
if (!this.shouldLog(level)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const entry = {
|
|
49
|
+
timestamp: new Date().toISOString(),
|
|
50
|
+
level,
|
|
51
|
+
service: this.service,
|
|
52
|
+
message,
|
|
53
|
+
...(metadata && { metadata }),
|
|
54
|
+
};
|
|
55
|
+
// Console output with color-coding
|
|
56
|
+
const style = LOG_STYLES[level];
|
|
57
|
+
const levelUpper = level.toUpperCase().padEnd(5);
|
|
58
|
+
if (typeof console !== "undefined") {
|
|
59
|
+
// Format: [timestamp] [SERVICE] [LEVEL] message
|
|
60
|
+
const prefix = `%c[${entry.timestamp}] [${this.service}] [${levelUpper}]`;
|
|
61
|
+
const msg = metadata ? `${message} %o` : message;
|
|
62
|
+
switch (level) {
|
|
63
|
+
case "trace":
|
|
64
|
+
case "debug":
|
|
65
|
+
case "info":
|
|
66
|
+
console.log(prefix, style, msg, ...(metadata ? [metadata] : []));
|
|
67
|
+
break;
|
|
68
|
+
case "warn":
|
|
69
|
+
console.warn(prefix, style, msg, ...(metadata ? [metadata] : []));
|
|
70
|
+
break;
|
|
71
|
+
case "error":
|
|
72
|
+
case "fatal":
|
|
73
|
+
console.error(prefix, style, msg, ...(metadata ? [metadata] : []));
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Also log the structured JSON for debugging
|
|
78
|
+
if (level === "fatal" || level === "error") {
|
|
79
|
+
console.debug("Structured log:", JSON.stringify(entry));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
trace(message, metadata) {
|
|
83
|
+
this.log("trace", message, metadata);
|
|
84
|
+
}
|
|
85
|
+
debug(message, metadata) {
|
|
86
|
+
this.log("debug", message, metadata);
|
|
87
|
+
}
|
|
88
|
+
info(message, metadata) {
|
|
89
|
+
this.log("info", message, metadata);
|
|
90
|
+
}
|
|
91
|
+
warn(message, metadata) {
|
|
92
|
+
this.log("warn", message, metadata);
|
|
93
|
+
}
|
|
94
|
+
error(message, metadata) {
|
|
95
|
+
this.log("error", message, metadata);
|
|
96
|
+
}
|
|
97
|
+
fatal(message, metadata) {
|
|
98
|
+
this.log("fatal", message, metadata);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Create a logger instance for a service
|
|
103
|
+
* @param service - Service name (e.g., "gui", "http-agent", "tui")
|
|
104
|
+
* @param minLevel - Minimum log level to display (default: "debug")
|
|
105
|
+
*/
|
|
106
|
+
export function createLogger(service, minLevel = "debug") {
|
|
107
|
+
return new Logger(service, minLevel);
|
|
108
|
+
}
|
|
@@ -20,6 +20,8 @@ export declare const DisplayMessage: z.ZodObject<{
|
|
|
20
20
|
toolCalls: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
21
21
|
id: z.ZodString;
|
|
22
22
|
title: z.ZodString;
|
|
23
|
+
prettyName: z.ZodOptional<z.ZodString>;
|
|
24
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
23
25
|
kind: z.ZodEnum<{
|
|
24
26
|
read: "read";
|
|
25
27
|
edit: "edit";
|
|
@@ -116,6 +118,8 @@ export declare const ChatSessionState: z.ZodObject<{
|
|
|
116
118
|
toolCalls: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
117
119
|
id: z.ZodString;
|
|
118
120
|
title: z.ZodString;
|
|
121
|
+
prettyName: z.ZodOptional<z.ZodString>;
|
|
122
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
119
123
|
kind: z.ZodEnum<{
|
|
120
124
|
read: "read";
|
|
121
125
|
edit: "edit";
|
|
@@ -71,6 +71,8 @@ export type ToolCallContentBlock = z.infer<typeof ToolCallContentBlockSchema>;
|
|
|
71
71
|
export declare const ToolCallSchema: z.ZodObject<{
|
|
72
72
|
id: z.ZodString;
|
|
73
73
|
title: z.ZodString;
|
|
74
|
+
prettyName: z.ZodOptional<z.ZodString>;
|
|
75
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
74
76
|
kind: z.ZodEnum<{
|
|
75
77
|
read: "read";
|
|
76
78
|
edit: "edit";
|
|
@@ -75,6 +75,10 @@ export const ToolCallSchema = z.object({
|
|
|
75
75
|
id: z.string(),
|
|
76
76
|
/** Human-readable description of the operation */
|
|
77
77
|
title: z.string(),
|
|
78
|
+
/** Optional pretty name for the tool (e.g. "Web Search" instead of "web_search") */
|
|
79
|
+
prettyName: z.string().optional(),
|
|
80
|
+
/** Optional icon identifier for the tool (e.g. "Globe", "Search", "Edit") */
|
|
81
|
+
icon: z.string().optional(),
|
|
78
82
|
/** Category for UI presentation */
|
|
79
83
|
kind: ToolCallKindSchema,
|
|
80
84
|
/** Current execution status */
|
|
@@ -14,5 +14,11 @@ export interface ChatEmptyStateProps extends React.HTMLAttributes<HTMLDivElement
|
|
|
14
14
|
onPromptClick?: (prompt: string) => void;
|
|
15
15
|
/** Callback when guide is clicked */
|
|
16
16
|
onGuideClick?: () => void;
|
|
17
|
+
/** Callback when "Open Files" is clicked */
|
|
18
|
+
onOpenFiles?: () => void;
|
|
19
|
+
/** Callback when hovering over a prompt */
|
|
20
|
+
onPromptHover?: (prompt: string) => void;
|
|
21
|
+
/** Callback when mouse leaves a prompt */
|
|
22
|
+
onPromptLeave?: () => void;
|
|
17
23
|
}
|
|
18
24
|
export declare const ChatEmptyState: React.ForwardRefExoticComponent<ChatEmptyStateProps & React.RefAttributes<HTMLDivElement>>;
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { ChevronRight } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "../lib/utils.js";
|
|
5
|
-
export const ChatEmptyState = React.forwardRef(({ title, description, guideUrl, guideText = "Guide", suggestedPrompts = [], onPromptClick, onGuideClick, className, ...props }, ref) => {
|
|
5
|
+
export const ChatEmptyState = React.forwardRef(({ title, description, guideUrl, guideText = "Guide", suggestedPrompts = [], onPromptClick, onGuideClick, onOpenFiles, onPromptHover, onPromptLeave, className, ...props }, ref) => {
|
|
6
6
|
const handlePromptClick = (prompt) => {
|
|
7
7
|
onPromptClick?.(prompt);
|
|
8
8
|
};
|
|
@@ -17,6 +17,6 @@ export const ChatEmptyState = React.forwardRef(({ title, description, guideUrl,
|
|
|
17
17
|
for (let i = 0; i < suggestedPrompts.length; i += 2) {
|
|
18
18
|
promptRows.push(suggestedPrompts.slice(i, i + 2));
|
|
19
19
|
}
|
|
20
|
-
return (_jsxs("div", { ref: ref, className: cn("flex flex-col items-start
|
|
20
|
+
return (_jsxs("div", { ref: ref, className: cn("flex flex-col items-start", className), ...props, children: [_jsx("h3", { className: "text-heading-4 text-text-primary mb-6", children: title }), _jsx("p", { className: "text-subheading text-text-secondary max-w-prose mb-6", children: description }), onOpenFiles && (_jsxs("button", { type: "button", onClick: onOpenFiles, className: "inline-flex items-center gap-1 py-1.5 pr-3 -ml-3 pl-3 rounded-lg hover:bg-accent transition-colors mb-6", children: [_jsx("span", { className: "text-paragraph-sm-medium text-foreground tracking-wide leading-none", children: "View Files" }), _jsx(ChevronRight, { className: "size-4 text-foreground shrink-0" })] })), (guideUrl || onGuideClick) && (_jsxs("button", { type: "button", onClick: handleGuideClick, className: "inline-flex items-center gap-1 py-1.5 pr-3 -ml-3 pl-3 rounded-lg hover:bg-accent transition-colors", children: [_jsx("span", { className: "text-paragraph-sm-medium text-foreground tracking-wide leading-none", children: guideText }), _jsx(ChevronRight, { className: "size-4 text-foreground shrink-0" })] })), suggestedPrompts.length > 0 && (_jsxs("div", { className: "flex flex-col gap-3 w-full max-w-prompt-container", children: [_jsx("p", { className: "text-label text-text-tertiary", children: "Suggested Prompts" }), _jsx("div", { className: "flex flex-col gap-2.5", children: promptRows.map((row) => (_jsx("div", { className: "flex gap-2.5 items-center", children: row.map((prompt) => (_jsx("button", { type: "button", onClick: () => handlePromptClick(prompt), onMouseEnter: () => onPromptHover?.(prompt), onMouseLeave: () => onPromptLeave?.(), className: "flex-1 flex items-start gap-2 p-3 bg-secondary hover:bg-secondary/80 rounded-2xl transition-colors min-w-0", children: _jsx("span", { className: "text-paragraph font-normal leading-normal text-text-tertiary truncate", children: prompt }) }, prompt))) }, row.join("-")))) })] }))] }));
|
|
21
21
|
});
|
|
22
22
|
ChatEmptyState.displayName = "ChatEmptyState";
|
|
@@ -8,6 +8,10 @@ export interface ChatInputRootProps extends Omit<React.FormHTMLAttributes<HTMLFo
|
|
|
8
8
|
* Either client or value/onChange/onSubmit must be provided
|
|
9
9
|
*/
|
|
10
10
|
client?: AcpClient | null;
|
|
11
|
+
/**
|
|
12
|
+
* Start session function (required when using client)
|
|
13
|
+
*/
|
|
14
|
+
startSession?: () => Promise<string | null>;
|
|
11
15
|
/**
|
|
12
16
|
* Input value (legacy prop-based pattern)
|
|
13
17
|
* Either client or value/onChange/onSubmit must be provided
|
|
@@ -15,18 +15,23 @@ const useChatInputContext = () => {
|
|
|
15
15
|
}
|
|
16
16
|
return context;
|
|
17
17
|
};
|
|
18
|
-
const ChatInputRoot = React.forwardRef(({ client, value: valueProp, onChange: onChangeProp, onSubmit: onSubmitProp, disabled = false, isSubmitting: isSubmittingProp, submitOnEnter = true, className, children, ...props }, ref) => {
|
|
18
|
+
const ChatInputRoot = React.forwardRef(({ client, startSession, value: valueProp, onChange: onChangeProp, onSubmit: onSubmitProp, disabled = false, isSubmitting: isSubmittingProp, submitOnEnter = true, className, children, ...props }, ref) => {
|
|
19
19
|
const textareaRef = React.useRef(null);
|
|
20
|
+
// Provide a dummy startSession function if not provided (for React hooks rule)
|
|
21
|
+
const dummyStartSession = React.useCallback(async () => Promise.resolve(null), []);
|
|
20
22
|
// Always call hooks unconditionally (React rules)
|
|
21
|
-
const hookData = useCoreChatInput(client ?? null);
|
|
23
|
+
const hookData = useCoreChatInput(client ?? null, startSession ?? dummyStartSession);
|
|
22
24
|
const storeIsStreaming = useChatStore((state) => state.isStreaming);
|
|
23
25
|
// Choose data source based on whether client is provided
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
26
|
+
const useHookData = client && startSession;
|
|
27
|
+
const value = useHookData ? hookData.value : valueProp || "";
|
|
28
|
+
const onChange = useHookData
|
|
29
|
+
? hookData.onChange
|
|
30
|
+
: onChangeProp || (() => { });
|
|
31
|
+
const onSubmit = useHookData
|
|
27
32
|
? hookData.onSubmit
|
|
28
33
|
: onSubmitProp || (async () => { });
|
|
29
|
-
const isSubmitting =
|
|
34
|
+
const isSubmitting = useHookData
|
|
30
35
|
? hookData.isSubmitting || storeIsStreaming
|
|
31
36
|
: isSubmittingProp || false;
|
|
32
37
|
// Command menu state
|
|
@@ -201,7 +206,7 @@ const ChatInputField = React.forwardRef(({ asChild = false, className, onKeyDown
|
|
|
201
206
|
if (asChild && React.isValidElement(children)) {
|
|
202
207
|
return React.cloneElement(children, fieldProps);
|
|
203
208
|
}
|
|
204
|
-
return (_jsx("textarea", { ...fieldProps, className: cn("w-full resize-none rounded-none border-none p-4 shadow-none", "outline-none ring-0
|
|
209
|
+
return (_jsx("textarea", { ...fieldProps, rows: 1, className: cn("w-full resize-none rounded-none border-none p-4 shadow-none", "outline-none ring-0 max-h-[6lh] min-h-[44px]", "bg-transparent dark:bg-transparent focus-visible:ring-0", "text-paragraph-sm placeholder:text-muted-foreground", "disabled:cursor-not-allowed disabled:opacity-50", className) }));
|
|
205
210
|
});
|
|
206
211
|
ChatInputField.displayName = "ChatInput.Field";
|
|
207
212
|
const ChatInputSubmit = React.forwardRef(({ asChild = false, className, disabled: disabledProp, children, ...props }, ref) => {
|
|
@@ -41,38 +41,118 @@ ChatLayoutBody.displayName = "ChatLayout.Body";
|
|
|
41
41
|
const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChange, showScrollToBottom = true, ...props }, ref) => {
|
|
42
42
|
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
|
43
43
|
const scrollContainerRef = React.useRef(null);
|
|
44
|
+
const wasAtBottomRef = React.useRef(true); // Track if user was at bottom before content update
|
|
45
|
+
const isAutoScrollingRef = React.useRef(false); // Track if we're programmatically scrolling
|
|
44
46
|
// Merge refs
|
|
45
47
|
React.useImperativeHandle(ref, () => scrollContainerRef.current);
|
|
46
48
|
// Check if user is at bottom of scroll
|
|
47
49
|
const checkScrollPosition = React.useCallback(() => {
|
|
48
50
|
const container = scrollContainerRef.current;
|
|
49
51
|
if (!container)
|
|
50
|
-
return;
|
|
52
|
+
return false;
|
|
51
53
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
52
54
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
53
55
|
const isAtBottom = distanceFromBottom < 100; // 100px threshold
|
|
54
56
|
setShowScrollButton(!isAtBottom && showScrollToBottom);
|
|
55
57
|
onScrollChange?.(isAtBottom);
|
|
58
|
+
return isAtBottom;
|
|
56
59
|
}, [onScrollChange, showScrollToBottom]);
|
|
57
60
|
// Handle scroll events
|
|
58
61
|
const handleScroll = React.useCallback(() => {
|
|
59
|
-
|
|
62
|
+
// If this is a programmatic scroll, don't update wasAtBottomRef
|
|
63
|
+
if (isAutoScrollingRef.current) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// This is a user-initiated scroll, update the position
|
|
67
|
+
const isAtBottom = checkScrollPosition();
|
|
68
|
+
wasAtBottomRef.current = isAtBottom;
|
|
60
69
|
}, [checkScrollPosition]);
|
|
61
70
|
// Scroll to bottom function
|
|
62
|
-
const scrollToBottom = React.useCallback(() => {
|
|
71
|
+
const scrollToBottom = React.useCallback((smooth = true) => {
|
|
63
72
|
const container = scrollContainerRef.current;
|
|
64
73
|
if (!container)
|
|
65
74
|
return;
|
|
75
|
+
// Mark that we're about to programmatically scroll
|
|
76
|
+
isAutoScrollingRef.current = true;
|
|
77
|
+
wasAtBottomRef.current = true; // Set immediately for instant scrolls
|
|
66
78
|
container.scrollTo({
|
|
67
79
|
top: container.scrollHeight,
|
|
68
|
-
behavior: "smooth",
|
|
80
|
+
behavior: smooth ? "smooth" : "auto",
|
|
69
81
|
});
|
|
82
|
+
// Clear the flag after scroll completes
|
|
83
|
+
// For instant scrolling, clear immediately; for smooth, wait
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
isAutoScrollingRef.current = false;
|
|
86
|
+
}, smooth ? 300 : 0);
|
|
70
87
|
}, []);
|
|
71
|
-
//
|
|
88
|
+
// Auto-scroll when content changes if user was at bottom
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
const container = scrollContainerRef.current;
|
|
91
|
+
if (!container)
|
|
92
|
+
return;
|
|
93
|
+
// If user was at the bottom, scroll to new content
|
|
94
|
+
if (wasAtBottomRef.current && !isAutoScrollingRef.current) {
|
|
95
|
+
// Use requestAnimationFrame to ensure DOM has updated
|
|
96
|
+
requestAnimationFrame(() => {
|
|
97
|
+
scrollToBottom(false); // Use instant scroll for streaming to avoid jarring smooth animations
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// Update scroll position state (but don't change wasAtBottomRef if we're auto-scrolling)
|
|
101
|
+
if (!isAutoScrollingRef.current) {
|
|
102
|
+
checkScrollPosition();
|
|
103
|
+
}
|
|
104
|
+
}, [children, scrollToBottom, checkScrollPosition]);
|
|
105
|
+
// Check scroll position on mount
|
|
72
106
|
React.useEffect(() => {
|
|
73
|
-
|
|
107
|
+
if (!isAutoScrollingRef.current) {
|
|
108
|
+
const isAtBottom = checkScrollPosition();
|
|
109
|
+
wasAtBottomRef.current = isAtBottom;
|
|
110
|
+
}
|
|
74
111
|
}, [checkScrollPosition]);
|
|
75
|
-
|
|
112
|
+
// Detect user interaction with scroll area (wheel, touch) - IMMEDIATELY break auto-scroll
|
|
113
|
+
const handleUserInteraction = React.useCallback(() => {
|
|
114
|
+
// Immediately mark that user is interacting
|
|
115
|
+
isAutoScrollingRef.current = false;
|
|
116
|
+
// For wheel/touch events, temporarily break auto-scroll
|
|
117
|
+
// The actual scroll event will update wasAtBottomRef properly
|
|
118
|
+
// This prevents the race condition where content updates before scroll completes
|
|
119
|
+
const container = scrollContainerRef.current;
|
|
120
|
+
if (!container)
|
|
121
|
+
return;
|
|
122
|
+
// Check current position BEFORE the scroll happens
|
|
123
|
+
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
124
|
+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
125
|
+
// If user is not currently at the bottom, definitely break auto-scroll
|
|
126
|
+
if (distanceFromBottom >= 100) {
|
|
127
|
+
wasAtBottomRef.current = false;
|
|
128
|
+
}
|
|
129
|
+
// If they are at bottom, the scroll event will determine if they stay there
|
|
130
|
+
}, []);
|
|
131
|
+
// Handle keyboard navigation
|
|
132
|
+
const handleKeyDown = React.useCallback((e) => {
|
|
133
|
+
// If user presses arrow keys, page up/down, home/end - they're scrolling
|
|
134
|
+
const scrollKeys = [
|
|
135
|
+
"ArrowUp",
|
|
136
|
+
"ArrowDown",
|
|
137
|
+
"PageUp",
|
|
138
|
+
"PageDown",
|
|
139
|
+
"Home",
|
|
140
|
+
"End",
|
|
141
|
+
];
|
|
142
|
+
if (scrollKeys.includes(e.key)) {
|
|
143
|
+
isAutoScrollingRef.current = false;
|
|
144
|
+
// Check position on next frame after the scroll happens
|
|
145
|
+
requestAnimationFrame(() => {
|
|
146
|
+
const container = scrollContainerRef.current;
|
|
147
|
+
if (!container)
|
|
148
|
+
return;
|
|
149
|
+
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
150
|
+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
151
|
+
wasAtBottomRef.current = distanceFromBottom < 100;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}, []);
|
|
155
|
+
return (_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsx("div", { ref: scrollContainerRef, className: cn("h-full overflow-y-auto flex flex-col", className), onScroll: handleScroll, onWheel: handleUserInteraction, onTouchStart: handleUserInteraction, onKeyDown: handleKeyDown, tabIndex: 0, ...props, children: _jsx("div", { className: "mx-auto max-w-chat flex-1 w-full flex flex-col", children: children }) }), showScrollButton && (_jsx("button", { type: "button", onClick: () => scrollToBottom(true), 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" }) }))] }));
|
|
76
156
|
});
|
|
77
157
|
ChatLayoutMessages.displayName = "ChatLayout.Messages";
|
|
78
158
|
const ChatLayoutFooter = React.forwardRef(({ className, children, ...props }, ref) => {
|
|
@@ -88,10 +168,24 @@ const ChatLayoutSidebar = React.forwardRef(({ className, children, ...props }, r
|
|
|
88
168
|
ChatLayoutSidebar.displayName = "ChatLayout.Sidebar";
|
|
89
169
|
const ChatLayoutAside = React.forwardRef(({ breakpoint = "lg", className, children, ...props }, ref) => {
|
|
90
170
|
const { panelSize } = useChatLayoutContext();
|
|
171
|
+
const [minSizePercent, setMinSizePercent] = React.useState(25);
|
|
172
|
+
// Convert 400px minimum to percentage based on window width
|
|
173
|
+
React.useEffect(() => {
|
|
174
|
+
const updateMinSize = () => {
|
|
175
|
+
const minPixels = 400;
|
|
176
|
+
const minPercent = (minPixels / window.innerWidth) * 100;
|
|
177
|
+
setMinSizePercent(Math.max(minPercent, 25)); // Never less than 25% or 400px
|
|
178
|
+
};
|
|
179
|
+
updateMinSize();
|
|
180
|
+
window.addEventListener("resize", updateMinSize);
|
|
181
|
+
return () => {
|
|
182
|
+
window.removeEventListener("resize", updateMinSize);
|
|
183
|
+
};
|
|
184
|
+
}, []);
|
|
91
185
|
// Hidden state - don't render
|
|
92
186
|
if (panelSize === "hidden")
|
|
93
187
|
return null;
|
|
94
|
-
return (_jsxs(_Fragment, { children: [_jsx(ResizableHandle, { withHandle: true }), _jsx(ResizablePanel, { defaultSize: 25, minSize:
|
|
188
|
+
return (_jsxs(_Fragment, { children: [_jsx(ResizableHandle, { withHandle: true, className: "group-hover:opacity-100 opacity-0 transition-opacity" }), _jsx(ResizablePanel, { defaultSize: 25, minSize: minSizePercent, maxSize: 35, className: "group", children: _jsx("div", { ref: ref, className: cn(
|
|
95
189
|
// Hidden by default, visible at breakpoint
|
|
96
190
|
"hidden h-full border-l border-border bg-card overflow-y-auto transition-all duration-300",
|
|
97
191
|
// Breakpoint visibility
|
|
@@ -7,7 +7,7 @@ import type { TodoItem } from "./TodoListItem.js";
|
|
|
7
7
|
* Following component architecture best practices
|
|
8
8
|
*/
|
|
9
9
|
export interface TodoTabContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
10
|
-
todos
|
|
10
|
+
todos?: TodoItem[];
|
|
11
11
|
}
|
|
12
12
|
export declare const TodoTabContent: React.ForwardRefExoticComponent<TodoTabContentProps & React.RefAttributes<HTMLDivElement>>;
|
|
13
13
|
export interface FilesTabContentProps extends React.HTMLAttributes<HTMLDivElement> {
|