@townco/ui 0.1.35 → 0.1.37
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 +1 -1
- package/dist/core/hooks/use-chat-messages.js +17 -6
- package/dist/core/hooks/use-chat-session.d.ts +1 -1
- package/dist/core/hooks/use-chat-session.js +4 -7
- package/dist/core/lib/logger.d.ts +59 -0
- package/dist/core/lib/logger.js +191 -0
- package/dist/gui/components/ChatInput.d.ts +4 -0
- package/dist/gui/components/ChatInput.js +11 -6
- package/dist/gui/components/ChatView.js +10 -3
- package/dist/gui/components/MessageContent.js +1 -1
- package/dist/sdk/transports/http.d.ts +1 -0
- package/dist/sdk/transports/http.js +28 -8
- package/dist/tui/components/ChatView.js +5 -5
- package/dist/tui/components/LogsPanel.d.ts +5 -0
- package/dist/tui/components/LogsPanel.js +29 -0
- 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";
|
|
@@ -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,10 +23,20 @@ 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
|
}
|
|
31
41
|
try {
|
|
32
42
|
// Start streaming and track time immediately
|
|
@@ -57,7 +67,7 @@ export function useChatMessages(client) {
|
|
|
57
67
|
const messageStream = client.receiveMessages();
|
|
58
68
|
// Send ONLY the new message (not full history)
|
|
59
69
|
// The agent backend now manages conversation context
|
|
60
|
-
client.sendMessage(content,
|
|
70
|
+
client.sendMessage(content, activeSessionId).catch((error) => {
|
|
61
71
|
const message = error instanceof Error ? error.message : String(error);
|
|
62
72
|
setError(message);
|
|
63
73
|
setIsStreaming(false);
|
|
@@ -106,6 +116,7 @@ export function useChatMessages(client) {
|
|
|
106
116
|
}, [
|
|
107
117
|
client,
|
|
108
118
|
sessionId,
|
|
119
|
+
startSession,
|
|
109
120
|
addMessage,
|
|
110
121
|
updateMessage,
|
|
111
122
|
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,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-compatible logger
|
|
3
|
+
* Outputs structured JSON logs to console with color-coding
|
|
4
|
+
* Also captures logs to a global store for in-app viewing
|
|
5
|
+
* In Node.js environment with logsDir option, also writes to .logs/ directory
|
|
6
|
+
*/
|
|
7
|
+
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
8
|
+
export interface LogEntry {
|
|
9
|
+
id: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
level: LogLevel;
|
|
12
|
+
service: string;
|
|
13
|
+
message: string;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get all captured logs
|
|
18
|
+
*/
|
|
19
|
+
export declare function getCapturedLogs(): LogEntry[];
|
|
20
|
+
/**
|
|
21
|
+
* Clear all captured logs
|
|
22
|
+
*/
|
|
23
|
+
export declare function clearCapturedLogs(): void;
|
|
24
|
+
/**
|
|
25
|
+
* Subscribe to log updates
|
|
26
|
+
*/
|
|
27
|
+
type LogSubscriber = (entry: LogEntry) => void;
|
|
28
|
+
export declare function subscribeToLogs(callback: LogSubscriber): () => void;
|
|
29
|
+
/**
|
|
30
|
+
* Configure global logs directory for file writing
|
|
31
|
+
* Must be called before creating any loggers (typically at TUI startup)
|
|
32
|
+
*/
|
|
33
|
+
export declare function configureLogsDir(logsDir: string): void;
|
|
34
|
+
export declare class Logger {
|
|
35
|
+
private service;
|
|
36
|
+
private minLevel;
|
|
37
|
+
private logFilePath?;
|
|
38
|
+
private logsDir?;
|
|
39
|
+
private writeQueue;
|
|
40
|
+
private isWriting;
|
|
41
|
+
constructor(service: string, minLevel?: LogLevel);
|
|
42
|
+
private setupFileLogging;
|
|
43
|
+
private writeToFile;
|
|
44
|
+
private shouldLog;
|
|
45
|
+
private log;
|
|
46
|
+
trace(message: string, metadata?: Record<string, unknown>): void;
|
|
47
|
+
debug(message: string, metadata?: Record<string, unknown>): void;
|
|
48
|
+
info(message: string, metadata?: Record<string, unknown>): void;
|
|
49
|
+
warn(message: string, metadata?: Record<string, unknown>): void;
|
|
50
|
+
error(message: string, metadata?: Record<string, unknown>): void;
|
|
51
|
+
fatal(message: string, metadata?: Record<string, unknown>): void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create a logger instance for a service
|
|
55
|
+
* @param service - Service name (e.g., "gui", "http-agent", "tui")
|
|
56
|
+
* @param minLevel - Minimum log level to display (default: "debug")
|
|
57
|
+
*/
|
|
58
|
+
export declare function createLogger(service: string, minLevel?: LogLevel): Logger;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-compatible logger
|
|
3
|
+
* Outputs structured JSON logs to console with color-coding
|
|
4
|
+
* Also captures logs to a global store for in-app viewing
|
|
5
|
+
* In Node.js environment with logsDir option, also writes to .logs/ directory
|
|
6
|
+
*/
|
|
7
|
+
// Check if running in Node.js
|
|
8
|
+
const isNode = typeof process !== "undefined" && process.versions?.node;
|
|
9
|
+
// Global logs directory configuration (set once at app startup for TUI)
|
|
10
|
+
let globalLogsDir;
|
|
11
|
+
// Global log store
|
|
12
|
+
const globalLogStore = [];
|
|
13
|
+
let logIdCounter = 0;
|
|
14
|
+
/**
|
|
15
|
+
* Get all captured logs
|
|
16
|
+
*/
|
|
17
|
+
export function getCapturedLogs() {
|
|
18
|
+
return [...globalLogStore];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Clear all captured logs
|
|
22
|
+
*/
|
|
23
|
+
export function clearCapturedLogs() {
|
|
24
|
+
globalLogStore.length = 0;
|
|
25
|
+
}
|
|
26
|
+
const logSubscribers = new Set();
|
|
27
|
+
export function subscribeToLogs(callback) {
|
|
28
|
+
logSubscribers.add(callback);
|
|
29
|
+
return () => logSubscribers.delete(callback);
|
|
30
|
+
}
|
|
31
|
+
function notifyLogSubscribers(entry) {
|
|
32
|
+
for (const callback of logSubscribers) {
|
|
33
|
+
callback(entry);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Configure global logs directory for file writing
|
|
38
|
+
* Must be called before creating any loggers (typically at TUI startup)
|
|
39
|
+
*/
|
|
40
|
+
export function configureLogsDir(logsDir) {
|
|
41
|
+
globalLogsDir = logsDir;
|
|
42
|
+
}
|
|
43
|
+
const LOG_LEVELS = {
|
|
44
|
+
trace: 0,
|
|
45
|
+
debug: 1,
|
|
46
|
+
info: 2,
|
|
47
|
+
warn: 3,
|
|
48
|
+
error: 4,
|
|
49
|
+
fatal: 5,
|
|
50
|
+
};
|
|
51
|
+
const _LOG_COLORS = {
|
|
52
|
+
trace: "#6B7280", // gray
|
|
53
|
+
debug: "#3B82F6", // blue
|
|
54
|
+
info: "#10B981", // green
|
|
55
|
+
warn: "#F59E0B", // orange
|
|
56
|
+
error: "#EF4444", // red
|
|
57
|
+
fatal: "#DC2626", // dark red
|
|
58
|
+
};
|
|
59
|
+
const _LOG_STYLES = {
|
|
60
|
+
trace: "color: #6B7280",
|
|
61
|
+
debug: "color: #3B82F6; font-weight: bold",
|
|
62
|
+
info: "color: #10B981; font-weight: bold",
|
|
63
|
+
warn: "color: #F59E0B; font-weight: bold",
|
|
64
|
+
error: "color: #EF4444; font-weight: bold",
|
|
65
|
+
fatal: "color: #DC2626; font-weight: bold; background: #FEE2E2",
|
|
66
|
+
};
|
|
67
|
+
export class Logger {
|
|
68
|
+
service;
|
|
69
|
+
minLevel;
|
|
70
|
+
logFilePath;
|
|
71
|
+
logsDir;
|
|
72
|
+
writeQueue = [];
|
|
73
|
+
isWriting = false;
|
|
74
|
+
constructor(service, minLevel = "debug") {
|
|
75
|
+
this.service = service;
|
|
76
|
+
this.minLevel = minLevel;
|
|
77
|
+
// In production, suppress trace and debug logs
|
|
78
|
+
if (typeof process !== "undefined" &&
|
|
79
|
+
process.env?.NODE_ENV === "production") {
|
|
80
|
+
this.minLevel = "info";
|
|
81
|
+
}
|
|
82
|
+
// Note: File logging setup is done lazily in log() method
|
|
83
|
+
// This allows loggers created before configureLogsDir() to still write to files
|
|
84
|
+
}
|
|
85
|
+
setupFileLogging() {
|
|
86
|
+
if (!isNode || !globalLogsDir)
|
|
87
|
+
return;
|
|
88
|
+
try {
|
|
89
|
+
// Dynamic import for Node.js modules
|
|
90
|
+
const path = require("node:path");
|
|
91
|
+
const fs = require("node:fs");
|
|
92
|
+
this.logsDir = globalLogsDir;
|
|
93
|
+
this.logFilePath = path.join(this.logsDir, `${this.service}.log`);
|
|
94
|
+
// Create logs directory if it doesn't exist
|
|
95
|
+
if (!fs.existsSync(this.logsDir)) {
|
|
96
|
+
fs.mkdirSync(this.logsDir, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (_error) {
|
|
100
|
+
// Silently fail if we can't set up file logging
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async writeToFile(content) {
|
|
104
|
+
if (!this.logFilePath || !isNode)
|
|
105
|
+
return;
|
|
106
|
+
this.writeQueue.push(content);
|
|
107
|
+
if (this.isWriting) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.isWriting = true;
|
|
111
|
+
while (this.writeQueue.length > 0) {
|
|
112
|
+
const batch = this.writeQueue.splice(0, this.writeQueue.length);
|
|
113
|
+
const data = `${batch.join("\n")}\n`;
|
|
114
|
+
try {
|
|
115
|
+
// Dynamic import for Node.js modules
|
|
116
|
+
const fs = require("node:fs");
|
|
117
|
+
await fs.promises.appendFile(this.logFilePath, data, "utf-8");
|
|
118
|
+
}
|
|
119
|
+
catch (_error) {
|
|
120
|
+
// Silently fail
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.isWriting = false;
|
|
124
|
+
}
|
|
125
|
+
shouldLog(level) {
|
|
126
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[this.minLevel];
|
|
127
|
+
}
|
|
128
|
+
log(level, message, metadata) {
|
|
129
|
+
if (!this.shouldLog(level)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const entry = {
|
|
133
|
+
id: `log_${++logIdCounter}`,
|
|
134
|
+
timestamp: new Date().toISOString(),
|
|
135
|
+
level,
|
|
136
|
+
service: this.service,
|
|
137
|
+
message,
|
|
138
|
+
...(metadata && { metadata }),
|
|
139
|
+
};
|
|
140
|
+
// Store in global log store
|
|
141
|
+
globalLogStore.push(entry);
|
|
142
|
+
// Notify subscribers
|
|
143
|
+
notifyLogSubscribers(entry);
|
|
144
|
+
// Write to file in Node.js (for logs tab to read)
|
|
145
|
+
// Lazily set up file logging if globalLogsDir was configured after this logger was created
|
|
146
|
+
if (isNode && !this.logFilePath && globalLogsDir) {
|
|
147
|
+
this.setupFileLogging();
|
|
148
|
+
}
|
|
149
|
+
if (isNode && this.logFilePath) {
|
|
150
|
+
// Write as JSON without the id field (to match expected format)
|
|
151
|
+
const fileEntry = {
|
|
152
|
+
timestamp: entry.timestamp,
|
|
153
|
+
level: entry.level,
|
|
154
|
+
service: entry.service,
|
|
155
|
+
message: entry.message,
|
|
156
|
+
...(entry.metadata && { metadata: entry.metadata }),
|
|
157
|
+
};
|
|
158
|
+
this.writeToFile(JSON.stringify(fileEntry)).catch(() => {
|
|
159
|
+
// Silently fail
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// No console output - logs are only captured and displayed in UI
|
|
163
|
+
// This prevents logs from polluting stdout/stderr in TUI mode
|
|
164
|
+
}
|
|
165
|
+
trace(message, metadata) {
|
|
166
|
+
this.log("trace", message, metadata);
|
|
167
|
+
}
|
|
168
|
+
debug(message, metadata) {
|
|
169
|
+
this.log("debug", message, metadata);
|
|
170
|
+
}
|
|
171
|
+
info(message, metadata) {
|
|
172
|
+
this.log("info", message, metadata);
|
|
173
|
+
}
|
|
174
|
+
warn(message, metadata) {
|
|
175
|
+
this.log("warn", message, metadata);
|
|
176
|
+
}
|
|
177
|
+
error(message, metadata) {
|
|
178
|
+
this.log("error", message, metadata);
|
|
179
|
+
}
|
|
180
|
+
fatal(message, metadata) {
|
|
181
|
+
this.log("fatal", message, metadata);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Create a logger instance for a service
|
|
186
|
+
* @param service - Service name (e.g., "gui", "http-agent", "tui")
|
|
187
|
+
* @param minLevel - Minimum log level to display (default: "debug")
|
|
188
|
+
*/
|
|
189
|
+
export function createLogger(service, minLevel = "debug") {
|
|
190
|
+
return new Logger(service, minLevel);
|
|
191
|
+
}
|
|
@@ -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
|
|
@@ -4,6 +4,7 @@ import { ArrowUp, ChevronUp, Code, PanelRight, Settings, Sparkles, } from "lucid
|
|
|
4
4
|
import { useEffect, useState } from "react";
|
|
5
5
|
import { useChatMessages, useChatSession, useToolCalls, } from "../../core/hooks/index.js";
|
|
6
6
|
import { useChatStore } from "../../core/store/chat-store.js";
|
|
7
|
+
import { formatTokenPercentage } from "../../core/utils/model-context.js";
|
|
7
8
|
import { cn } from "../lib/utils.js";
|
|
8
9
|
import { ChatEmptyState, ChatHeader, ChatInputActions, ChatInputAttachment, ChatInputCommandMenu, ChatInputField, ChatInputRoot, ChatInputSubmit, ChatInputToolbar, ChatInputVoiceInput, ChatLayout, FilesTabContent, Message, MessageContent, PanelTabsHeader, SourcesTabContent, Tabs, TabsContent, TodoTabContent, } from "./index.js";
|
|
9
10
|
const logger = createLogger("gui");
|
|
@@ -21,10 +22,11 @@ function AppChatHeader({ agentName, todos, sources, showHeader, }) {
|
|
|
21
22
|
}
|
|
22
23
|
export function ChatView({ client, initialSessionId, error: initError, }) {
|
|
23
24
|
// Use shared hooks from @townco/ui/core - MUST be called before any early returns
|
|
24
|
-
const { connectionStatus, connect, sessionId } = useChatSession(client, initialSessionId);
|
|
25
|
-
const { messages, sendMessage } = useChatMessages(client);
|
|
25
|
+
const { connectionStatus, connect, sessionId, startSession } = useChatSession(client, initialSessionId);
|
|
26
|
+
const { messages, sendMessage } = useChatMessages(client, startSession);
|
|
26
27
|
useToolCalls(client); // Still need to subscribe to tool call events
|
|
27
28
|
const error = useChatStore((state) => state.error);
|
|
29
|
+
const currentModel = useChatStore((state) => state.currentModel);
|
|
28
30
|
const [agentName, setAgentName] = useState("Agent");
|
|
29
31
|
const [isLargeScreen, setIsLargeScreen] = useState(typeof window !== "undefined" ? window.innerWidth >= 1024 : true);
|
|
30
32
|
// Log connection status changes
|
|
@@ -98,6 +100,11 @@ export function ChatView({ client, initialSessionId, error: initError, }) {
|
|
|
98
100
|
favicon: "https://www.google.com/s2/favicons?domain=theverge.com&sz=32",
|
|
99
101
|
},
|
|
100
102
|
];
|
|
103
|
+
// Get the latest token usage from the most recent assistant message
|
|
104
|
+
const latestTokenUsage = messages
|
|
105
|
+
.slice()
|
|
106
|
+
.reverse()
|
|
107
|
+
.find((msg) => msg.role === "assistant" && msg.tokenUsage)?.tokenUsage;
|
|
101
108
|
// Command menu items for chat input
|
|
102
109
|
const commandMenuItems = [
|
|
103
110
|
{
|
|
@@ -168,5 +175,5 @@ export function ChatView({ client, initialSessionId, error: initError, }) {
|
|
|
168
175
|
previousMessage?.role === "assistant" ? "mt-2" : "mt-6";
|
|
169
176
|
}
|
|
170
177
|
return (_jsx(Message, { message: message, className: spacingClass, isLastMessage: index === messages.length - 1, children: _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }) }, message.id));
|
|
171
|
-
}) })) }), _jsx(ChatLayout.Footer, { children: _jsxs(ChatInputRoot, { client: client, children: [_jsx(ChatInputCommandMenu, { commands: commandMenuItems }), _jsx(ChatInputField, { placeholder: "Type a message or / for commands...", autoFocus: true }), _jsxs(ChatInputToolbar, { children: [_jsxs("div", { className: "flex items-
|
|
178
|
+
}) })) }), _jsx(ChatLayout.Footer, { children: _jsxs(ChatInputRoot, { client: client, startSession: startSession, children: [_jsx(ChatInputCommandMenu, { commands: commandMenuItems }), _jsx(ChatInputField, { placeholder: "Type a message or / for commands...", autoFocus: true }), _jsxs(ChatInputToolbar, { children: [_jsxs("div", { className: "flex items-baseline gap-1", children: [_jsx(ChatInputActions, {}), _jsx(ChatInputAttachment, {}), latestTokenUsage && (_jsxs("span", { className: "text-xs text-muted-foreground/50 ml-2", children: ["Context:", " ", formatTokenPercentage(latestTokenUsage.totalTokens ?? 0, currentModel ?? undefined), " ", "(", (latestTokenUsage.totalTokens ?? 0).toLocaleString(), " ", "tokens)"] }))] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(ChatInputVoiceInput, {}), _jsx(ChatInputSubmit, { children: _jsx(ArrowUp, { className: "size-4" }) })] })] })] }) })] })] }), isLargeScreen && (_jsx(ChatLayout.Aside, { breakpoint: "lg", children: _jsxs(Tabs, { defaultValue: "todo", className: "flex flex-col h-full", children: [_jsx("div", { className: cn("border-b border-border bg-card", "px-6 py-2 h-16", "flex items-center", "[border-bottom-width:0.5px]"), children: _jsx(PanelTabsHeader, { showIcons: true, visibleTabs: ["todo", "files", "sources"], variant: "compact" }) }), _jsx(TabsContent, { value: "todo", className: "flex-1 p-4 mt-0", children: _jsx(TodoTabContent, { todos: todos }) }), _jsx(TabsContent, { value: "files", className: "flex-1 p-4 mt-0", children: _jsx(FilesTabContent, {}) }), _jsx(TabsContent, { value: "sources", className: "flex-1 p-4 mt-0", children: _jsx(SourcesTabContent, { sources: sources }) })] }) }))] }));
|
|
172
179
|
}
|
|
@@ -135,7 +135,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
return _jsx(_Fragment, { children: elements });
|
|
138
|
-
})()) : (_jsx("div", { className: "whitespace-pre-wrap", children: message.content }))
|
|
138
|
+
})()) : (_jsx("div", { className: "whitespace-pre-wrap", children: message.content }))] }));
|
|
139
139
|
}
|
|
140
140
|
return (_jsx("div", { ref: ref, className: cn(messageContentVariants({ role, variant }), isStreaming && "animate-pulse-subtle", className), ...props, children: content }));
|
|
141
141
|
});
|
|
@@ -21,6 +21,7 @@ export class HttpTransport {
|
|
|
21
21
|
abortController = null;
|
|
22
22
|
options;
|
|
23
23
|
isReceivingMessages = false;
|
|
24
|
+
isInReplayMode = false; // True during session replay, ignores non-replay streaming
|
|
24
25
|
constructor(options) {
|
|
25
26
|
// Ensure baseUrl doesn't end with a slash
|
|
26
27
|
this.options = { ...options, baseUrl: options.baseUrl.replace(/\/$/, "") };
|
|
@@ -100,6 +101,8 @@ export class HttpTransport {
|
|
|
100
101
|
capabilities: initResponse.agentCapabilities,
|
|
101
102
|
});
|
|
102
103
|
// Step 2: Open SSE connection FIRST so we can receive replayed messages
|
|
104
|
+
// Enter replay mode - ignore non-replay streaming until user sends a message
|
|
105
|
+
this.isInReplayMode = true;
|
|
103
106
|
this.currentSessionId = sessionId;
|
|
104
107
|
await this.connectSSE();
|
|
105
108
|
// Step 3: Load existing session (will trigger message replay)
|
|
@@ -116,6 +119,7 @@ export class HttpTransport {
|
|
|
116
119
|
});
|
|
117
120
|
this.connected = true;
|
|
118
121
|
this.reconnectAttempts = 0;
|
|
122
|
+
// Note: isInReplayMode will be set to false when user sends their first message
|
|
119
123
|
}
|
|
120
124
|
catch (error) {
|
|
121
125
|
this.connected = false;
|
|
@@ -163,6 +167,11 @@ export class HttpTransport {
|
|
|
163
167
|
if (!this.connected || !this.currentSessionId) {
|
|
164
168
|
throw new Error("Transport not connected");
|
|
165
169
|
}
|
|
170
|
+
// Exit replay mode when user sends their first message
|
|
171
|
+
if (this.isInReplayMode) {
|
|
172
|
+
logger.info("Exiting replay mode - user sent a message");
|
|
173
|
+
this.isInReplayMode = false;
|
|
174
|
+
}
|
|
166
175
|
try {
|
|
167
176
|
// Reset stream state for new message
|
|
168
177
|
this.streamComplete = false;
|
|
@@ -729,6 +738,15 @@ export class HttpTransport {
|
|
|
729
738
|
this.notifySessionUpdate(sessionUpdate);
|
|
730
739
|
}
|
|
731
740
|
else if (update?.sessionUpdate === "agent_message_chunk") {
|
|
741
|
+
// Check if this is a replay (not live streaming)
|
|
742
|
+
const isReplay = update._meta &&
|
|
743
|
+
typeof update._meta === "object" &&
|
|
744
|
+
"isReplay" in update._meta &&
|
|
745
|
+
update._meta.isReplay === true;
|
|
746
|
+
// If in replay mode, ignore any chunks that aren't marked as replay
|
|
747
|
+
if (this.isInReplayMode && !isReplay) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
732
750
|
// Handle agent message chunks
|
|
733
751
|
const sessionUpdate = {
|
|
734
752
|
type: "generic",
|
|
@@ -741,10 +759,7 @@ export class HttpTransport {
|
|
|
741
759
|
"tokenUsage" in update._meta
|
|
742
760
|
? update._meta.tokenUsage
|
|
743
761
|
: undefined;
|
|
744
|
-
|
|
745
|
-
tokenUsage,
|
|
746
|
-
});
|
|
747
|
-
// Queue message chunks if present
|
|
762
|
+
// Queue message chunks if present (but skip during replay)
|
|
748
763
|
// For agent_message_chunk, content is an object, not an array
|
|
749
764
|
const content = update.content;
|
|
750
765
|
if (content && typeof content === "object") {
|
|
@@ -759,7 +774,8 @@ export class HttpTransport {
|
|
|
759
774
|
isComplete: false,
|
|
760
775
|
};
|
|
761
776
|
}
|
|
762
|
-
|
|
777
|
+
// Only queue chunks for live streaming, not replay
|
|
778
|
+
if (chunk && !isReplay) {
|
|
763
779
|
// Resolve any waiting receive() calls immediately
|
|
764
780
|
const resolver = this.chunkResolvers.shift();
|
|
765
781
|
if (resolver) {
|
|
@@ -771,10 +787,11 @@ export class HttpTransport {
|
|
|
771
787
|
}
|
|
772
788
|
}
|
|
773
789
|
// Also notify as a complete message for session replay
|
|
774
|
-
//
|
|
790
|
+
// During replay: always send session updates with complete messages
|
|
791
|
+
// During live streaming: only send when NOT actively receiving (prevents duplication)
|
|
775
792
|
if (chunk &&
|
|
776
793
|
typeof contentObj.text === "string" &&
|
|
777
|
-
!this.isReceivingMessages) {
|
|
794
|
+
(isReplay || !this.isReceivingMessages)) {
|
|
778
795
|
const messageSessionUpdate = {
|
|
779
796
|
type: "generic",
|
|
780
797
|
sessionId,
|
|
@@ -795,7 +812,10 @@ export class HttpTransport {
|
|
|
795
812
|
this.notifySessionUpdate(messageSessionUpdate);
|
|
796
813
|
}
|
|
797
814
|
}
|
|
798
|
-
|
|
815
|
+
// Only send generic session update during live streaming (not replay)
|
|
816
|
+
if (!isReplay) {
|
|
817
|
+
this.notifySessionUpdate(sessionUpdate);
|
|
818
|
+
}
|
|
799
819
|
}
|
|
800
820
|
else if (update?.sessionUpdate === "user_message_chunk") {
|
|
801
821
|
// Handle user message chunks (could be from replay or new messages)
|
|
@@ -12,10 +12,10 @@ export function ChatView({ client }) {
|
|
|
12
12
|
const _currentModel = useChatStore((state) => state.currentModel);
|
|
13
13
|
const _tokenDisplayMode = useChatStore((state) => state.tokenDisplayMode);
|
|
14
14
|
// Use headless hooks for business logic
|
|
15
|
-
useChatSession(client); // Subscribe to session changes
|
|
16
|
-
const { messages, isStreaming } = useChatMessages(client);
|
|
15
|
+
const { startSession } = useChatSession(client); // Subscribe to session changes
|
|
16
|
+
const { messages, isStreaming } = useChatMessages(client, startSession);
|
|
17
17
|
useToolCalls(client); // Still need to subscribe to tool call events
|
|
18
|
-
const { value, isSubmitting, attachedFiles, onChange, onSubmit } = useChatInput(client);
|
|
18
|
+
const { value, isSubmitting, attachedFiles, onChange, onSubmit } = useChatInput(client, startSession);
|
|
19
19
|
// Check if we're actively receiving content (hide waiting indicator)
|
|
20
20
|
const _hasStreamingContent = messages.some((msg) => msg.isStreaming && msg.content.length > 0);
|
|
21
21
|
// Callbacks for keyboard shortcuts
|
|
@@ -34,8 +34,8 @@ export function ChatViewStatus({ client }) {
|
|
|
34
34
|
const currentContext = useChatStore((state) => state.currentContext);
|
|
35
35
|
const currentModel = useChatStore((state) => state.currentModel);
|
|
36
36
|
const tokenDisplayMode = useChatStore((state) => state.tokenDisplayMode);
|
|
37
|
-
const { connectionStatus, sessionId } = useChatSession(client);
|
|
38
|
-
const { messages, isStreaming } = useChatMessages(client);
|
|
37
|
+
const { connectionStatus, sessionId, startSession } = useChatSession(client);
|
|
38
|
+
const { messages, isStreaming } = useChatMessages(client, startSession);
|
|
39
39
|
const hasStreamingContent = messages.some((msg) => msg.isStreaming && msg.content.length > 0);
|
|
40
40
|
return (_jsx(StatusBar, { connectionStatus: connectionStatus, sessionId: sessionId, isStreaming: isStreaming, streamingStartTime: streamingStartTime, hasStreamingContent: hasStreamingContent, totalBilled: totalBilled, currentContext: currentContext, currentModel: currentModel, tokenDisplayMode: tokenDisplayMode }));
|
|
41
41
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { subscribeToLogs } from "../../core/lib/logger.js";
|
|
5
|
+
// Color mapping for log levels
|
|
6
|
+
const LOG_LEVEL_COLORS = {
|
|
7
|
+
trace: "gray",
|
|
8
|
+
debug: "blue",
|
|
9
|
+
info: "green",
|
|
10
|
+
warn: "yellow",
|
|
11
|
+
error: "red",
|
|
12
|
+
fatal: "red",
|
|
13
|
+
};
|
|
14
|
+
export function LogsPanel({ logs: initialLogs }) {
|
|
15
|
+
const [logs, setLogs] = useState(initialLogs);
|
|
16
|
+
// Subscribe to new logs
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const unsubscribe = subscribeToLogs((entry) => {
|
|
19
|
+
setLogs((prev) => [...prev, entry]);
|
|
20
|
+
});
|
|
21
|
+
return unsubscribe;
|
|
22
|
+
}, []);
|
|
23
|
+
if (logs.length === 0) {
|
|
24
|
+
return (_jsx(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(Text, { dimColor: true, children: "No logs yet..." }) }));
|
|
25
|
+
}
|
|
26
|
+
// Show last 100 logs
|
|
27
|
+
const displayLogs = logs.slice(-100);
|
|
28
|
+
return (_jsx(Box, { flexDirection: "column", paddingX: 1, children: displayLogs.map((log) => (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { dimColor: true, children: new Date(log.timestamp).toLocaleTimeString() }), _jsxs(Text, { color: LOG_LEVEL_COLORS[log.level], bold: true, children: ["[", log.level.toUpperCase(), "]"] }), _jsxs(Text, { dimColor: true, children: ["[", log.service, "]"] }), _jsx(Text, { children: log.message }), log.metadata && (_jsx(Text, { dimColor: true, children: JSON.stringify(log.metadata) }))] }, log.id))) }));
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@townco/ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@agentclientprotocol/sdk": "^0.5.1",
|
|
43
|
-
"@townco/core": "0.0.
|
|
43
|
+
"@townco/core": "0.0.15",
|
|
44
44
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
45
45
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
46
46
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@tailwindcss/postcss": "^4.1.17",
|
|
65
|
-
"@townco/tsconfig": "0.1.
|
|
65
|
+
"@townco/tsconfig": "0.1.34",
|
|
66
66
|
"@types/node": "^24.10.0",
|
|
67
67
|
"@types/react": "^19.2.2",
|
|
68
68
|
"ink": "^6.4.0",
|