@townco/ui 0.1.51 → 0.1.52

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.
@@ -7,4 +7,5 @@ export * from "./use-chat-messages.js";
7
7
  export * from "./use-chat-session.js";
8
8
  export * from "./use-media-query.js";
9
9
  export * from "./use-message-history.js";
10
+ export * from "./use-subagent-stream.js";
10
11
  export * from "./use-tool-calls.js";
@@ -7,4 +7,5 @@ export * from "./use-chat-messages.js";
7
7
  export * from "./use-chat-session.js";
8
8
  export * from "./use-media-query.js";
9
9
  export * from "./use-message-history.js";
10
+ export * from "./use-subagent-stream.js";
10
11
  export * from "./use-tool-calls.js";
@@ -69,6 +69,90 @@ export declare function useChatMessages(client: AcpClient | null, startSession:
69
69
  originalTokens?: number | undefined;
70
70
  finalTokens?: number | undefined;
71
71
  } | undefined;
72
+ subagentPort?: number | undefined;
73
+ subagentSessionId?: string | undefined;
74
+ subagentMessages?: {
75
+ id: string;
76
+ content: string;
77
+ toolCalls?: {
78
+ id: string;
79
+ title: string;
80
+ status: "pending" | "in_progress" | "completed" | "failed";
81
+ prettyName?: string | undefined;
82
+ icon?: string | undefined;
83
+ content?: ({
84
+ type: "content";
85
+ content: {
86
+ type: "text";
87
+ text: string;
88
+ };
89
+ } | {
90
+ type: "text";
91
+ text: string;
92
+ } | {
93
+ type: "image";
94
+ data: string;
95
+ mimeType?: string | undefined;
96
+ alt?: string | undefined;
97
+ } | {
98
+ type: "image";
99
+ url: string;
100
+ alt?: string | undefined;
101
+ } | {
102
+ type: "diff";
103
+ path: string;
104
+ oldText: string;
105
+ newText: string;
106
+ line?: number | null | undefined;
107
+ } | {
108
+ type: "terminal";
109
+ terminalId: string;
110
+ })[] | undefined;
111
+ }[] | undefined;
112
+ contentBlocks?: ({
113
+ type: "text";
114
+ text: string;
115
+ } | {
116
+ type: "tool_call";
117
+ toolCall: {
118
+ id: string;
119
+ title: string;
120
+ status: "pending" | "in_progress" | "completed" | "failed";
121
+ prettyName?: string | undefined;
122
+ icon?: string | undefined;
123
+ content?: ({
124
+ type: "content";
125
+ content: {
126
+ type: "text";
127
+ text: string;
128
+ };
129
+ } | {
130
+ type: "text";
131
+ text: string;
132
+ } | {
133
+ type: "image";
134
+ data: string;
135
+ mimeType?: string | undefined;
136
+ alt?: string | undefined;
137
+ } | {
138
+ type: "image";
139
+ url: string;
140
+ alt?: string | undefined;
141
+ } | {
142
+ type: "diff";
143
+ path: string;
144
+ oldText: string;
145
+ newText: string;
146
+ line?: number | null | undefined;
147
+ } | {
148
+ type: "terminal";
149
+ terminalId: string;
150
+ })[] | undefined;
151
+ };
152
+ })[] | undefined;
153
+ isStreaming?: boolean | undefined;
154
+ }[] | undefined;
155
+ subagentStreaming?: boolean | undefined;
72
156
  }[] | undefined;
73
157
  tokenUsage?: {
74
158
  inputTokens?: number | undefined;
@@ -112,6 +112,18 @@ export function useChatSession(client, initialSessionId) {
112
112
  setConnectionStatus("connecting");
113
113
  setError(null);
114
114
  await client.connect();
115
+ // Get the session ID from the transport after connecting
116
+ // (the transport creates a session during connect)
117
+ const currentSession = client.getCurrentSession();
118
+ if (currentSession?.id) {
119
+ setSessionId(currentSession.id);
120
+ // Update URL with session ID
121
+ if (typeof window !== "undefined") {
122
+ const url = new URL(window.location.href);
123
+ url.searchParams.set("session", currentSession.id);
124
+ window.history.replaceState({}, "", url.toString());
125
+ }
126
+ }
115
127
  setConnectionStatus("connected");
116
128
  }
117
129
  catch (error) {
@@ -122,7 +134,7 @@ export function useChatSession(client, initialSessionId) {
122
134
  setError(message);
123
135
  setConnectionStatus("error");
124
136
  }
125
- }, [client, setConnectionStatus, setError]);
137
+ }, [client, setConnectionStatus, setSessionId, setError]);
126
138
  /**
127
139
  * Load an existing session
128
140
  */
@@ -197,8 +209,13 @@ export function useChatSession(client, initialSessionId) {
197
209
  try {
198
210
  const id = await client.startSession();
199
211
  setSessionId(id);
200
- clearMessages();
201
- resetTokens();
212
+ // Only clear messages if there are no messages from initial message
213
+ // (initial messages are added before startSession is called)
214
+ const currentMessages = useChatStore.getState().messages;
215
+ if (currentMessages.length === 0) {
216
+ clearMessages();
217
+ resetTokens();
218
+ }
202
219
  // Update URL with new session ID
203
220
  if (typeof window !== "undefined") {
204
221
  const url = new URL(window.location.href);
@@ -0,0 +1,28 @@
1
+ import type { SubagentMessage } from "../schemas/tool-call.js";
2
+ export interface UseSubagentStreamOptions {
3
+ /** Sub-agent HTTP port */
4
+ port: number;
5
+ /** Sub-agent session ID */
6
+ sessionId: string;
7
+ /** Base host (defaults to localhost) */
8
+ host?: string;
9
+ }
10
+ export interface UseSubagentStreamReturn {
11
+ /** Accumulated messages from the sub-agent */
12
+ messages: SubagentMessage[];
13
+ /** Whether the stream is currently active */
14
+ isStreaming: boolean;
15
+ /** Error message if connection failed */
16
+ error: string | null;
17
+ }
18
+ /**
19
+ * Hook to connect directly to a sub-agent's SSE endpoint and stream messages.
20
+ *
21
+ * This hook:
22
+ * - Connects to the sub-agent's HTTP server at the given port
23
+ * - Subscribes to the /events SSE endpoint with the session ID
24
+ * - Parses incoming session/update notifications
25
+ * - Extracts text chunks and tool calls
26
+ * - Returns accumulated messages for display
27
+ */
28
+ export declare function useSubagentStream(options: UseSubagentStreamOptions | null): UseSubagentStreamReturn;
@@ -0,0 +1,254 @@
1
+ import { createLogger } from "@townco/core";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ const logger = createLogger("subagent-stream");
4
+ /**
5
+ * Hook to connect directly to a sub-agent's SSE endpoint and stream messages.
6
+ *
7
+ * This hook:
8
+ * - Connects to the sub-agent's HTTP server at the given port
9
+ * - Subscribes to the /events SSE endpoint with the session ID
10
+ * - Parses incoming session/update notifications
11
+ * - Extracts text chunks and tool calls
12
+ * - Returns accumulated messages for display
13
+ */
14
+ export function useSubagentStream(options) {
15
+ const [messages, setMessages] = useState([]);
16
+ // Start as streaming=true if options provided, since we're about to connect
17
+ const [isStreaming, setIsStreaming] = useState(!!options);
18
+ const [hasCompleted, setHasCompleted] = useState(false);
19
+ const [error, setError] = useState(null);
20
+ const abortControllerRef = useRef(null);
21
+ const currentMessageRef = useRef(null);
22
+ const updateTimeoutRef = useRef(null);
23
+ // Throttled update to prevent excessive re-renders
24
+ const scheduleUpdate = useCallback(() => {
25
+ // If there's already a pending timeout, let it handle the update
26
+ // (it will read the latest currentMessageRef value)
27
+ if (updateTimeoutRef.current)
28
+ return;
29
+ updateTimeoutRef.current = setTimeout(() => {
30
+ updateTimeoutRef.current = null;
31
+ if (currentMessageRef.current) {
32
+ setMessages([{ ...currentMessageRef.current }]);
33
+ }
34
+ }, 250); // Batch updates every 250ms
35
+ }, []);
36
+ // Process incoming SSE message from sub-agent
37
+ // Defined BEFORE connectToSubagent so it's available in the closure
38
+ const processSSEMessage = useCallback((data) => {
39
+ try {
40
+ const message = JSON.parse(data);
41
+ logger.debug("Processing SSE message", {
42
+ method: message.method,
43
+ hasParams: !!message.params,
44
+ });
45
+ // Check if this is a session/update notification
46
+ if (message.method === "session/update" && message.params?.update) {
47
+ const update = message.params.update;
48
+ logger.debug("Got session update", {
49
+ sessionUpdate: update.sessionUpdate,
50
+ });
51
+ if (update.sessionUpdate === "agent_message_chunk") {
52
+ // Handle text chunk
53
+ const content = update.content;
54
+ if (content?.type === "text" && typeof content.text === "string") {
55
+ if (currentMessageRef.current) {
56
+ currentMessageRef.current.content += content.text;
57
+ // Add to contentBlocks - append to last text block or create new one
58
+ const blocks = currentMessageRef.current.contentBlocks ?? [];
59
+ const lastBlock = blocks[blocks.length - 1];
60
+ if (lastBlock && lastBlock.type === "text") {
61
+ lastBlock.text += content.text;
62
+ }
63
+ else {
64
+ blocks.push({ type: "text", text: content.text });
65
+ }
66
+ currentMessageRef.current.contentBlocks = blocks;
67
+ scheduleUpdate();
68
+ }
69
+ }
70
+ }
71
+ else if (update.sessionUpdate === "tool_call") {
72
+ // Handle new tool call
73
+ const toolCall = {
74
+ id: update.toolCallId ?? `tc-${Date.now()}`,
75
+ title: update.title ?? "Tool call",
76
+ prettyName: update._meta?.prettyName,
77
+ icon: update._meta?.icon,
78
+ status: update.status ?? "pending",
79
+ content: [],
80
+ };
81
+ if (currentMessageRef.current) {
82
+ currentMessageRef.current.toolCalls = [
83
+ ...(currentMessageRef.current.toolCalls ?? []),
84
+ toolCall,
85
+ ];
86
+ // Add to contentBlocks for interleaved display
87
+ const blocks = currentMessageRef.current.contentBlocks ?? [];
88
+ blocks.push({ type: "tool_call", toolCall });
89
+ currentMessageRef.current.contentBlocks = blocks;
90
+ scheduleUpdate();
91
+ }
92
+ }
93
+ else if (update.sessionUpdate === "tool_call_update") {
94
+ // Handle tool call update (status change, completion)
95
+ if (currentMessageRef.current?.toolCalls) {
96
+ const toolCallId = update.toolCallId;
97
+ const updateToolCall = (tc) => tc.id === toolCallId
98
+ ? {
99
+ ...tc,
100
+ status: update.status ?? tc.status,
101
+ content: update.content ?? tc.content,
102
+ }
103
+ : tc;
104
+ currentMessageRef.current.toolCalls =
105
+ currentMessageRef.current.toolCalls.map(updateToolCall);
106
+ // Also update in contentBlocks
107
+ if (currentMessageRef.current.contentBlocks) {
108
+ currentMessageRef.current.contentBlocks =
109
+ currentMessageRef.current.contentBlocks.map((block) => block.type === "tool_call"
110
+ ? { ...block, toolCall: updateToolCall(block.toolCall) }
111
+ : block);
112
+ }
113
+ scheduleUpdate();
114
+ }
115
+ }
116
+ }
117
+ }
118
+ catch (err) {
119
+ logger.error("Failed to parse sub-agent SSE message", {
120
+ error: err instanceof Error ? err.message : String(err),
121
+ });
122
+ }
123
+ }, [scheduleUpdate]);
124
+ const connectToSubagent = useCallback(async (port, sessionId, host) => {
125
+ const baseUrl = `http://${host}:${port}`;
126
+ logger.info("Connecting to sub-agent SSE", { baseUrl, sessionId });
127
+ setIsStreaming(true);
128
+ setError(null);
129
+ // Create abort controller for cleanup
130
+ const abortController = new AbortController();
131
+ abortControllerRef.current = abortController;
132
+ try {
133
+ logger.info("Fetching SSE endpoint", {
134
+ url: `${baseUrl}/events`,
135
+ sessionId,
136
+ });
137
+ const response = await fetch(`${baseUrl}/events`, {
138
+ method: "GET",
139
+ headers: {
140
+ "X-Session-ID": sessionId,
141
+ },
142
+ signal: abortController.signal,
143
+ });
144
+ logger.info("SSE response received", {
145
+ status: response.status,
146
+ ok: response.ok,
147
+ });
148
+ if (!response.ok) {
149
+ throw new Error(`SSE connection failed: HTTP ${response.status}`);
150
+ }
151
+ if (!response.body) {
152
+ throw new Error("Response body is null");
153
+ }
154
+ logger.info("Sub-agent SSE connection opened, starting to read stream");
155
+ // Read the SSE stream
156
+ const reader = response.body.getReader();
157
+ const decoder = new TextDecoder();
158
+ let buffer = "";
159
+ // Initialize current message
160
+ currentMessageRef.current = {
161
+ id: `subagent-${Date.now()}`,
162
+ content: "",
163
+ toolCalls: [],
164
+ contentBlocks: [],
165
+ isStreaming: true,
166
+ };
167
+ setMessages([currentMessageRef.current]);
168
+ while (true) {
169
+ const { done, value } = await reader.read();
170
+ if (done) {
171
+ logger.debug("Sub-agent SSE stream closed");
172
+ break;
173
+ }
174
+ // Decode the chunk and add to buffer
175
+ buffer += decoder.decode(value, { stream: true });
176
+ // Process complete SSE messages
177
+ const lines = buffer.split("\n");
178
+ buffer = lines.pop() || ""; // Keep incomplete line in buffer
179
+ let currentEvent = { event: "message", data: "" };
180
+ for (const line of lines) {
181
+ if (line.startsWith("event:")) {
182
+ currentEvent.event = line.substring(6).trim();
183
+ }
184
+ else if (line.startsWith("data:")) {
185
+ currentEvent.data = line.substring(5).trim();
186
+ }
187
+ else if (line === "") {
188
+ // Empty line signals end of event
189
+ if (currentEvent.event === "message" && currentEvent.data) {
190
+ processSSEMessage(currentEvent.data);
191
+ }
192
+ // Reset for next event
193
+ currentEvent = { event: "message", data: "" };
194
+ }
195
+ }
196
+ }
197
+ }
198
+ catch (err) {
199
+ if (err instanceof Error && err.name === "AbortError") {
200
+ logger.debug("Sub-agent SSE stream aborted");
201
+ }
202
+ else {
203
+ const errorMessage = err instanceof Error
204
+ ? err.message
205
+ : "Failed to connect to sub-agent";
206
+ logger.error("Sub-agent SSE error", { error: errorMessage });
207
+ setError(errorMessage);
208
+ }
209
+ }
210
+ finally {
211
+ // Mark streaming as complete
212
+ if (currentMessageRef.current) {
213
+ currentMessageRef.current.isStreaming = false;
214
+ setMessages((prev) => prev.map((m) => m.id === currentMessageRef.current?.id
215
+ ? { ...m, isStreaming: false }
216
+ : m));
217
+ }
218
+ setHasCompleted(true);
219
+ setIsStreaming(false);
220
+ abortControllerRef.current = null;
221
+ logger.debug("Sub-agent stream completed");
222
+ }
223
+ }, [processSSEMessage]);
224
+ // Connect when options change
225
+ useEffect(() => {
226
+ if (!options) {
227
+ return;
228
+ }
229
+ const { port, sessionId, host = "localhost" } = options;
230
+ if (!port || !sessionId) {
231
+ return;
232
+ }
233
+ // Reset state for new connection
234
+ setMessages([]);
235
+ setError(null);
236
+ setHasCompleted(false);
237
+ setIsStreaming(true);
238
+ connectToSubagent(port, sessionId, host);
239
+ // Cleanup on unmount or options change
240
+ return () => {
241
+ if (abortControllerRef.current) {
242
+ abortControllerRef.current.abort();
243
+ abortControllerRef.current = null;
244
+ }
245
+ if (updateTimeoutRef.current) {
246
+ clearTimeout(updateTimeoutRef.current);
247
+ updateTimeoutRef.current = null;
248
+ }
249
+ };
250
+ }, [options?.port, options?.sessionId, options?.host, connectToSubagent]);
251
+ // Derive streaming status: streaming if we haven't completed yet
252
+ const effectiveIsStreaming = !hasCompleted;
253
+ return { messages, isStreaming: effectiveIsStreaming, error };
254
+ }
@@ -67,6 +67,90 @@ export declare function useToolCalls(client: AcpClient | null): {
67
67
  originalTokens?: number | undefined;
68
68
  finalTokens?: number | undefined;
69
69
  } | undefined;
70
+ subagentPort?: number | undefined;
71
+ subagentSessionId?: string | undefined;
72
+ subagentMessages?: {
73
+ id: string;
74
+ content: string;
75
+ toolCalls?: {
76
+ id: string;
77
+ title: string;
78
+ status: "pending" | "in_progress" | "completed" | "failed";
79
+ prettyName?: string | undefined;
80
+ icon?: string | undefined;
81
+ content?: ({
82
+ type: "content";
83
+ content: {
84
+ type: "text";
85
+ text: string;
86
+ };
87
+ } | {
88
+ type: "text";
89
+ text: string;
90
+ } | {
91
+ type: "image";
92
+ data: string;
93
+ mimeType?: string | undefined;
94
+ alt?: string | undefined;
95
+ } | {
96
+ type: "image";
97
+ url: string;
98
+ alt?: string | undefined;
99
+ } | {
100
+ type: "diff";
101
+ path: string;
102
+ oldText: string;
103
+ newText: string;
104
+ line?: number | null | undefined;
105
+ } | {
106
+ type: "terminal";
107
+ terminalId: string;
108
+ })[] | undefined;
109
+ }[] | undefined;
110
+ contentBlocks?: ({
111
+ type: "text";
112
+ text: string;
113
+ } | {
114
+ type: "tool_call";
115
+ toolCall: {
116
+ id: string;
117
+ title: string;
118
+ status: "pending" | "in_progress" | "completed" | "failed";
119
+ prettyName?: string | undefined;
120
+ icon?: string | undefined;
121
+ content?: ({
122
+ type: "content";
123
+ content: {
124
+ type: "text";
125
+ text: string;
126
+ };
127
+ } | {
128
+ type: "text";
129
+ text: string;
130
+ } | {
131
+ type: "image";
132
+ data: string;
133
+ mimeType?: string | undefined;
134
+ alt?: string | undefined;
135
+ } | {
136
+ type: "image";
137
+ url: string;
138
+ alt?: string | undefined;
139
+ } | {
140
+ type: "diff";
141
+ path: string;
142
+ oldText: string;
143
+ newText: string;
144
+ line?: number | null | undefined;
145
+ } | {
146
+ type: "terminal";
147
+ terminalId: string;
148
+ })[] | undefined;
149
+ };
150
+ })[] | undefined;
151
+ isStreaming?: boolean | undefined;
152
+ }[] | undefined;
153
+ subagentStreaming?: boolean | undefined;
70
154
  }[]>;
71
155
  getToolCallsForSession: (sessionId: string) => ToolCall[];
72
156
  };