@townco/ui 0.1.26 → 0.1.27

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.
@@ -53,20 +53,11 @@ export function useChatMessages(client) {
53
53
  streamingStartTime: startTime, // Use the same start time from when user sent message
54
54
  };
55
55
  addMessage(assistantMessage);
56
- // Build full conversation history (excluding system messages)
57
- const conversationHistory = messages
58
- .filter((msg) => msg.role !== "system")
59
- .map((msg) => `${msg.role === "user" ? "Human" : "Assistant"}: ${msg.content}`)
60
- .join("\n\n");
61
- // Combine history with new message
62
- const fullPrompt = conversationHistory
63
- ? `${conversationHistory}\n\nHuman: ${content}`
64
- : content;
65
56
  // Start receiving chunks (async iterator)
66
57
  const messageStream = client.receiveMessages();
67
- // Send full conversation without awaiting (fire and forget)
68
- // This allows chunks to start arriving while we're listening
69
- client.sendMessage(fullPrompt, sessionId).catch((error) => {
58
+ // Send ONLY the new message (not full history)
59
+ // The agent backend now manages conversation context
60
+ client.sendMessage(content, sessionId).catch((error) => {
70
61
  const message = error instanceof Error ? error.message : String(error);
71
62
  setError(message);
72
63
  setIsStreaming(false);
@@ -115,7 +106,6 @@ export function useChatMessages(client) {
115
106
  }, [
116
107
  client,
117
108
  sessionId,
118
- messages,
119
109
  addMessage,
120
110
  updateMessage,
121
111
  setIsStreaming,
@@ -2,10 +2,11 @@ import type { AcpClient } from "../../sdk/client/index.js";
2
2
  /**
3
3
  * Hook for managing chat session lifecycle
4
4
  */
5
- export declare function useChatSession(client: AcpClient | null): {
5
+ export declare function useChatSession(client: AcpClient | null, initialSessionId?: string | null): {
6
6
  connectionStatus: "error" | "connecting" | "connected" | "disconnected";
7
7
  sessionId: string | null;
8
8
  connect: () => Promise<void>;
9
+ loadSession: (sessionIdToLoad: string) => Promise<void>;
9
10
  startSession: () => Promise<void>;
10
11
  disconnect: () => Promise<void>;
11
12
  };
@@ -5,7 +5,7 @@ const logger = createLogger("use-chat-session", "debug");
5
5
  /**
6
6
  * Hook for managing chat session lifecycle
7
7
  */
8
- export function useChatSession(client) {
8
+ export function useChatSession(client, initialSessionId) {
9
9
  const connectionStatus = useChatStore((state) => state.connectionStatus);
10
10
  const sessionId = useChatStore((state) => state.sessionId);
11
11
  const setConnectionStatus = useChatStore((state) => state.setConnectionStatus);
@@ -13,8 +13,42 @@ export function useChatSession(client) {
13
13
  const setError = useChatStore((state) => state.setError);
14
14
  const clearMessages = useChatStore((state) => state.clearMessages);
15
15
  const resetTokens = useChatStore((state) => state.resetTokens);
16
+ const addMessage = useChatStore((state) => state.addMessage);
17
+ // Subscribe to session updates to handle replayed messages
18
+ useEffect(() => {
19
+ if (!client)
20
+ return;
21
+ const unsubscribe = client.onSessionUpdate((update) => {
22
+ // Handle replayed messages during session loading
23
+ if (update.message) {
24
+ logger.debug("Session update with message", {
25
+ message: update.message,
26
+ });
27
+ // Convert SDK message to DisplayMessage
28
+ // Filter out tool messages as they're not displayed in the chat
29
+ if (update.message.role !== "tool") {
30
+ const displayMessage = {
31
+ id: update.message.id,
32
+ role: update.message.role,
33
+ content: update.message.content
34
+ .map((c) => {
35
+ if (c.type === "text") {
36
+ return c.text;
37
+ }
38
+ return "";
39
+ })
40
+ .join(""),
41
+ timestamp: update.message.timestamp,
42
+ isStreaming: false,
43
+ };
44
+ addMessage(displayMessage);
45
+ }
46
+ }
47
+ });
48
+ return unsubscribe;
49
+ }, [client, addMessage]);
16
50
  /**
17
- * Connect to the agent
51
+ * Connect to the agent (without creating a session)
18
52
  */
19
53
  const connect = useCallback(async () => {
20
54
  if (!client) {
@@ -36,6 +70,65 @@ export function useChatSession(client) {
36
70
  setConnectionStatus("error");
37
71
  }
38
72
  }, [client, setConnectionStatus, setError]);
73
+ /**
74
+ * Load an existing session
75
+ */
76
+ const loadSession = useCallback(async (sessionIdToLoad) => {
77
+ if (!client) {
78
+ setError("No client available");
79
+ return;
80
+ }
81
+ try {
82
+ setConnectionStatus("connecting");
83
+ setError(null);
84
+ // Try to load session (this will also connect and replay messages)
85
+ const id = await client.loadSession(sessionIdToLoad);
86
+ setSessionId(id);
87
+ setConnectionStatus("connected");
88
+ // Don't clear messages - they will be populated by session replay
89
+ resetTokens();
90
+ logger.info("Session loaded successfully", { sessionId: id });
91
+ }
92
+ catch (error) {
93
+ logger.warn("Failed to load session, creating new one instead", {
94
+ sessionId: sessionIdToLoad,
95
+ error: error instanceof Error ? error.message : String(error),
96
+ });
97
+ // If session not found, create a new one instead
98
+ try {
99
+ // Connect and create new session
100
+ await connect();
101
+ const newId = await client.startSession();
102
+ setSessionId(newId);
103
+ clearMessages();
104
+ resetTokens();
105
+ // Update URL with new session ID
106
+ if (typeof window !== "undefined") {
107
+ const url = new URL(window.location.href);
108
+ url.searchParams.set("session", newId);
109
+ window.history.replaceState({}, "", url.toString());
110
+ }
111
+ logger.info("Created new session after failed load", {
112
+ sessionId: newId,
113
+ });
114
+ }
115
+ catch (fallbackError) {
116
+ const message = fallbackError instanceof Error
117
+ ? fallbackError.message
118
+ : String(fallbackError);
119
+ setError(`Failed to load or create session: ${message}`);
120
+ setConnectionStatus("error");
121
+ }
122
+ }
123
+ }, [
124
+ client,
125
+ setConnectionStatus,
126
+ setSessionId,
127
+ setError,
128
+ clearMessages,
129
+ resetTokens,
130
+ connect,
131
+ ]);
39
132
  /**
40
133
  * Start a new session
41
134
  */
@@ -49,6 +142,12 @@ export function useChatSession(client) {
49
142
  setSessionId(id);
50
143
  clearMessages();
51
144
  resetTokens();
145
+ // Update URL with new session ID
146
+ if (typeof window !== "undefined") {
147
+ const url = new URL(window.location.href);
148
+ url.searchParams.set("session", id);
149
+ window.history.pushState({}, "", url.toString());
150
+ }
52
151
  }
53
152
  catch (error) {
54
153
  const message = error instanceof Error ? error.message : String(error);
@@ -71,22 +170,34 @@ export function useChatSession(client) {
71
170
  setError(message);
72
171
  }
73
172
  }, [client, setConnectionStatus, setSessionId, setError]);
74
- // Auto-connect on mount if client is available
173
+ // Auto-connect on mount if client is available and handle initial session
75
174
  useEffect(() => {
76
- if (client && connectionStatus === "disconnected") {
175
+ if (!client || connectionStatus !== "disconnected") {
176
+ return;
177
+ }
178
+ // If we have an initial session ID, load it
179
+ if (initialSessionId) {
180
+ logger.info("Loading initial session from URL", {
181
+ sessionId: initialSessionId,
182
+ });
183
+ loadSession(initialSessionId);
184
+ }
185
+ else {
186
+ // Otherwise, connect normally (will create new session after)
77
187
  connect();
78
188
  }
79
- }, [client, connectionStatus, connect]);
80
- // Auto-start session after connecting
189
+ }, [client, connectionStatus, initialSessionId, connect, loadSession]);
190
+ // Auto-start new session after connecting (only if no initial session)
81
191
  useEffect(() => {
82
- if (connectionStatus === "connected" && !sessionId) {
192
+ if (connectionStatus === "connected" && !sessionId && !initialSessionId) {
83
193
  startSession();
84
194
  }
85
- }, [connectionStatus, sessionId, startSession]);
195
+ }, [connectionStatus, sessionId, initialSessionId, startSession]);
86
196
  return {
87
197
  connectionStatus,
88
198
  sessionId,
89
199
  connect,
200
+ loadSession,
90
201
  startSession,
91
202
  disconnect,
92
203
  };
@@ -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
+ }
@@ -41,8 +41,13 @@ export declare class AcpClient {
41
41
  isConnected(): boolean;
42
42
  /**
43
43
  * Start a new chat session
44
+ * Note: For HTTP transport, the session is created during connect() and this just returns the existing session ID
44
45
  */
45
46
  startSession(config?: Partial<SessionConfig>): Promise<string>;
47
+ /**
48
+ * Load an existing session
49
+ */
50
+ loadSession(sessionId: string, config?: Partial<SessionConfig>): Promise<string>;
46
51
  /**
47
52
  * Send a message in the current session
48
53
  */
@@ -46,8 +46,31 @@ export class AcpClient {
46
46
  }
47
47
  /**
48
48
  * Start a new chat session
49
+ * Note: For HTTP transport, the session is created during connect() and this just returns the existing session ID
49
50
  */
50
51
  async startSession(config) {
52
+ // For HTTP transport, the session is already created during connect()
53
+ // Just get the session ID from the transport's current session
54
+ const transportSessionId = this.transport.currentSessionId;
55
+ if (transportSessionId) {
56
+ // Use the session ID from the transport
57
+ const now = new Date().toISOString();
58
+ const session = {
59
+ id: transportSessionId,
60
+ status: "connected",
61
+ config: config
62
+ ? { ...config, agentPath: config.agentPath || "" }
63
+ : { agentPath: "" },
64
+ messages: [],
65
+ metadata: {
66
+ startedAt: now,
67
+ },
68
+ };
69
+ this.sessions.set(transportSessionId, session);
70
+ this.currentSessionId = transportSessionId;
71
+ return transportSessionId;
72
+ }
73
+ // Fallback: generate session ID (for transports that don't auto-create sessions)
51
74
  const sessionId = this.generateSessionId();
52
75
  const now = new Date().toISOString();
53
76
  const session = {
@@ -67,6 +90,34 @@ export class AcpClient {
67
90
  this.updateSessionStatus(sessionId, "connected");
68
91
  return sessionId;
69
92
  }
93
+ /**
94
+ * Load an existing session
95
+ */
96
+ async loadSession(sessionId, config) {
97
+ if (!this.transport.loadSession) {
98
+ throw new Error("Transport does not support loading sessions");
99
+ }
100
+ const now = new Date().toISOString();
101
+ // Create session object
102
+ const session = {
103
+ id: sessionId,
104
+ status: "connecting",
105
+ config: config
106
+ ? { ...config, agentPath: config.agentPath || "" }
107
+ : { agentPath: "" },
108
+ messages: [],
109
+ metadata: {
110
+ startedAt: now,
111
+ },
112
+ };
113
+ this.sessions.set(sessionId, session);
114
+ this.currentSessionId = sessionId;
115
+ // Load session from transport (will replay messages via session updates)
116
+ await this.transport.loadSession(sessionId);
117
+ // Update session status
118
+ this.updateSessionStatus(sessionId, "connected");
119
+ return sessionId;
120
+ }
70
121
  /**
71
122
  * Send a message in the current session
72
123
  */
@@ -19,8 +19,14 @@ export declare class HttpTransport implements Transport {
19
19
  private reconnecting;
20
20
  private abortController;
21
21
  private options;
22
+ private isReceivingMessages;
22
23
  constructor(options: HttpTransportOptions);
23
24
  connect(): Promise<void>;
25
+ /**
26
+ * Load an existing session instead of creating a new one
27
+ * @param sessionId - The session ID to load
28
+ */
29
+ loadSession(sessionId: string): Promise<void>;
24
30
  disconnect(): Promise<void>;
25
31
  send(message: Message): Promise<void>;
26
32
  receive(): AsyncIterableIterator<MessageChunk>;
@@ -20,6 +20,7 @@ export class HttpTransport {
20
20
  reconnecting = false;
21
21
  abortController = null;
22
22
  options;
23
+ isReceivingMessages = false;
23
24
  constructor(options) {
24
25
  // Ensure baseUrl doesn't end with a slash
25
26
  this.options = { ...options, baseUrl: options.baseUrl.replace(/\/$/, "") };
@@ -62,6 +63,72 @@ export class HttpTransport {
62
63
  throw err;
63
64
  }
64
65
  }
66
+ /**
67
+ * Load an existing session instead of creating a new one
68
+ * @param sessionId - The session ID to load
69
+ */
70
+ async loadSession(sessionId) {
71
+ if (this.connected) {
72
+ logger.warn("Transport already connected, disconnecting first", {
73
+ sessionId,
74
+ });
75
+ await this.disconnect();
76
+ }
77
+ try {
78
+ this.abortController = new AbortController();
79
+ // Step 1: Initialize the ACP connection
80
+ const initRequest = {
81
+ protocolVersion: acp.PROTOCOL_VERSION,
82
+ clientCapabilities: {
83
+ fs: {
84
+ readTextFile: true,
85
+ writeTextFile: true,
86
+ },
87
+ },
88
+ };
89
+ logger.info("Loading session - initializing connection", { sessionId });
90
+ const initResponse = await this.sendRpcRequest("initialize", initRequest);
91
+ // Check if loadSession is supported
92
+ if (!initResponse.agentCapabilities?.loadSession) {
93
+ logger.error("Agent does not support loading sessions", {
94
+ capabilities: initResponse.agentCapabilities,
95
+ });
96
+ throw new Error("Agent does not support loading sessions");
97
+ }
98
+ logger.info("ACP connection initialized, loading session", {
99
+ sessionId,
100
+ capabilities: initResponse.agentCapabilities,
101
+ });
102
+ // Step 2: Open SSE connection FIRST so we can receive replayed messages
103
+ this.currentSessionId = sessionId;
104
+ await this.connectSSE();
105
+ // Step 3: Load existing session (will trigger message replay)
106
+ const loadRequest = {
107
+ sessionId,
108
+ cwd: "/",
109
+ mcpServers: [],
110
+ };
111
+ logger.info("Sending session/load request", { loadRequest });
112
+ const loadResponse = await this.sendRpcRequest("session/load", loadRequest);
113
+ logger.info("Session loaded successfully", {
114
+ sessionId: this.currentSessionId,
115
+ loadResponse,
116
+ });
117
+ this.connected = true;
118
+ this.reconnectAttempts = 0;
119
+ }
120
+ catch (error) {
121
+ this.connected = false;
122
+ const err = error instanceof Error ? error : new Error(String(error));
123
+ logger.error("Failed to load session", {
124
+ sessionId,
125
+ error: err.message,
126
+ stack: err.stack,
127
+ });
128
+ this.notifyError(err);
129
+ throw err;
130
+ }
131
+ }
65
132
  async disconnect() {
66
133
  if (!this.connected) {
67
134
  return;
@@ -147,46 +214,54 @@ export class HttpTransport {
147
214
  }
148
215
  }
149
216
  async *receive() {
150
- // Keep yielding chunks until stream is complete
151
- while (!this.streamComplete) {
152
- // Check if there are queued messages
153
- if (this.messageQueue.length > 0) {
154
- const chunk = this.messageQueue.shift();
155
- if (chunk) {
156
- yield chunk;
217
+ // Mark that we're actively receiving messages (prevents duplicate session updates)
218
+ this.isReceivingMessages = true;
219
+ try {
220
+ // Keep yielding chunks until stream is complete
221
+ while (!this.streamComplete) {
222
+ // Check if there are queued messages
223
+ if (this.messageQueue.length > 0) {
224
+ const chunk = this.messageQueue.shift();
225
+ if (chunk) {
226
+ yield chunk;
227
+ if (chunk.isComplete) {
228
+ return;
229
+ }
230
+ }
231
+ }
232
+ else {
233
+ // Wait for next chunk to arrive
234
+ const chunk = await new Promise((resolve) => {
235
+ this.chunkResolvers.push(resolve);
236
+ });
157
237
  if (chunk.isComplete) {
238
+ yield chunk;
158
239
  return;
159
240
  }
241
+ else {
242
+ yield chunk;
243
+ }
160
244
  }
161
245
  }
162
- else {
163
- // Wait for next chunk to arrive
164
- const chunk = await new Promise((resolve) => {
165
- this.chunkResolvers.push(resolve);
166
- });
167
- if (chunk.isComplete) {
168
- yield chunk;
169
- return;
170
- }
171
- else {
246
+ // Yield any remaining queued messages
247
+ while (this.messageQueue.length > 0) {
248
+ const chunk = this.messageQueue.shift();
249
+ if (chunk) {
172
250
  yield chunk;
173
251
  }
174
252
  }
253
+ // Mark the stream as complete
254
+ yield {
255
+ id: this.currentSessionId || "unknown",
256
+ role: "assistant",
257
+ contentDelta: { type: "text", text: "" },
258
+ isComplete: true,
259
+ };
175
260
  }
176
- // Yield any remaining queued messages
177
- while (this.messageQueue.length > 0) {
178
- const chunk = this.messageQueue.shift();
179
- if (chunk) {
180
- yield chunk;
181
- }
261
+ finally {
262
+ // Reset flag when receive() completes
263
+ this.isReceivingMessages = false;
182
264
  }
183
- // Mark the stream as complete
184
- yield {
185
- id: this.currentSessionId || "unknown",
186
- role: "assistant",
187
- contentDelta: { type: "text", text: "" },
188
- isComplete: true,
189
- };
190
265
  }
191
266
  isConnected() {
192
267
  return this.connected;
@@ -215,6 +290,7 @@ export class HttpTransport {
215
290
  method,
216
291
  params,
217
292
  };
293
+ logger.debug("Sending RPC request", { method, params, request });
218
294
  const headers = {
219
295
  "Content-Type": "application/json",
220
296
  ...this.options.headers,
@@ -694,9 +770,62 @@ export class HttpTransport {
694
770
  this.messageQueue.push(chunk);
695
771
  }
696
772
  }
773
+ // Also notify as a complete message for session replay
774
+ // Only send session updates when NOT actively receiving messages (prevents duplication during normal streaming)
775
+ if (chunk &&
776
+ typeof contentObj.text === "string" &&
777
+ !this.isReceivingMessages) {
778
+ const messageSessionUpdate = {
779
+ type: "generic",
780
+ sessionId,
781
+ status: "active",
782
+ message: {
783
+ id: `msg_${Date.now()}_assistant`,
784
+ role: "assistant",
785
+ content: [
786
+ {
787
+ type: "text",
788
+ text: contentObj.text,
789
+ },
790
+ ],
791
+ timestamp: new Date().toISOString(),
792
+ },
793
+ };
794
+ // Notify as a complete message (for session replay)
795
+ this.notifySessionUpdate(messageSessionUpdate);
796
+ }
697
797
  }
698
798
  this.notifySessionUpdate(sessionUpdate);
699
799
  }
800
+ else if (update?.sessionUpdate === "user_message_chunk") {
801
+ // Handle user message chunks (could be from replay or new messages)
802
+ logger.debug("Received user_message_chunk", { update });
803
+ const content = update.content;
804
+ if (content && typeof content === "object") {
805
+ const contentObj = content;
806
+ if (contentObj.type === "text" && typeof contentObj.text === "string") {
807
+ // Notify session update with user message
808
+ const sessionUpdate = {
809
+ type: "generic",
810
+ sessionId,
811
+ status: "active",
812
+ message: {
813
+ id: `msg_${Date.now()}_user`,
814
+ role: "user",
815
+ content: [
816
+ {
817
+ type: "text",
818
+ text: contentObj.text,
819
+ },
820
+ ],
821
+ timestamp: new Date().toISOString(),
822
+ },
823
+ };
824
+ logger.debug("Notifying session update for user message");
825
+ this.notifySessionUpdate(sessionUpdate);
826
+ }
827
+ }
828
+ }
700
829
  else {
701
830
  // Handle other session updates
702
831
  const sessionUpdate = {
@@ -7,6 +7,10 @@ export interface Transport {
7
7
  * Initialize the transport connection
8
8
  */
9
9
  connect(): Promise<void>;
10
+ /**
11
+ * Load an existing session (optional, not all transports support this)
12
+ */
13
+ loadSession?(sessionId: string): Promise<void>;
10
14
  /**
11
15
  * Close the transport connection
12
16
  */
@@ -0,0 +1,5 @@
1
+ import type { LogEntry } from "../../core/lib/logger.js";
2
+ export interface LogsPanelProps {
3
+ logs: LogEntry[];
4
+ }
5
+ export declare function LogsPanel({ logs: initialLogs }: LogsPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -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.26",
3
+ "version": "0.1.27",
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.4",
43
+ "@townco/core": "0.0.5",
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.23",
65
+ "@townco/tsconfig": "0.1.24",
66
66
  "@types/node": "^24.10.0",
67
67
  "@types/react": "^19.2.2",
68
68
  "ink": "^6.4.0",