@townco/ui 0.1.116 → 0.1.118

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.
@@ -330,30 +330,7 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
330
330
  logger.info("Prompt clicked", { prompt });
331
331
  }, onPromptHover: handlePromptHover, onPromptLeave: handlePromptLeave, onOpenFiles: openFiles, onOpenSettings: openSettings, toolsAndMcpsCount: agentTools.length +
332
332
  agentMcps.length +
333
- agentSubagents.length }) })) })) : null) : (_jsx("div", { className: "flex flex-col px-4", children: messages.map((message, index) => {
334
- // Calculate dynamic spacing based on message sequence
335
- const isFirst = index === 0;
336
- const previousMessage = isFirst
337
- ? null
338
- : messages[index - 1];
339
- let spacingClass = "mt-2";
340
- if (isFirst) {
341
- // First message needs more top margin if it's an assistant initial message
342
- spacingClass =
343
- message.role === "assistant" ? "mt-8" : "mt-2";
344
- }
345
- else if (message.role === "user") {
346
- // User message usually starts a new turn
347
- spacingClass =
348
- previousMessage?.role === "user" ? "mt-4" : "mt-4";
349
- }
350
- else if (message.role === "assistant") {
351
- // Assistant message is usually a response
352
- spacingClass =
353
- previousMessage?.role === "assistant"
354
- ? "mt-2"
355
- : "mt-6";
356
- }
333
+ agentSubagents.length }) })) })) : null) : (_jsx("div", { className: "flex flex-col px-4 py-4", children: messages.map((message, index) => {
357
334
  // Check if any message is streaming
358
335
  const anyMessageStreaming = messages.some((m) => m.isStreaming);
359
336
  // Calculate which user message number this is (1-indexed for display, 0-indexed for API)
@@ -366,7 +343,7 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
366
343
  // Check if this message should be dimmed (comes after editing message)
367
344
  const shouldDim = editingMessageIndex !== null &&
368
345
  index > editingMessageIndex;
369
- return (_jsx(Message, { message: message, className: cn(spacingClass, "group", shouldDim && "opacity-50"), isLastMessage: index === messages.length - 1, children: _jsx("div", { className: "flex flex-col w-full", children: message.role === "user" ? (_jsx(EditableUserMessage, { message: message, messageIndex: userMessageIndex, isStreaming: anyMessageStreaming, onEditAndResend: editAndResend, sticky: true, onEditingChange: (isEditing) => {
346
+ return (_jsx(Message, { message: message, className: cn("group", shouldDim && "opacity-50"), isLastMessage: index === messages.length - 1, children: _jsx("div", { className: "flex flex-col w-full min-w-0", children: message.role === "user" ? (_jsx(EditableUserMessage, { message: message, messageIndex: userMessageIndex, isStreaming: anyMessageStreaming, onEditAndResend: editAndResend, sticky: true, onEditingChange: (isEditing) => {
370
347
  setEditingMessageIndex(isEditing ? index : null);
371
348
  } })) : (_jsxs(_Fragment, { children: [
372
349
  _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }), _jsx(MessageActions, { message: message, isStreaming: message.isStreaming, onSendMessage: sendMessage, isLastAssistantMessage: index ===
@@ -114,7 +114,7 @@ function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAnd
114
114
  }
115
115
  : undefined, role: sticky && !isEditing ? "button" : undefined, tabIndex: sticky && !isEditing ? 0 : undefined, children: _jsxs("div", { className: "flex flex-col gap-2", children: [message.images && message.images.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: message.images.map((image, imageIndex) => (_jsx("img", { src: `data:${image.mimeType};base64,${image.data}`, alt: `Attachment ${imageIndex + 1}`, className: cn("max-w-[200px] max-h-[200px] rounded-lg object-cover", isEditing && "opacity-50") }, `image-${image.mimeType}-${image.data.slice(0, 20)}`))) })), message.content && (
116
116
  // biome-ignore lint/a11y/useSemanticElements: contentEditable div preserves whitespace formatting better than textarea
117
- _jsx("div", { ref: contentEditableRef, role: "textbox", tabIndex: isEditing ? 0 : -1, contentEditable: isEditing, onKeyDown: isEditing ? handleKeyDown : undefined, suppressContentEditableWarning: true, className: cn("whitespace-pre-wrap text-foreground leading-relaxed outline-none", isEditing && "cursor-text", !isEditing && "cursor-default"), children: message.content }))] }) }), !isStreaming && message.content && (_jsx(Actions, { className: cn("mt-2 transition-opacity justify-end", isEditing
117
+ _jsx("div", { ref: contentEditableRef, role: "textbox", tabIndex: isEditing ? 0 : -1, contentEditable: isEditing, onKeyDown: isEditing ? handleKeyDown : undefined, suppressContentEditableWarning: true, className: cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-foreground leading-relaxed outline-none", isEditing && "cursor-text", !isEditing && "cursor-default"), children: message.content }))] }) }), !isStreaming && message.content && (_jsx(Actions, { className: cn("mt-2 transition-opacity justify-end", isEditing
118
118
  ? "opacity-100"
119
119
  : "opacity-0 group-hover/user-message:opacity-100"), children: isEditing ? (_jsxs(_Fragment, { children: [
120
120
  _jsx(Button, { variant: "ghost", size: "sm", onClick: handleCancelEdit, className: "h-7 px-2 text-xs text-muted-foreground hover:text-foreground", children: "Cancel" }), _jsx(Button, { size: "sm", onClick: handleSaveAndResend, className: "h-7 px-3 text-xs", children: "Send" })
@@ -6,10 +6,10 @@ import { cn } from "../lib/utils.js";
6
6
  * Message wrapper component inspired by shadcn.io/ai
7
7
  * Provides role-based layout and styling for chat messages
8
8
  */
9
- const messageVariants = cva("flex animate-fadeIn", {
9
+ const messageVariants = cva("flex min-w-0 animate-fadeIn", {
10
10
  variants: {
11
11
  role: {
12
- user: "max-w-[80%] self-end ml-auto mr-2",
12
+ user: "max-w-[80%] self-end ml-auto",
13
13
  assistant: "self-start mr-auto",
14
14
  system: "self-start mr-auto max-w-full",
15
15
  },
@@ -63,7 +63,7 @@ function PureMessageActions({ message, isStreaming, onRedo, onSendMessage, isLas
63
63
  toast.info("Export not available");
64
64
  }
65
65
  };
66
- return (_jsxs(Actions, { className: cn("mt-2 mb-10", visibilityClass), children: [
66
+ return (_jsxs(Actions, { className: cn(visibilityClass), children: [
67
67
  _jsx(Action, { onClick: handleCopy, tooltip: isCopied ? "Copied!" : "Copy", children: isCopied ? _jsx(Check, { className: "size-4" }) : _jsx(Copy, { className: "size-4" }) }), _jsx(Action, { onClick: handleRedo, tooltip: "Redo", children: _jsx(RotateCcw, { className: "size-4" }) }), _jsxs(DropdownMenu, { children: [
68
68
  _jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [
69
69
  _jsx(TooltipTrigger, { asChild: true, children: _jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { className: cn("relative size-7 p-1.5 text-muted-foreground hover:text-foreground"), size: "sm", type: "button", variant: "ghost", children: [
@@ -13,11 +13,11 @@ import { ToolOperation } from "./ToolOperation.js";
13
13
  * Provides the content container with role-based styling
14
14
  * Handles automatic rendering of thinking, waiting states, and content
15
15
  */
16
- const messageContentVariants = cva("w-full rounded-2xl text-[var(--font-size)] font-[var(--font-family)] leading-relaxed break-words transition-colors", {
16
+ const messageContentVariants = cva("w-full min-w-0 rounded-2xl text-[var(--font-size)] font-[var(--font-family)] leading-relaxed break-words [overflow-wrap:anywhere] transition-colors", {
17
17
  variants: {
18
18
  role: {
19
19
  user: "bg-secondary text-foreground px-4 py-4 text-paragraph",
20
- assistant: "text-foreground text-paragraph-sm",
20
+ assistant: "text-foreground text-paragraph",
21
21
  system: "bg-card border border-border text-foreground opacity-80 text-caption px-4 py-3",
22
22
  },
23
23
  variant: {
@@ -40,6 +40,11 @@ function countCitations(text) {
40
40
  count++;
41
41
  return count;
42
42
  }
43
+ function trimTrailingNewlines(text) {
44
+ // Avoid rendering an extra blank line at the end of assistant markdown
45
+ // (especially noticeable right before tool-operation blocks).
46
+ return text.replace(/\n+$/g, "");
47
+ }
43
48
  export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStreaming: isStreamingProp, message, thinkingDisplayStyle = "collapsible", className, children, ...props }, ref) => {
44
49
  // Get streaming start time and current model from store
45
50
  const streamingStartTime = useChatStore((state) => state.streamingStartTime);
@@ -70,7 +75,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
70
75
  const hasThinking = !!thinking;
71
76
  // Check if waiting (streaming but no content yet)
72
77
  const isWaiting = message.isStreaming && !message.content && message.role === "assistant";
73
- content = (_jsxs(_Fragment, { children: [message.role === "assistant" && hasThinking && (_jsx("div", { children: _jsx(Reasoning, { content: thinking, isStreaming: message.isStreaming, mode: thinkingDisplayStyle, autoCollapse: true }) })), isWaiting && streamingStartTime && (_jsx("div", { className: "flex flex-col my-4 rounded-md px-1 -mx-1 w-fit", children: _jsx("div", { className: "flex items-center gap-1.5", children: _jsx("span", { className: "text-paragraph-sm text-text-secondary/70", children: "Working..." }) }) })), message.role === "assistant" ? ((() => {
78
+ content = (_jsxs(_Fragment, { children: [message.role === "assistant" && hasThinking && (_jsx("div", { children: _jsx(Reasoning, { content: thinking, isStreaming: message.isStreaming, mode: thinkingDisplayStyle, autoCollapse: true }) })), isWaiting && streamingStartTime && (_jsx("div", { className: "flex flex-col rounded-md px-1 -mx-1 w-fit", children: _jsx("div", { className: "flex items-center gap-1.5", children: _jsx("span", { className: "text-paragraph-sm text-text-secondary/70", children: "Working..." }) }) })), message.role === "assistant" ? ((() => {
74
79
  // Sort tool calls by content position
75
80
  const sortedToolCalls = (message.toolCalls || [])
76
81
  .slice()
@@ -210,7 +215,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
210
215
  const hasHookPositions = hookNotifications.some((n) => n.contentPosition !== undefined);
211
216
  if (!hasHookPositions) {
212
217
  // No positions - render hooks at top, then content, then tool calls
213
- return (_jsxs(_Fragment, { children: [hookNotifications.length > 0 && (_jsx("div", { className: "flex flex-col gap-2 mb-1", children: hookNotifications.map((notification) => (_jsx(HookNotification, { notification: notification }, notification.id))) })), _jsx("div", { children: _jsx(Response, { content: message.content, isStreaming: message.isStreaming, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: 0 }) }), groupedToolCalls.length > 0 && (_jsx("div", { className: "flex flex-col gap-2 mb-1", children: groupedToolCalls.map((item, index) => renderToolCallOrGroup(item, index)) })), shouldShowThinkingIndicator && (_jsx("div", { className: "flex flex-col my-4 rounded-md px-1 -mx-1 w-fit", children: _jsx("div", { className: "flex items-center gap-1.5", children: _jsx("span", { className: "text-paragraph-sm text-text-secondary/70", children: "Thinking..." }) }) }))] }));
218
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [hookNotifications.length > 0 && (_jsx("div", { className: "flex flex-col gap-2", children: hookNotifications.map((notification) => (_jsx(HookNotification, { notification: notification }, notification.id))) })), _jsx("div", { children: _jsx(Response, { content: trimTrailingNewlines(message.content), isStreaming: message.isStreaming, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: 0 }) }), groupedToolCalls.length > 0 && (_jsx("div", { className: "flex flex-col gap-2", children: groupedToolCalls.map((item, index) => renderToolCallOrGroup(item, index)) })), shouldShowThinkingIndicator && (_jsx("div", { className: "flex flex-col rounded-md px-1 -mx-1 w-fit", children: _jsx("div", { className: "flex items-center gap-1.5", children: _jsx("span", { className: "text-paragraph-sm text-text-secondary/70", children: "Thinking..." }) }) }))] }));
214
219
  }
215
220
  // Hooks have positions - render them inline with content
216
221
  const elements = [];
@@ -221,9 +226,10 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
221
226
  // Add text before this hook notification
222
227
  if (position > currentPosition) {
223
228
  const textChunk = message.content.slice(currentPosition, position);
224
- if (textChunk) {
225
- elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, `text-before-hook-${notification.id}`));
226
- citationOffset += countCitations(textChunk);
229
+ const trimmedChunk = trimTrailingNewlines(textChunk);
230
+ if (trimmedChunk) {
231
+ elements.push(_jsx("div", { children: _jsx(Response, { content: trimmedChunk, isStreaming: false, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, `text-before-hook-${notification.id}`));
232
+ citationOffset += countCitations(trimmedChunk);
227
233
  }
228
234
  }
229
235
  // Add hook notification
@@ -233,9 +239,10 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
233
239
  // Add remaining text
234
240
  if (currentPosition < message.content.length) {
235
241
  const remainingText = message.content.slice(currentPosition);
236
- if (remainingText) {
237
- elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, "text-end-hooks"));
238
- citationOffset += countCitations(remainingText);
242
+ const trimmedRemaining = trimTrailingNewlines(remainingText);
243
+ if (trimmedRemaining) {
244
+ elements.push(_jsx("div", { children: _jsx(Response, { content: trimmedRemaining, isStreaming: message.isStreaming, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, "text-end-hooks"));
245
+ citationOffset += countCitations(trimmedRemaining);
239
246
  }
240
247
  }
241
248
  // Add tool calls at the end
@@ -244,7 +251,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
244
251
  elements.push(renderToolCallOrGroup(item, index));
245
252
  });
246
253
  }
247
- return _jsx(_Fragment, { children: elements });
254
+ return _jsx("div", { className: "flex flex-col gap-2", children: elements });
248
255
  }
249
256
  // Render content interleaved with tool calls and hook notifications
250
257
  // Group consecutive tool calls with the same batchId or same title
@@ -299,12 +306,13 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
299
306
  // Flush any pending batch before adding text
300
307
  flushBatch();
301
308
  const textChunk = message.content.slice(currentPosition, position);
302
- if (textChunk) {
309
+ const trimmedChunk = trimTrailingNewlines(textChunk);
310
+ if (trimmedChunk) {
303
311
  const itemId = positionedItem.type === "toolCall"
304
312
  ? positionedItem.item.id
305
313
  : positionedItem.item.id;
306
- elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, `text-before-${itemId}`));
307
- citationOffset += countCitations(textChunk);
314
+ elements.push(_jsx("div", { children: _jsx(Response, { content: trimmedChunk, isStreaming: false, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, `text-before-${itemId}`));
315
+ citationOffset += countCitations(trimmedChunk);
308
316
  }
309
317
  }
310
318
  if (positionedItem.type === "hookNotification") {
@@ -357,9 +365,10 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
357
365
  // Add remaining text after the last non-preliminary tool call
358
366
  if (currentPosition < message.content.length) {
359
367
  const remainingText = message.content.slice(currentPosition);
360
- if (remainingText) {
361
- elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, "text-end"));
362
- citationOffset += countCitations(remainingText);
368
+ const trimmedRemaining = trimTrailingNewlines(remainingText);
369
+ if (trimmedRemaining) {
370
+ elements.push(_jsx("div", { children: _jsx(Response, { content: trimmedRemaining, isStreaming: message.isStreaming, showEmpty: false, sources: sourcesForResponse, messageId: message.id, citationIndexOffset: citationOffset }) }, "text-end"));
371
+ citationOffset += countCitations(trimmedRemaining);
363
372
  }
364
373
  }
365
374
  // Render preliminary (selecting) tool calls at the end, grouped
@@ -377,10 +386,10 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
377
386
  }
378
387
  // Add thinking indicator if all tool calls are complete but message is still streaming
379
388
  if (shouldShowThinkingIndicator) {
380
- elements.push(_jsx("div", { className: "flex flex-col my-4 rounded-md px-1 -mx-1 w-fit", children: _jsx("div", { className: "flex items-center gap-1.5", children: _jsx("span", { className: "text-paragraph-sm text-text-secondary/70", children: "Thinking..." }) }) }, "thinking-indicator"));
389
+ elements.push(_jsx("div", { className: "flex flex-col rounded-md px-1 -mx-1 w-fit", children: _jsx("div", { className: "flex items-center gap-1.5", children: _jsx("span", { className: "text-paragraph-sm text-text-secondary/70", children: "Thinking..." }) }) }, "thinking-indicator"));
381
390
  }
382
- return _jsx(_Fragment, { children: elements });
383
- })()) : (_jsxs("div", { className: "flex flex-col gap-2", children: [message.images && message.images.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: message.images.map((image, imageIndex) => (_jsx("img", { src: `data:${image.mimeType};base64,${image.data}`, alt: `Attachment ${imageIndex + 1}`, className: "max-w-[200px] max-h-[200px] rounded-lg object-cover" }, `image-${image.mimeType}-${image.data.slice(0, 20)}`))) })), message.content && (_jsx("div", { className: "whitespace-pre-wrap", children: message.content }))] }))] }));
391
+ return _jsx("div", { className: "flex flex-col gap-2", children: elements });
392
+ })()) : (_jsxs("div", { className: "flex flex-col gap-2", children: [message.images && message.images.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: message.images.map((image, imageIndex) => (_jsx("img", { src: `data:${image.mimeType};base64,${image.data}`, alt: `Attachment ${imageIndex + 1}`, className: "max-w-[200px] max-h-[200px] rounded-lg object-cover" }, `image-${image.mimeType}-${image.data.slice(0, 20)}`))) })), message.content && (_jsx("div", { className: "whitespace-pre-wrap break-words [overflow-wrap:anywhere]", children: message.content }))] }))] }));
384
393
  }
385
394
  return (_jsx("div", { ref: ref, className: cn(messageContentVariants({ role, variant }), isStreaming && "animate-pulse-subtle", className), ...props, children: content }));
386
395
  });
@@ -1,13 +1,79 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { createLogger } from "@townco/core";
4
+ import { Check, Copy, Download } from "lucide-react";
4
5
  import * as React from "react";
5
6
  import remarkGfm from "remark-gfm";
7
+ import { toast } from "sonner";
6
8
  import { Streamdown } from "streamdown";
7
9
  import { remarkCitations } from "../lib/remark-citations.js";
8
10
  import { cn } from "../lib/utils.js";
11
+ import { Action, Actions } from "./Actions.js";
9
12
  import { CitationChip } from "./CitationChip.js";
10
13
  const logger = createLogger("Response", "debug");
14
+ function escapeCsvCell(value) {
15
+ // Escape double quotes by doubling them, and wrap in quotes if needed.
16
+ const mustQuote = /[",\n\r]/.test(value);
17
+ const escaped = value.replaceAll('"', '""');
18
+ return mustQuote ? `"${escaped}"` : escaped;
19
+ }
20
+ function tableToCsv(table) {
21
+ const rows = Array.from(table.querySelectorAll("tr"));
22
+ return rows
23
+ .map((row) => {
24
+ const cells = Array.from(row.querySelectorAll("th,td"));
25
+ return cells
26
+ .map((cell) => escapeCsvCell((cell.textContent || "").trim()))
27
+ .join(",");
28
+ })
29
+ .join("\n");
30
+ }
31
+ function downloadTextFile(filename, content, mime) {
32
+ const blob = new Blob([content], { type: mime });
33
+ const url = URL.createObjectURL(blob);
34
+ const a = document.createElement("a");
35
+ a.href = url;
36
+ a.download = filename;
37
+ document.body.appendChild(a);
38
+ a.click();
39
+ document.body.removeChild(a);
40
+ URL.revokeObjectURL(url);
41
+ }
42
+ function TableWithActions(tableProps) {
43
+ const { className, ...rest } = tableProps;
44
+ const tableRef = React.useRef(null);
45
+ const [isCopied, setIsCopied] = React.useState(false);
46
+ const handleCopy = React.useCallback(async () => {
47
+ const table = tableRef.current;
48
+ if (!table)
49
+ return;
50
+ try {
51
+ const csv = tableToCsv(table);
52
+ await navigator.clipboard.writeText(csv);
53
+ setIsCopied(true);
54
+ toast.success("Copied table as CSV");
55
+ setTimeout(() => setIsCopied(false), 1500);
56
+ }
57
+ catch {
58
+ toast.error("Failed to copy table");
59
+ }
60
+ }, []);
61
+ const handleDownload = React.useCallback(() => {
62
+ const table = tableRef.current;
63
+ if (!table)
64
+ return;
65
+ const csv = tableToCsv(table);
66
+ downloadTextFile("table.csv", csv, "text/csv;charset=utf-8");
67
+ toast.success("Downloaded table.csv");
68
+ }, []);
69
+ return (_jsxs("div", { className: "group/table", children: [
70
+ _jsx("div", { className: "overflow-x-auto", children: _jsx("table", { ref: tableRef, className: cn("min-w-full border-collapse border border-border rounded-md", className), ...rest }) }), _jsxs(Actions, { className: cn("mt-2 flex justify-end", "opacity-0 group-hover/table:opacity-100 transition-opacity",
71
+ // Keep layout stable and avoid accidental clicks when hidden
72
+ "pointer-events-none group-hover/table:pointer-events-auto"), children: [
73
+ _jsx(Action, { onClick: handleCopy, tooltip: isCopied ? "Copied!" : "Copy", children: isCopied ? (_jsx(Check, { className: "size-4" })) : (_jsx(Copy, { className: "size-4" })) }), _jsx(Action, { onClick: handleDownload, tooltip: "Download CSV", children: _jsx(Download, { className: "size-4" }) })
74
+ ] })
75
+ ] }));
76
+ }
11
77
  export const Response = React.memo(React.forwardRef(({ content, isStreaming = false, showEmpty = true, emptyMessage = "", sources = [], messageId, citationIndexOffset = 0, className, ...props }, ref) => {
12
78
  // State to force remount when sources change
13
79
  const [remountKey, setRemountKey] = React.useState(0);
@@ -41,6 +107,11 @@ export const Response = React.memo(React.forwardRef(({ content, isStreaming = fa
41
107
  // running local index + the provided chunk offset.
42
108
  const citationCounter = { current: 0 };
43
109
  return {
110
+ table: (props) => (_jsx(TableWithActions, { ...props })),
111
+ thead: (props) => (_jsx("thead", { className: cn("bg-card border-b border-border", props.className), ...props })),
112
+ tr: (props) => (_jsx("tr", { className: cn("border-b border-border hover:bg-card transition-colors", props.className), ...props })),
113
+ th: (props) => (_jsx("th", { className: cn("px-4 py-2 text-left font-semibold text-foreground border-r border-border last:border-r-0", props.className), ...props })),
114
+ td: (props) => (_jsx("td", { className: cn("px-4 py-2 text-foreground border-r border-border last:border-r-0", props.className), ...props })),
44
115
  // Custom span component that intercepts citation markers
45
116
  // The remark-citations plugin creates spans with class "citation-marker"
46
117
  // and data-citation-id attribute
@@ -44,15 +44,7 @@ export function ToolCall({ toolCall }) {
44
44
  const { resolvedTheme } = useTheme();
45
45
  // Detect TodoWrite tool and subagent
46
46
  const isTodoWrite = toolCall.title === "todo_write";
47
- // A subagent call can be detected by:
48
- // - Live: has port and sessionId (but no stored messages yet)
49
- // - Replay: has stored subagentMessages
50
- const hasLiveSubagent = !!(toolCall.subagentPort && toolCall.subagentSessionId);
51
- const hasStoredSubagent = !!(toolCall.subagentMessages && toolCall.subagentMessages.length > 0);
52
- const isSubagentCall = hasLiveSubagent || hasStoredSubagent;
53
- // Use replay mode if we have stored messages - they should take precedence
54
- // over trying to connect to SSE (which won't work for replayed sessions)
55
- const isReplaySubagent = hasStoredSubagent;
47
+ const isSubagentCall = !!(toolCall.subagentPort && toolCall.subagentSessionId);
56
48
  // Safely access ChatLayout context - will be undefined if not within ChatLayout
57
49
  const layoutContext = React.useContext(ChatLayout.Context);
58
50
  // Click handler: toggle sidepanel for TodoWrite, subagent details for subagents, expand for others
@@ -147,7 +139,7 @@ export function ToolCall({ toolCall }) {
147
139
  if (isPreliminary) {
148
140
  return (_jsx("div", { className: "flex flex-col my-4", children: _jsxs("span", { className: "text-paragraph-sm text-muted-foreground/50", children: ["Invoking ", displayName] }) }));
149
141
  }
150
- return (_jsxs("div", { className: "flex flex-col my-4", children: [_jsxs("button", { type: "button", className: "flex flex-col items-start gap-0.5 cursor-pointer bg-transparent border-none p-0 text-left group w-fit", onClick: handleHeaderClick, "aria-expanded": isTodoWrite ? undefined : isExpanded, children: [_jsxs("div", { className: "flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground", children: [_jsx("div", { className: iconColorClass, title: statusTooltip, children: _jsx(IconComponent, { className: "h-3 w-3" }) }), _jsx("span", { className: "text-paragraph-sm text-muted-foreground", children: displayName }), hasError && _jsx(AlertCircle, { className: "h-3 w-3 text-destructive" }), isTodoWrite ? (_jsx(ChevronRight, { className: "h-3 w-3 text-muted-foreground/70" })) : (_jsx(ChevronDown, { className: `h-3 w-3 text-muted-foreground/70 transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}` }))] }), toolCall.subline && (_jsx("span", { className: "text-paragraph-sm text-muted-foreground/70 pl-4.5", children: toolCall.subline }))] }), !isTodoWrite && isSubagentCall && (_jsx("div", { className: "pl-4.5", children: _jsx(SubAgentDetails, { port: toolCall.subagentPort, sessionId: toolCall.subagentSessionId, parentStatus: toolCall.status, agentName: toolCall.rawInput?.agentName, query: toolCall.rawInput?.query, isExpanded: isSubagentExpanded, onExpandChange: setIsSubagentExpanded, storedMessages: toolCall.subagentMessages, isReplay: isReplaySubagent }) })), !isTodoWrite && !isSubagentCall && isExpanded && (_jsxs("div", { className: "mt-2 text-sm border border-border rounded-lg bg-card overflow-hidden w-full", children: [toolCall.rawInput &&
142
+ return (_jsxs("div", { className: "flex flex-col my-4", children: [_jsxs("button", { type: "button", className: "flex flex-col items-start gap-0.5 cursor-pointer bg-transparent border-none p-0 text-left group w-fit", onClick: handleHeaderClick, "aria-expanded": isTodoWrite ? undefined : isExpanded, children: [_jsxs("div", { className: "flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground", children: [_jsx("div", { className: iconColorClass, title: statusTooltip, children: _jsx(IconComponent, { className: "h-3 w-3" }) }), _jsx("span", { className: "text-paragraph-sm text-muted-foreground", children: displayName }), hasError && _jsx(AlertCircle, { className: "h-3 w-3 text-destructive" }), isTodoWrite ? (_jsx(ChevronRight, { className: "h-3 w-3 text-muted-foreground/70" })) : (_jsx(ChevronDown, { className: `h-3 w-3 text-muted-foreground/70 transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}` }))] }), toolCall.subline && (_jsx("span", { className: "text-paragraph-sm text-muted-foreground/70 pl-4.5", children: toolCall.subline }))] }), !isTodoWrite && isSubagentCall && (_jsx("div", { className: "pl-4.5", children: _jsx(SubAgentDetails, { port: toolCall.subagentPort, sessionId: toolCall.subagentSessionId, parentStatus: toolCall.status, agentName: toolCall.rawInput?.agentName, query: toolCall.rawInput?.query, isExpanded: isSubagentExpanded, onExpandChange: setIsSubagentExpanded }) })), !isTodoWrite && !isSubagentCall && isExpanded && (_jsxs("div", { className: "mt-2 text-sm border border-border rounded-lg bg-card overflow-hidden w-full", children: [toolCall.rawInput &&
151
143
  Object.keys(toolCall.rawInput).length > 0 &&
152
144
  !toolCall.subagentPort && (_jsxs("div", { className: "p-3 border-b border-border", children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Input" }), _jsx("div", { className: "text-[11px] font-mono text-foreground", children: _jsx(JsonView, { value: toolCall.rawInput, collapsed: false, displayDataTypes: false, displayObjectSize: false, enableClipboard: true, style: jsonStyle }) })] })), toolCall.locations && toolCall.locations.length > 0 && (_jsxs("div", { className: "p-3 border-b border-border", children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Files" }), _jsx("ul", { className: "space-y-1", children: toolCall.locations.map((loc) => (_jsxs("li", { className: "font-mono text-[11px] text-foreground bg-muted px-1.5 py-0.5 rounded w-fit", children: [loc.path, loc.line !== null &&
153
145
  loc.line !== undefined &&
@@ -241,7 +241,7 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
241
241
  const displayText = getDisplayText();
242
242
  // For preliminary/selecting states, show simple non-expandable text
243
243
  if (isSelecting && !isGrouped) {
244
- return (_jsx("div", { className: "flex flex-col my-4 rounded-md px-1 -mx-1 w-fit", children: _jsxs("div", { className: "flex items-center gap-1.5", children: [
244
+ return (_jsx("div", { className: "flex flex-col rounded-md px-1 -mx-1 w-fit", children: _jsxs("div", { className: "flex items-center gap-1.5", children: [
245
245
  _jsx("div", { className: "text-text-secondary/70", children: _jsx(IconComponent, { className: "h-3 w-3" }) }), _jsxs("span", { className: "text-paragraph-xs text-text-muted", children: [displayText, singleToolCall?.startedAt && (_jsx(RunningDuration, { startTime: singleToolCall.startedAt, isRunning: !singleToolCall.subagentCompleted }))] })
246
246
  ] }) }));
247
247
  }
@@ -258,14 +258,14 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
258
258
  })();
259
259
  // Check if all tool calls in the group are completed
260
260
  const allCompleted = toolCalls.every((tc) => tc.subagentCompleted);
261
- return (_jsxs("div", { className: "flex flex-col my-4 rounded-md px-1 -mx-1 w-fit", children: [
261
+ return (_jsxs("div", { className: "flex flex-col rounded-md px-1 -mx-1 w-fit", children: [
262
262
  _jsxs("div", { className: "flex items-center gap-1.5", children: [
263
263
  _jsx("div", { className: "text-text-secondary/70", children: _jsx(ListVideo, { className: "h-3 w-3" }) }), _jsx("span", { className: "text-paragraph-sm text-text-secondary/70", children: "Selecting tools" }), selectingStartTime && (_jsx(RunningDuration, { startTime: selectingStartTime, isRunning: !allCompleted })), _jsx("span", { className: "text-[10px] bg-muted px-1.5 py-0.5 rounded text-text-secondary/70", children: toolCalls.length })
264
264
  ] }), _jsx("span", { className: "text-paragraph-sm text-text-secondary/70 pl-4.5", children: displayText })
265
265
  ] }));
266
266
  }
267
267
  // Full display (for single tool call or expanded group, includes minimized state)
268
- return (_jsxs("div", { className: "flex flex-col my-4", children: [
268
+ return (_jsxs("div", { className: "flex flex-col", children: [
269
269
  _jsxs("button", { type: "button", className: "flex flex-col items-start gap-0.5 cursor-pointer bg-transparent border-none p-0 text-left group w-fit rounded-md px-1 -mx-1", onClick: handleHeaderClick, "aria-expanded": isTodoWrite ? undefined : isExpanded, children: [
270
270
  _jsxs("div", { className: "flex items-center gap-1.5", children: [
271
271
  _jsx("div", { className: "text-text-secondary/70 group-hover:text-text-secondary transition-colors", children: _jsx(IconComponent, { className: "h-3 w-3" }) }), _jsxs("span", { className: "text-paragraph-sm text-text-secondary/70 group-hover:text-text-secondary transition-colors", children: [isGrouped && _jsx("span", { className: "mr-1", children: "Parallel operation" }), !isGrouped && displayText] }), isSubagentCall && (_jsx(ContextUsageIndicator, { contextSize: subagentHeaderContextSize, size: 12, className: "text-text-secondary/70 group-hover:text-text-secondary transition-colors" })), !isGrouped &&
@@ -331,7 +331,7 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
331
331
  return null;
332
332
  })()] }), !isTodoWrite && isSubagentCall && singleToolCall && (_jsx("div", { className: "pl-4.5 mt-2", children: _jsx(SubAgentDetails, { parentStatus: singleToolCall.status, agentName: singleToolCall.rawInput?.agentName, query: singleToolCall.rawInput?.query, isExpanded: isSubagentExpanded, onExpandChange: setIsSubagentExpanded, storedMessages: singleToolCall.subagentMessages }) })), !isTodoWrite && isExpanded && (_jsx("div", { className: "mt-1", children: isGrouped ? (
333
333
  // Render individual tool calls in group
334
- _jsx("div", { className: "flex flex-col gap-4 mt-2", children: toolCalls.map((toolCall) => {
334
+ _jsx("div", { className: "flex flex-col gap-2 mt-1", children: toolCalls.map((toolCall) => {
335
335
  const hookNotification = hookNotifications.find((n) => n.toolCallId === toolCall.id);
336
336
  return (_jsx(GroupedToolCallItem, { toolCall: toolCall, ...(hookNotification ? { hookNotification } : {}) }, toolCall.id));
337
337
  }) })) : (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/ui",
3
- "version": "0.1.116",
3
+ "version": "0.1.118",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -49,7 +49,7 @@
49
49
  "@radix-ui/react-slot": "^1.2.4",
50
50
  "@radix-ui/react-tabs": "^1.1.13",
51
51
  "@radix-ui/react-tooltip": "^1.2.8",
52
- "@townco/core": "0.0.94",
52
+ "@townco/core": "0.0.96",
53
53
  "@types/mdast": "^4.0.4",
54
54
  "@uiw/react-json-view": "^2.0.0-alpha.39",
55
55
  "class-variance-authority": "^0.7.1",
@@ -67,7 +67,7 @@
67
67
  "zustand": "^5.0.8"
68
68
  },
69
69
  "devDependencies": {
70
- "@townco/tsconfig": "0.1.113",
70
+ "@townco/tsconfig": "0.1.115",
71
71
  "@types/node": "^24.10.0",
72
72
  "@types/react": "^19.2.2",
73
73
  "@types/unist": "^3.0.3",
@@ -1,23 +0,0 @@
1
- export interface SubagentStreamProps {
2
- /** Sub-agent HTTP port */
3
- port: number;
4
- /** Sub-agent session ID */
5
- sessionId: string;
6
- /** Optional host (defaults to localhost) */
7
- host?: string;
8
- /** Parent tool call status - use this to determine if sub-agent is running */
9
- parentStatus?: "pending" | "in_progress" | "completed" | "failed";
10
- /** Sub-agent name (for display) */
11
- agentName?: string | undefined;
12
- /** Query sent to the sub-agent */
13
- query?: string | undefined;
14
- }
15
- /**
16
- * SubagentStream component - displays streaming content from a sub-agent.
17
- *
18
- * This component:
19
- * - Connects directly to the sub-agent's SSE endpoint
20
- * - Displays streaming text and tool calls
21
- * - Renders in a collapsible section (collapsed by default)
22
- */
23
- export declare function SubagentStream({ port, sessionId, host, parentStatus, agentName, query, }: SubagentStreamProps): import("react/jsx-runtime").JSX.Element;
@@ -1,98 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ChevronDown, CircleDot, Loader2 } from "lucide-react";
3
- import React, { useCallback, useEffect, useRef, useState } from "react";
4
- import { useSubagentStream } from "../../core/hooks/use-subagent-stream.js";
5
- const SCROLL_THRESHOLD = 50; // px from bottom to consider "at bottom"
6
- /**
7
- * SubagentStream component - displays streaming content from a sub-agent.
8
- *
9
- * This component:
10
- * - Connects directly to the sub-agent's SSE endpoint
11
- * - Displays streaming text and tool calls
12
- * - Renders in a collapsible section (collapsed by default)
13
- */
14
- export function SubagentStream({ port, sessionId, host, parentStatus, agentName, query, }) {
15
- const [isExpanded, setIsExpanded] = useState(false); // Start collapsed for parallel ops
16
- const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
17
- const [isNearBottom, setIsNearBottom] = useState(true);
18
- const thinkingContainerRef = useRef(null);
19
- const { messages, isStreaming: hookIsStreaming, error } = useSubagentStream({
20
- port,
21
- sessionId,
22
- ...(host !== undefined ? { host } : {}),
23
- });
24
- // Use parent status as primary indicator, fall back to hook's streaming state
25
- // Parent is "in_progress" means sub-agent is definitely still running
26
- const isRunning = parentStatus === "in_progress" || parentStatus === "pending" || hookIsStreaming;
27
- // Get the current/latest message
28
- const currentMessage = messages[messages.length - 1];
29
- const hasContent = currentMessage &&
30
- (currentMessage.content ||
31
- (currentMessage.toolCalls && currentMessage.toolCalls.length > 0));
32
- // Auto-collapse Thinking when completed (so Output is the primary view)
33
- const prevIsRunningRef = useRef(isRunning);
34
- useEffect(() => {
35
- if (prevIsRunningRef.current && !isRunning) {
36
- // Just completed - collapse thinking to show output
37
- setIsThinkingExpanded(false);
38
- }
39
- prevIsRunningRef.current = isRunning;
40
- }, [isRunning]);
41
- // Check if user is near bottom of scroll area
42
- const checkScrollPosition = useCallback(() => {
43
- const container = thinkingContainerRef.current;
44
- if (!container)
45
- return;
46
- const { scrollTop, scrollHeight, clientHeight } = container;
47
- const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
48
- setIsNearBottom(distanceFromBottom < SCROLL_THRESHOLD);
49
- }, []);
50
- // Scroll to bottom
51
- const scrollToBottom = useCallback(() => {
52
- const container = thinkingContainerRef.current;
53
- if (!container)
54
- return;
55
- container.scrollTop = container.scrollHeight;
56
- }, []);
57
- // Auto-scroll when content changes and user is near bottom
58
- useEffect(() => {
59
- if (isNearBottom && (isRunning || hasContent)) {
60
- scrollToBottom();
61
- }
62
- }, [currentMessage?.content, currentMessage?.toolCalls, isNearBottom, isRunning, hasContent, scrollToBottom]);
63
- // Set up scroll listener
64
- useEffect(() => {
65
- const container = thinkingContainerRef.current;
66
- if (!container)
67
- return;
68
- const handleScroll = () => checkScrollPosition();
69
- container.addEventListener("scroll", handleScroll, { passive: true });
70
- checkScrollPosition(); // Check initial position
71
- return () => container.removeEventListener("scroll", handleScroll);
72
- }, [checkScrollPosition, isThinkingExpanded, isExpanded]);
73
- // Get last line of streaming content for preview
74
- const lastLine = currentMessage?.content
75
- ? currentMessage.content.split("\n").filter(Boolean).pop() || ""
76
- : "";
77
- const previewText = lastLine.length > 100 ? `${lastLine.slice(0, 100)}...` : lastLine;
78
- return (_jsxs("div", { children: [!isExpanded && (_jsx("button", { type: "button", onClick: () => setIsExpanded(true), className: "w-full max-w-md text-left cursor-pointer bg-transparent border-none p-0", children: previewText ? (_jsx("p", { className: `text-paragraph-sm text-muted-foreground truncate ${isRunning ? "animate-pulse" : ""}`, children: previewText })) : isRunning ? (_jsx("p", { className: "text-paragraph-sm text-muted-foreground/50 italic animate-pulse", children: "Waiting for response..." })) : null })), isExpanded && (_jsxs("div", { className: "space-y-3", children: [(agentName || query) && (_jsxs("div", { children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Input" }), _jsxs("div", { className: "text-[11px] font-mono space-y-1", children: [agentName && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "agentName: " }), _jsx("span", { className: "text-foreground", children: agentName })] })), query && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "query: " }), _jsx("span", { className: "text-foreground", children: query })] }))] })] })), _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setIsThinkingExpanded(!isThinkingExpanded), className: "flex items-center gap-2 cursor-pointer bg-transparent border-none p-0 text-left group", children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider font-sans", children: "Thinking" }), _jsx(ChevronDown, { className: `h-3 w-3 text-muted-foreground/70 transition-transform duration-200 ${isThinkingExpanded ? "rotate-180" : ""}` })] }), isThinkingExpanded && (_jsxs("div", { ref: thinkingContainerRef, className: "mt-2 rounded-md overflow-hidden bg-muted/30 border border-border/50 max-h-[200px] overflow-y-auto", children: [error && (_jsxs("div", { className: "px-2 py-2 text-[11px] text-destructive", children: ["Error: ", error] })), !error && !hasContent && isRunning && (_jsx("div", { className: "px-2 py-2 text-[11px] text-muted-foreground", children: "Waiting for sub-agent response..." })), currentMessage && (_jsxs("div", { className: "px-2 py-2 space-y-2", children: [currentMessage.toolCalls &&
79
- currentMessage.toolCalls.length > 0 && (_jsx("div", { className: "space-y-1", children: currentMessage.toolCalls.map((tc) => (_jsx(SubagentToolCallItem, { toolCall: tc }, tc.id))) })), currentMessage.content && (_jsxs("div", { className: "text-[11px] text-foreground whitespace-pre-wrap font-mono", children: [currentMessage.content, currentMessage.isStreaming && (_jsx("span", { className: "inline-block w-1.5 h-3 bg-primary/70 ml-0.5 animate-pulse" }))] }))] }))] }))] }), !isRunning && currentMessage?.content && (_jsxs("div", { children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Output" }), _jsx("div", { className: "text-[11px] text-foreground whitespace-pre-wrap font-mono max-h-[200px] overflow-y-auto rounded-md bg-muted/30 border border-border/50 px-2 py-2", children: currentMessage.content })] }))] }))] }));
80
- }
81
- /**
82
- * Simple tool call display for sub-agent tool calls
83
- */
84
- function SubagentToolCallItem({ toolCall }) {
85
- const statusIcon = {
86
- pending: "...",
87
- in_progress: "",
88
- completed: "",
89
- failed: "",
90
- }[toolCall.status];
91
- const statusColor = {
92
- pending: "text-muted-foreground",
93
- in_progress: "text-blue-500",
94
- completed: "text-green-500",
95
- failed: "text-destructive",
96
- }[toolCall.status];
97
- return (_jsxs("div", { className: "flex items-center gap-2 text-[10px] text-muted-foreground", children: [_jsx("span", { className: statusColor, children: statusIcon }), _jsx("span", { className: "font-medium", children: toolCall.prettyName || toolCall.title }), toolCall.status === "in_progress" && (_jsx(Loader2, { className: "h-2.5 w-2.5 animate-spin" }))] }));
98
- }