@townco/ui 0.1.82 → 0.1.93

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.
Files changed (63) hide show
  1. package/dist/core/hooks/use-chat-input.js +13 -6
  2. package/dist/core/hooks/use-chat-messages.d.ts +17 -0
  3. package/dist/core/hooks/use-chat-messages.js +294 -10
  4. package/dist/core/schemas/chat.d.ts +20 -0
  5. package/dist/core/schemas/chat.js +4 -0
  6. package/dist/core/schemas/index.d.ts +1 -0
  7. package/dist/core/schemas/index.js +1 -0
  8. package/dist/core/schemas/source.d.ts +22 -0
  9. package/dist/core/schemas/source.js +45 -0
  10. package/dist/core/store/chat-store.d.ts +4 -0
  11. package/dist/core/store/chat-store.js +54 -0
  12. package/dist/gui/components/Actions.d.ts +15 -0
  13. package/dist/gui/components/Actions.js +22 -0
  14. package/dist/gui/components/ChatInput.d.ts +9 -1
  15. package/dist/gui/components/ChatInput.js +24 -6
  16. package/dist/gui/components/ChatInputCommandMenu.d.ts +1 -0
  17. package/dist/gui/components/ChatInputCommandMenu.js +22 -5
  18. package/dist/gui/components/ChatInputParameters.d.ts +13 -0
  19. package/dist/gui/components/ChatInputParameters.js +67 -0
  20. package/dist/gui/components/ChatLayout.d.ts +2 -0
  21. package/dist/gui/components/ChatLayout.js +183 -61
  22. package/dist/gui/components/ChatPanelTabContent.d.ts +7 -0
  23. package/dist/gui/components/ChatPanelTabContent.js +17 -7
  24. package/dist/gui/components/ChatView.js +105 -15
  25. package/dist/gui/components/CitationChip.d.ts +15 -0
  26. package/dist/gui/components/CitationChip.js +72 -0
  27. package/dist/gui/components/EditableUserMessage.d.ts +18 -0
  28. package/dist/gui/components/EditableUserMessage.js +109 -0
  29. package/dist/gui/components/MessageActions.d.ts +16 -0
  30. package/dist/gui/components/MessageActions.js +97 -0
  31. package/dist/gui/components/MessageContent.js +22 -7
  32. package/dist/gui/components/Response.d.ts +3 -0
  33. package/dist/gui/components/Response.js +30 -3
  34. package/dist/gui/components/Sidebar.js +1 -1
  35. package/dist/gui/components/TodoSubline.js +1 -1
  36. package/dist/gui/components/WorkProgress.js +7 -0
  37. package/dist/gui/components/index.d.ts +6 -1
  38. package/dist/gui/components/index.js +6 -1
  39. package/dist/gui/hooks/index.d.ts +1 -0
  40. package/dist/gui/hooks/index.js +1 -0
  41. package/dist/gui/hooks/use-favicon.d.ts +6 -0
  42. package/dist/gui/hooks/use-favicon.js +47 -0
  43. package/dist/gui/hooks/use-scroll-to-bottom.d.ts +14 -0
  44. package/dist/gui/hooks/use-scroll-to-bottom.js +317 -1
  45. package/dist/gui/index.d.ts +1 -1
  46. package/dist/gui/index.js +1 -1
  47. package/dist/gui/lib/motion.js +6 -6
  48. package/dist/gui/lib/remark-citations.d.ts +28 -0
  49. package/dist/gui/lib/remark-citations.js +70 -0
  50. package/dist/sdk/client/acp-client.d.ts +38 -1
  51. package/dist/sdk/client/acp-client.js +67 -3
  52. package/dist/sdk/schemas/message.d.ts +40 -0
  53. package/dist/sdk/schemas/message.js +20 -0
  54. package/dist/sdk/transports/http.d.ts +24 -1
  55. package/dist/sdk/transports/http.js +189 -1
  56. package/dist/sdk/transports/stdio.d.ts +1 -0
  57. package/dist/sdk/transports/stdio.js +39 -0
  58. package/dist/sdk/transports/types.d.ts +46 -1
  59. package/dist/sdk/transports/websocket.d.ts +1 -0
  60. package/dist/sdk/transports/websocket.js +4 -0
  61. package/dist/tui/components/ChatView.js +3 -4
  62. package/package.json +5 -3
  63. package/src/styles/global.css +71 -0
@@ -1,11 +1,13 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createLogger } from "@townco/core";
3
- import { ArrowUp, Bug, PanelRight, Settings, X } from "lucide-react";
3
+ import { ArrowUp, Bug, Moon, PanelRight, Settings, Sun, X } from "lucide-react";
4
+ import * as React from "react";
4
5
  import { useEffect, useState } from "react";
5
6
  import { useChatMessages, useChatSession, useToolCalls, } from "../../core/hooks/index.js";
6
7
  import { selectTodosForCurrentSession, useChatStore, } from "../../core/store/chat-store.js";
8
+ import { useDocumentTitle } from "../hooks/use-favicon.js";
7
9
  import { cn } from "../lib/utils.js";
8
- import { AppSidebar, ChatEmptyState, ChatHeader, ChatInputActions, ChatInputAttachment, ChatInputCommandMenu, ChatInputField, ChatInputRoot, ChatInputSubmit, ChatInputToolbar, ChatInputVoiceInput, ChatLayout, ContextUsageButton, FilesTabContent, IconButton, Message, MessageContent, PanelTabsHeader, SettingsTabContent, SidebarInset, SidebarProvider, SidebarToggle, SourcesTabContent, Tabs, TabsContent, TodoTabContent, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "./index.js";
10
+ import { AppSidebar, ChatEmptyState, ChatHeader, ChatInputActions, ChatInputAttachment, ChatInputCommandMenu, ChatInputField, ChatInputParameters, ChatInputRoot, ChatInputStop, ChatInputSubmit, ChatInputToolbar, ChatInputVoiceInput, ChatLayout, ContextUsageButton, EditableUserMessage, FilesTabContent, IconButton, Message, MessageActions, MessageContent, PanelTabsHeader, SettingsTabContent, SidebarInset, SidebarProvider, SidebarToggle, SourcesTabContent, Tabs, TabsContent, TodoTabContent, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, useTheme, } from "./index.js";
9
11
  const logger = createLogger("gui");
10
12
  // Helper component to provide openFiles callback
11
13
  function OpenFilesButton({ children, }) {
@@ -33,7 +35,7 @@ function OpenFilesButton({ children, }) {
33
35
  // Note: Keyboard shortcut (Cmd+B / Ctrl+B) for toggling the right panel
34
36
  // is now handled internally by ChatLayout.Root
35
37
  // Chat input with attachment handling
36
- function ChatInputWithAttachments({ client, startSession, placeholder, commandMenuItems, }) {
38
+ function ChatInputWithAttachments({ client, startSession, placeholder, commandMenuItems, onCancel, promptParameters, }) {
37
39
  const attachedFiles = useChatStore((state) => state.input.attachedFiles);
38
40
  const addFileAttachment = useChatStore((state) => state.addFileAttachment);
39
41
  const removeFileAttachment = useChatStore((state) => state.removeFileAttachment);
@@ -42,23 +44,22 @@ function ChatInputWithAttachments({ client, startSession, placeholder, commandMe
42
44
  addFileAttachment(file);
43
45
  }
44
46
  };
45
- return (_jsxs(ChatInputRoot, { client: client, startSession: startSession, children: [
47
+ return (_jsxs(ChatInputRoot, { client: client, startSession: startSession, onCancel: onCancel, children: [
46
48
  _jsx(ChatInputCommandMenu, { commands: commandMenuItems }), attachedFiles.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 p-3 border-b border-border", children: attachedFiles.map((file, index) => (_jsxs("div", { className: "relative group rounded-md overflow-hidden border border-border", children: [
47
49
  _jsx("img", { src: `data:${file.mimeType};base64,${file.data}`, alt: file.name, className: "h-20 w-20 object-cover" }), _jsx("button", { type: "button", onClick: () => removeFileAttachment(index), className: "absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-background opacity-0 group-hover:opacity-100 transition-opacity", children: _jsx(X, { className: "size-3" }) })
48
50
  ] }, `attached-${file.name}-${file.data.slice(0, 20)}`))) })), _jsx(ChatInputField, { placeholder: placeholder, autoFocus: true, onFilesDropped: handleFilesSelected }), _jsxs(ChatInputToolbar, { children: [
49
- _jsxs("div", { className: "flex items-baseline gap-1", children: [
50
- _jsx(ChatInputActions, {}), _jsx(ChatInputAttachment, { onFilesSelected: handleFilesSelected })
51
- ] }), _jsxs("div", { className: "flex items-center gap-1", children: [
52
- _jsx(ChatInputVoiceInput, {}), _jsx(ChatInputSubmit, { children: _jsx(ArrowUp, { className: "size-4" }) })
51
+ _jsxs("div", { className: "flex items-center gap-1", children: [
52
+ _jsx(ChatInputActions, {}), _jsx(ChatInputAttachment, { onFilesSelected: handleFilesSelected }), promptParameters && promptParameters.length > 0 && (_jsx(ChatInputParameters, { parameters: promptParameters }))] }), _jsxs("div", { className: "flex items-center gap-1", children: [
53
+ _jsx(ChatInputVoiceInput, {}), _jsx(ChatInputStop, {}), _jsx(ChatInputSubmit, { children: _jsx(ArrowUp, { className: "size-4" }) })
53
54
  ] })
54
55
  ] })
55
56
  ] }));
56
57
  }
57
58
  // Controlled Tabs component for the aside panel
58
- function AsideTabs({ todos, tools, mcps, subagents, }) {
59
+ function AsideTabs({ todos, sources, tools, mcps, subagents, }) {
59
60
  const { activeTab, setActiveTab } = ChatLayout.useChatLayoutContext();
60
61
  return (_jsxs(Tabs, { value: activeTab, onValueChange: (value) => setActiveTab(value), className: "flex flex-col h-full", children: [
61
- _jsx("div", { className: cn("border-b border-border bg-card", "px-4 py-2 h-16", "flex items-center"), children: _jsx(PanelTabsHeader, { showIcons: true, visibleTabs: ["todo", "files", "sources", "settings"], variant: "compact" }) }), _jsx(TabsContent, { value: "todo", className: "flex-1 p-4 mt-0", children: _jsx(TodoTabContent, { todos: todos }) }), _jsx(TabsContent, { value: "files", className: "flex-1 p-4 mt-0", children: _jsx(FilesTabContent, {}) }), _jsx(TabsContent, { value: "sources", className: "flex-1 p-4 mt-0", children: _jsx(SourcesTabContent, {}) }), _jsx(TabsContent, { value: "settings", className: "flex-1 p-4 mt-0 overflow-y-auto", children: _jsx(SettingsTabContent, { tools: tools ?? [], mcps: mcps ?? [], subagents: subagents ?? [] }) })
62
+ _jsx("div", { className: cn("border-b border-border bg-card", "px-4 py-2 h-16", "flex items-center", "sticky top-0 z-10 shrink-0"), children: _jsx(PanelTabsHeader, { showIcons: true, visibleTabs: ["todo", "files", "sources", "settings"], variant: "compact" }) }), _jsx(TabsContent, { value: "todo", className: "flex-1 p-4 mt-0 overflow-y-auto", children: _jsx(TodoTabContent, { todos: todos }) }), _jsx(TabsContent, { value: "files", className: "flex-1 p-4 mt-0 overflow-y-auto", children: _jsx(FilesTabContent, {}) }), _jsx(TabsContent, { value: "sources", className: "flex-1 p-4 mt-0 overflow-y-auto", children: _jsx(SourcesTabContent, { sources: sources }) }), _jsx(TabsContent, { value: "settings", className: "flex-1 p-4 mt-0 overflow-y-auto", children: _jsx(SettingsTabContent, { tools: tools ?? [], mcps: mcps ?? [], subagents: subagents ?? [] }) })
62
63
  ] }));
63
64
  }
64
65
  // Header component that uses ChatLayout context (must be inside ChatLayout.Root)
@@ -81,7 +82,7 @@ function AppChatHeader({ agentName, showHeader, sessionId, debuggerUrl, }) {
81
82
  export function ChatView({ client, initialSessionId, error: initError, debuggerUrl, }) {
82
83
  // Use shared hooks from @townco/ui/core - MUST be called before any early returns
83
84
  const { connectionStatus, connect, sessionId, startSession } = useChatSession(client, initialSessionId);
84
- const { messages, sendMessage } = useChatMessages(client, startSession);
85
+ const { messages, sendMessage, editAndResend, cancel } = useChatMessages(client, startSession);
85
86
  useToolCalls(client); // Still need to subscribe to tool call events
86
87
  const error = useChatStore((state) => state.error);
87
88
  const [agentName, setAgentName] = useState(undefined);
@@ -90,10 +91,33 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
90
91
  const [agentTools, setAgentTools] = useState([]);
91
92
  const [agentMcps, setAgentMcps] = useState([]);
92
93
  const [agentSubagents, setAgentSubagents] = useState([]);
94
+ const [agentPromptParameters, setAgentPromptParameters] = useState([]);
93
95
  const [placeholder, setPlaceholder] = useState("Type a message or / for commands...");
94
96
  const [hideTopBar, setHideTopBar] = useState(false);
95
97
  const todos = useChatStore(selectTodosForCurrentSession);
96
98
  const _latestContextSize = useChatStore((state) => state.latestContextSize);
99
+ const { resolvedTheme, setTheme } = useTheme();
100
+ // Collect all sources from all messages for the sources panel
101
+ const allSources = React.useMemo(() => {
102
+ const sources = [];
103
+ for (const message of messages) {
104
+ if (message.sources) {
105
+ for (const source of message.sources) {
106
+ sources.push({
107
+ id: source.id,
108
+ title: source.title,
109
+ url: source.url,
110
+ snippet: source.snippet || "",
111
+ sourceName: source.sourceName || "",
112
+ favicon: source.favicon || "",
113
+ });
114
+ }
115
+ }
116
+ }
117
+ return sources;
118
+ }, [messages]);
119
+ // Update document title with agent name and animated dots when streaming
120
+ useDocumentTitle(agentName);
97
121
  // Log connection status changes
98
122
  useEffect(() => {
99
123
  logger.debug("Connection status changed", { status: connectionStatus });
@@ -145,6 +169,12 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
145
169
  subagents: agentInfo.subagents,
146
170
  });
147
171
  }
172
+ if (agentInfo.promptParameters && agentInfo.promptParameters.length > 0) {
173
+ setAgentPromptParameters(agentInfo.promptParameters);
174
+ logger.info("Agent prompt parameters loaded", {
175
+ promptParameters: agentInfo.promptParameters,
176
+ });
177
+ }
148
178
  if (agentInfo.uiConfig?.hideTopBar) {
149
179
  setHideTopBar(true);
150
180
  }
@@ -201,6 +231,19 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
201
231
  ];
202
232
  // Command menu items for chat input
203
233
  const commandMenuItems = [
234
+ {
235
+ id: "dark-mode",
236
+ label: resolvedTheme === "dark" ? "Light Mode" : "Dark Mode",
237
+ description: `Switch to ${resolvedTheme === "dark" ? "light" : "dark"} theme`,
238
+ icon: resolvedTheme === "dark" ? (_jsx(Sun, { className: "h-4 w-4" })) : (_jsx(Moon, { className: "h-4 w-4" })),
239
+ category: "action",
240
+ onSelect: () => {
241
+ setTheme(resolvedTheme === "dark" ? "light" : "dark");
242
+ logger.info("User toggled theme", {
243
+ newTheme: resolvedTheme === "dark" ? "light" : "dark",
244
+ });
245
+ },
246
+ },
204
247
  {
205
248
  id: "settings",
206
249
  label: "Open Settings",
@@ -222,7 +265,13 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
222
265
  _jsx(ChatLayout.Messages, { children: messages.length === 0 ? (
223
266
  // Only render empty state once agent info is loaded
224
267
  agentName !== undefined ? (_jsx(OpenFilesButton, { children: ({ openFiles, openSettings }) => (_jsx("div", { className: "flex flex-1 items-center px-4", children: _jsx(ChatEmptyState, { title: agentName, description: agentDescription ?? "", suggestedPrompts: suggestedPrompts ?? [], onPromptClick: (prompt) => {
225
- sendMessage(prompt);
268
+ // Get current prompt parameters from store
269
+ const currentParams = useChatStore.getState().input
270
+ .selectedPromptParameters;
271
+ sendMessage(prompt, undefined, currentParams &&
272
+ Object.keys(currentParams).length > 0
273
+ ? currentParams
274
+ : undefined);
226
275
  setPlaceholder("Type a message or / for commands...");
227
276
  logger.info("Prompt clicked", { prompt });
228
277
  }, onPromptHover: handlePromptHover, onPromptLeave: handlePromptLeave, onOpenFiles: openFiles, onOpenSettings: openSettings, toolsAndMcpsCount: agentTools.length +
@@ -251,10 +300,51 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
251
300
  ? "mt-2"
252
301
  : "mt-6";
253
302
  }
254
- return (_jsx(Message, { message: message, className: spacingClass, isLastMessage: index === messages.length - 1, children: _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }) }, message.id));
255
- }) })) }), _jsx(ChatLayout.Footer, { children: _jsx(ChatInputWithAttachments, { client: client, startSession: startSession, placeholder: placeholder, commandMenuItems: commandMenuItems }) })
303
+ // Check if any message is streaming
304
+ const anyMessageStreaming = messages.some((m) => m.isStreaming);
305
+ // Calculate which user message number this is (1-indexed for display, 0-indexed for API)
306
+ // Count user messages up to and including this index
307
+ const userMessageIndex = message.role === "user"
308
+ ? messages
309
+ .slice(0, index + 1)
310
+ .filter((m) => m.role === "user").length - 1
311
+ : -1;
312
+ return (_jsx(Message, { message: message, className: cn(spacingClass, "group"), 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 })) : (_jsxs(_Fragment, { children: [
313
+ _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }), _jsx(MessageActions, { message: message, isStreaming: message.isStreaming, onSendMessage: sendMessage, isLastAssistantMessage: index ===
314
+ messages.findLastIndex((m) => m.role === "assistant"), onRedo: () => {
315
+ // Find the user message that preceded this assistant message
316
+ let precedingUserMessage = null;
317
+ let precedingUserMessageIndex = -1;
318
+ const _userCount = 0;
319
+ for (let i = index - 1; i >= 0; i--) {
320
+ if (messages[i]?.role === "user") {
321
+ precedingUserMessage = messages[i];
322
+ // Count how many user messages came before this one
323
+ precedingUserMessageIndex =
324
+ messages
325
+ .slice(0, i + 1)
326
+ .filter((m) => m.role === "user")
327
+ .length - 1;
328
+ break;
329
+ }
330
+ }
331
+ if (precedingUserMessage?.content &&
332
+ precedingUserMessageIndex >= 0) {
333
+ // Convert images back to attachments format
334
+ const attachments = precedingUserMessage.images?.map((img, idx) => ({
335
+ name: `image-${idx}`,
336
+ path: "",
337
+ size: 0,
338
+ mimeType: img.mimeType,
339
+ data: img.data,
340
+ }));
341
+ editAndResend(precedingUserMessageIndex, precedingUserMessage.content, attachments);
342
+ }
343
+ } })
344
+ ] })) }) }, message.id));
345
+ }) })) }), _jsx(ChatLayout.Footer, { children: _jsx(ChatInputWithAttachments, { client: client, startSession: startSession, placeholder: placeholder, commandMenuItems: commandMenuItems, onCancel: cancel, promptParameters: agentPromptParameters }) })
256
346
  ] })
257
- ] }), _jsx(ChatLayout.Aside, { breakpoint: "md", children: _jsx(AsideTabs, { todos: todos, tools: agentTools, mcps: agentMcps, subagents: agentSubagents }) })
347
+ ] }), _jsx(ChatLayout.Aside, { breakpoint: "md", children: _jsx(AsideTabs, { todos: todos, sources: allSources, tools: agentTools, mcps: agentMcps, subagents: agentSubagents }) })
258
348
  ] }) })
259
349
  ] }));
260
350
  }
@@ -0,0 +1,15 @@
1
+ import * as React from "react";
2
+ import type { Source } from "../../core/schemas/source.js";
3
+ export interface CitationChipProps {
4
+ /** The source ID (e.g., "1", "2") */
5
+ sourceId: string;
6
+ /** Available sources to look up the citation */
7
+ sources: Source[];
8
+ /** Optional className */
9
+ className?: string;
10
+ }
11
+ /**
12
+ * CitationChip displays an inline citation pill that shows source details on hover
13
+ * and scrolls to the Sources panel on click.
14
+ */
15
+ export declare const CitationChip: React.ForwardRefExoticComponent<CitationChipProps & React.RefAttributes<HTMLSpanElement>>;
@@ -0,0 +1,72 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ExternalLink } from "lucide-react";
4
+ import * as React from "react";
5
+ import { cn } from "../lib/utils.js";
6
+ import * as ChatLayout from "./ChatLayout.js";
7
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "./Tooltip.js";
8
+ /**
9
+ * CitationChip displays an inline citation pill that shows source details on hover
10
+ * and scrolls to the Sources panel on click.
11
+ */
12
+ export const CitationChip = React.forwardRef(({ sourceId, sources, className }, ref) => {
13
+ const source = sources.find((s) => s.id === sourceId);
14
+ // Try to get ChatLayout context for panel control
15
+ let chatLayoutContext = null;
16
+ try {
17
+ // biome-ignore lint/correctness/useHookAtTopLevel: intentionally catching context absence
18
+ chatLayoutContext = ChatLayout.useChatLayoutContext();
19
+ }
20
+ catch {
21
+ // Not inside ChatLayout.Root - that's okay, we just won't have panel control
22
+ }
23
+ // Handle click - open Sources panel and scroll to this source
24
+ const handleClick = React.useCallback((e) => {
25
+ e.preventDefault();
26
+ e.stopPropagation();
27
+ if (chatLayoutContext) {
28
+ // Open the Sources panel
29
+ chatLayoutContext.setPanelSize("small");
30
+ chatLayoutContext.setActiveTab("sources");
31
+ // TODO: Add scroll-to-source functionality once we wire up highlighting
32
+ }
33
+ // Also open the source URL in a new tab if available
34
+ if (source?.url) {
35
+ window.open(source.url, "_blank", "noopener,noreferrer");
36
+ }
37
+ }, [chatLayoutContext, source?.url]);
38
+ // If source not found, just show the number
39
+ if (!source) {
40
+ return (_jsxs("span", { ref: ref, className: cn("inline-flex items-center px-1.5 py-0.5 text-xs font-medium", "bg-muted text-muted-foreground rounded-md", "cursor-default", className), children: ["[", sourceId, "]"] }));
41
+ }
42
+ const domain = source.sourceName || getDomain(source.url);
43
+ return (_jsx(TooltipProvider, { delayDuration: 200, children: _jsxs(Tooltip, { children: [
44
+ _jsx(TooltipTrigger, { asChild: true, children: _jsxs("span", { ref: ref, role: "button", tabIndex: 0, onClick: handleClick, onKeyDown: (e) => {
45
+ if (e.key === "Enter" || e.key === " ") {
46
+ handleClick(e);
47
+ }
48
+ }, className: cn("inline-flex items-center gap-1 px-1.5 py-0.5", "text-xs font-medium rounded-md", "bg-accent/50 text-accent-foreground", "hover:bg-accent cursor-pointer transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className), children: [source.favicon && (_jsx("img", { src: source.favicon, alt: "", className: "size-3 rounded-sm", onError: (e) => {
49
+ // Hide broken favicon images
50
+ e.target.style.display = "none";
51
+ } })), _jsx("span", { children: domain })
52
+ ] }) }), _jsx(TooltipContent, { side: "top", align: "start", className: "max-w-xs p-0 overflow-hidden", children: _jsxs("div", { className: "p-3", children: [
53
+ _jsxs("div", { className: "flex items-center gap-2 mb-2", children: [source.favicon && (_jsx("img", { src: source.favicon, alt: "", className: "size-4 rounded-sm" })), _jsx("span", { className: "font-medium text-sm", children: domain })
54
+ ] }), _jsx("h4", { className: "font-medium text-sm text-foreground line-clamp-2 mb-1", children: source.title }), source.snippet && (_jsx("p", { className: "text-xs text-muted-foreground line-clamp-3 mb-2", children: source.snippet })), _jsxs("div", { className: "flex items-center gap-1 text-xs text-muted-foreground", children: [
55
+ _jsx(ExternalLink, { className: "size-3" }), _jsx("span", { children: "Click to open" })
56
+ ] })
57
+ ] }) })
58
+ ] }) }));
59
+ });
60
+ CitationChip.displayName = "CitationChip";
61
+ /**
62
+ * Helper to extract domain from URL
63
+ */
64
+ function getDomain(url) {
65
+ try {
66
+ const hostname = new URL(url).hostname;
67
+ return hostname.replace(/^www\./, "");
68
+ }
69
+ catch {
70
+ return url;
71
+ }
72
+ }
@@ -0,0 +1,18 @@
1
+ import type { DisplayMessage } from "./MessageList.js";
2
+ export interface EditableUserMessageProps {
3
+ message: DisplayMessage;
4
+ messageIndex: number;
5
+ isStreaming: boolean;
6
+ onEditAndResend: (messageIndex: number, newContent: string, attachments?: Array<{
7
+ name: string;
8
+ path: string;
9
+ size: number;
10
+ mimeType: string;
11
+ data: string;
12
+ }>) => void;
13
+ /** Whether to make the message content sticky */
14
+ sticky?: boolean;
15
+ }
16
+ declare function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAndResend, sticky }: EditableUserMessageProps): import("react/jsx-runtime").JSX.Element;
17
+ export declare const EditableUserMessage: import("react").MemoExoticComponent<typeof PureEditableUserMessage>;
18
+ export {};
@@ -0,0 +1,109 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Check, Copy, Pencil } from "lucide-react";
4
+ import { memo, useCallback, useRef, useState } from "react";
5
+ import { toast } from "sonner";
6
+ import { Action, Actions } from "./Actions.js";
7
+ import { Button } from "./Button.js";
8
+ import { MessageContent } from "./MessageContent.js";
9
+ import { Textarea } from "./Textarea.js";
10
+ function PureEditableUserMessage({ message, messageIndex, isStreaming, onEditAndResend, sticky = false, }) {
11
+ const [isEditing, setIsEditing] = useState(false);
12
+ const [editedContent, setEditedContent] = useState(message.content || "");
13
+ const [isCopied, setIsCopied] = useState(false);
14
+ const containerRef = useRef(null);
15
+ // Handle clicking the sticky message to scroll back to its position
16
+ const handleStickyClick = useCallback(() => {
17
+ if (!sticky || !containerRef.current)
18
+ return;
19
+ // Find the scroll container (parent with overflow-y-auto)
20
+ let scrollContainer = containerRef.current.parentElement;
21
+ while (scrollContainer &&
22
+ !scrollContainer.classList.contains("overflow-y-auto")) {
23
+ scrollContainer = scrollContainer.parentElement;
24
+ }
25
+ if (scrollContainer) {
26
+ // Get the element's position relative to the scroll container
27
+ const _elementRect = containerRef.current.getBoundingClientRect();
28
+ const _containerRect = scrollContainer.getBoundingClientRect();
29
+ const elementTop = containerRef.current.offsetTop;
30
+ // Scroll so the element is at the top of the container
31
+ scrollContainer.scrollTo({
32
+ top: elementTop - 16, // Small offset for padding
33
+ behavior: "smooth",
34
+ });
35
+ }
36
+ }, [sticky]);
37
+ const handleCopy = useCallback(async () => {
38
+ if (!message.content) {
39
+ toast.error("There's no text to copy!");
40
+ return;
41
+ }
42
+ try {
43
+ await navigator.clipboard.writeText(message.content);
44
+ setIsCopied(true);
45
+ toast.success("Copied to clipboard!");
46
+ setTimeout(() => {
47
+ setIsCopied(false);
48
+ }, 2000);
49
+ }
50
+ catch {
51
+ toast.error("Failed to copy to clipboard");
52
+ }
53
+ }, [message.content]);
54
+ const handleStartEdit = useCallback(() => {
55
+ setEditedContent(message.content || "");
56
+ setIsEditing(true);
57
+ }, [message.content]);
58
+ const handleCancelEdit = useCallback(() => {
59
+ setIsEditing(false);
60
+ setEditedContent(message.content || "");
61
+ }, [message.content]);
62
+ const handleSaveAndResend = useCallback(() => {
63
+ if (!editedContent.trim())
64
+ return;
65
+ // Convert images back to attachments format if the message had images
66
+ const attachments = message.images?.map((img, idx) => ({
67
+ name: `image-${idx}`,
68
+ path: "",
69
+ size: 0,
70
+ mimeType: img.mimeType,
71
+ data: img.data,
72
+ }));
73
+ onEditAndResend(messageIndex, editedContent.trim(), attachments);
74
+ setIsEditing(false);
75
+ }, [messageIndex, editedContent, message.images, onEditAndResend]);
76
+ const handleKeyDown = useCallback((e) => {
77
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
78
+ e.preventDefault();
79
+ handleSaveAndResend();
80
+ }
81
+ if (e.key === "Escape") {
82
+ e.preventDefault();
83
+ handleCancelEdit();
84
+ }
85
+ }, [handleSaveAndResend, handleCancelEdit]);
86
+ if (isEditing) {
87
+ return (_jsxs("div", { className: "w-full bg-secondary rounded-2xl px-4 py-4", children: [message.images && message.images.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 mb-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 opacity-50" }, `image-${image.mimeType}-${image.data.slice(0, 20)}`))) })), _jsx(Textarea, { value: editedContent, onChange: (e) => setEditedContent(e.target.value), onKeyDown: handleKeyDown, className: "min-h-[60px] w-full resize-none bg-transparent border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 p-0 text-base leading-relaxed text-foreground", autoFocus: true, placeholder: "Edit your message..." }), _jsxs("div", { className: "flex items-center justify-end gap-2 mt-2", children: [
88
+ _jsx(Button, { variant: "ghost", size: "sm", onClick: handleCancelEdit, className: "h-7 px-2 text-xs", children: "Cancel" }), _jsx(Button, { size: "sm", onClick: handleSaveAndResend, disabled: !editedContent.trim(), className: "h-7 px-3 text-xs", children: "Send" })
89
+ ] })
90
+ ] }));
91
+ }
92
+ return (_jsxs("div", { ref: containerRef, className: "w-full group/user-message", children: [
93
+ _jsx("div", { className: sticky ? "sticky top-0 z-10 bg-background cursor-pointer" : "", onClick: sticky ? handleStickyClick : undefined, onKeyDown: sticky
94
+ ? (e) => {
95
+ if (e.key === "Enter" || e.key === " ") {
96
+ handleStickyClick();
97
+ }
98
+ }
99
+ : undefined, role: sticky ? "button" : undefined, tabIndex: sticky ? 0 : undefined, children: _jsx(MessageContent, { message: message }) }), !isStreaming && message.content && (_jsxs(Actions, { className: "mt-2 opacity-0 group-hover/user-message:opacity-100 transition-opacity", children: [
100
+ _jsx(Action, { onClick: handleCopy, tooltip: isCopied ? "Copied!" : "Copy", children: isCopied ? (_jsx(Check, { className: "size-4" })) : (_jsx(Copy, { className: "size-4" })) }), _jsx(Action, { onClick: handleStartEdit, tooltip: "Edit", children: _jsx(Pencil, { className: "size-4" }) })
101
+ ] }))] }));
102
+ }
103
+ export const EditableUserMessage = memo(PureEditableUserMessage, (prevProps, nextProps) => {
104
+ return (prevProps.isStreaming === nextProps.isStreaming &&
105
+ prevProps.message.id === nextProps.message.id &&
106
+ prevProps.message.content === nextProps.message.content &&
107
+ prevProps.messageIndex === nextProps.messageIndex &&
108
+ prevProps.sticky === nextProps.sticky);
109
+ });
@@ -0,0 +1,16 @@
1
+ import type { DisplayMessage } from "./MessageList.js";
2
+ export interface MessageActionsProps {
3
+ /** The message to show actions for */
4
+ message: DisplayMessage;
5
+ /** Whether the message is currently streaming */
6
+ isStreaming?: boolean;
7
+ /** Callback to regenerate/redo the response */
8
+ onRedo?: () => void;
9
+ /** Callback to send a message to the agent */
10
+ onSendMessage?: (message: string) => void;
11
+ /** Whether this is the last assistant message (shows actions by default) */
12
+ isLastAssistantMessage?: boolean;
13
+ }
14
+ declare function PureMessageActions({ message, isStreaming, onRedo, onSendMessage, isLastAssistantMessage }: MessageActionsProps): import("react/jsx-runtime").JSX.Element | null;
15
+ export declare const MessageActions: import("react").MemoExoticComponent<typeof PureMessageActions>;
16
+ export {};
@@ -0,0 +1,97 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Check, Copy, Download, FileSpreadsheet, FileText, FileType, RotateCcw, } from "lucide-react";
4
+ import { memo, useState } from "react";
5
+ import { toast } from "sonner";
6
+ import { cn } from "../lib/utils.js";
7
+ import { Action, Actions } from "./Actions.js";
8
+ import { Button } from "./Button.js";
9
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "./DropdownMenu.js";
10
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "./Tooltip.js";
11
+ const EXPORT_FORMATS = [
12
+ { id: "pdf", label: "PDF", icon: FileText },
13
+ { id: "excel", label: "Excel", icon: FileSpreadsheet },
14
+ { id: "csv", label: "CSV", icon: FileSpreadsheet },
15
+ { id: "text", label: "Text", icon: FileType },
16
+ { id: "markdown", label: "Markdown", icon: FileText },
17
+ ];
18
+ function PureMessageActions({ message, isStreaming, onRedo, onSendMessage, isLastAssistantMessage = false, }) {
19
+ const [isCopied, setIsCopied] = useState(false);
20
+ // Don't show actions while streaming
21
+ if (isStreaming) {
22
+ return null;
23
+ }
24
+ // Only show actions for assistant messages with content
25
+ if (message.role !== "assistant" || !message.content) {
26
+ return null;
27
+ }
28
+ // For non-last messages, show on hover only
29
+ const visibilityClass = isLastAssistantMessage
30
+ ? ""
31
+ : "opacity-0 group-hover:opacity-100 transition-opacity";
32
+ const handleCopy = async () => {
33
+ if (!message.content) {
34
+ toast.error("There's no text to copy!");
35
+ return;
36
+ }
37
+ try {
38
+ await navigator.clipboard.writeText(message.content);
39
+ setIsCopied(true);
40
+ toast.success("Copied to clipboard!");
41
+ // Reset the icon after 2 seconds
42
+ setTimeout(() => {
43
+ setIsCopied(false);
44
+ }, 2000);
45
+ }
46
+ catch {
47
+ toast.error("Failed to copy to clipboard");
48
+ }
49
+ };
50
+ const handleRedo = () => {
51
+ if (onRedo) {
52
+ onRedo();
53
+ }
54
+ else {
55
+ toast.info("Regenerate not available");
56
+ }
57
+ };
58
+ const handleExport = (format) => {
59
+ if (onSendMessage) {
60
+ onSendMessage(`produce an artifact as ${format} for this session`);
61
+ }
62
+ else {
63
+ toast.info("Export not available");
64
+ }
65
+ };
66
+ return (_jsxs(Actions, { className: cn("mt-2 mb-10", visibilityClass), children: [
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
+ _jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [
69
+ _jsx(TooltipTrigger, { asChild: true, children: _jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { className: cn("relative size-8 p-1.5 text-muted-foreground hover:text-foreground"), size: "sm", type: "button", variant: "ghost", children: [
70
+ _jsx(Download, { className: "size-4" }), _jsx("span", { className: "sr-only", children: "Export" })
71
+ ] }) }) }), _jsx(TooltipContent, { children: _jsx("p", { children: "Export" }) })
72
+ ] }) }), _jsx(DropdownMenuContent, { align: "start", children: EXPORT_FORMATS.map((format) => (_jsxs(DropdownMenuItem, { onClick: () => handleExport(format.label), children: [
73
+ _jsx(format.icon, { className: "size-4 mr-2" }), format.label] }, format.id))) })
74
+ ] })
75
+ ] }));
76
+ }
77
+ export const MessageActions = memo(PureMessageActions, (prevProps, nextProps) => {
78
+ if (prevProps.isStreaming !== nextProps.isStreaming) {
79
+ return false;
80
+ }
81
+ if (prevProps.message.id !== nextProps.message.id) {
82
+ return false;
83
+ }
84
+ if (prevProps.message.content !== nextProps.message.content) {
85
+ return false;
86
+ }
87
+ if (prevProps.onRedo !== nextProps.onRedo) {
88
+ return false;
89
+ }
90
+ if (prevProps.onSendMessage !== nextProps.onSendMessage) {
91
+ return false;
92
+ }
93
+ if (prevProps.isLastAssistantMessage !== nextProps.isLastAssistantMessage) {
94
+ return false;
95
+ }
96
+ return true;
97
+ });
@@ -49,7 +49,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
49
49
  const hasThinking = !!thinking;
50
50
  // Check if waiting (streaming but no content yet)
51
51
  const isWaiting = message.isStreaming && !message.content && message.role === "assistant";
52
- 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: "Thinking..." }) }) })), message.role === "assistant" ? ((() => {
52
+ 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" ? ((() => {
53
53
  // Sort tool calls by content position
54
54
  const sortedToolCalls = (message.toolCalls || [])
55
55
  .slice()
@@ -91,6 +91,14 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
91
91
  currentConsecutiveTitle = null;
92
92
  };
93
93
  for (const tc of toolCalls) {
94
+ // todo_write should never be grouped with other tool calls
95
+ const isTodoWrite = tc.title === "todo_write";
96
+ if (isTodoWrite) {
97
+ flushSelectingGroup();
98
+ flushConsecutiveGroup();
99
+ result.push(tc);
100
+ continue;
101
+ }
94
102
  // Handle batch groups (explicit batchId)
95
103
  if (tc.batchId) {
96
104
  flushSelectingGroup();
@@ -168,7 +176,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
168
176
  const hasHookPositions = hookNotifications.some((n) => n.contentPosition !== undefined);
169
177
  if (!hasHookPositions) {
170
178
  // No positions - render hooks at top, then tool calls, then content
171
- 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))) })), groupedToolCalls.length > 0 && (_jsx("div", { className: "flex flex-col gap-2 mb-1", children: groupedToolCalls.map((item, index) => renderToolCallOrGroup(item, index)) })), _jsx("div", { children: _jsx(Response, { content: message.content, isStreaming: message.isStreaming, showEmpty: false }) })
179
+ 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))) })), groupedToolCalls.length > 0 && (_jsx("div", { className: "flex flex-col gap-2 mb-1", children: groupedToolCalls.map((item, index) => renderToolCallOrGroup(item, index)) })), _jsx("div", { children: _jsx(Response, { content: message.content, isStreaming: message.isStreaming, showEmpty: false, sources: message.sources ?? [] }) })
172
180
  ] }));
173
181
  }
174
182
  // Hooks have positions - render them inline with content
@@ -180,7 +188,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
180
188
  if (position > currentPosition) {
181
189
  const textChunk = message.content.slice(currentPosition, position);
182
190
  if (textChunk) {
183
- elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false }) }, `text-before-hook-${notification.id}`));
191
+ elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false, sources: message.sources ?? [] }) }, `text-before-hook-${notification.id}`));
184
192
  }
185
193
  }
186
194
  // Add hook notification
@@ -191,7 +199,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
191
199
  if (currentPosition < message.content.length) {
192
200
  const remainingText = message.content.slice(currentPosition);
193
201
  if (remainingText) {
194
- elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false }) }, "text-end-hooks"));
202
+ elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false, sources: message.sources ?? [] }) }, "text-end-hooks"));
195
203
  }
196
204
  }
197
205
  // Add tool calls at the end
@@ -258,7 +266,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
258
266
  const itemId = positionedItem.type === "toolCall"
259
267
  ? positionedItem.item.id
260
268
  : positionedItem.item.id;
261
- elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false }) }, `text-before-${itemId}`));
269
+ elements.push(_jsx("div", { children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false, sources: message.sources ?? [] }) }, `text-before-${itemId}`));
262
270
  }
263
271
  }
264
272
  if (positionedItem.type === "hookNotification") {
@@ -270,7 +278,14 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
270
278
  else {
271
279
  // Tool call - check if it should be batched
272
280
  const toolCall = positionedItem.item;
273
- if (toolCall.batchId) {
281
+ // todo_write should never be grouped with other tool calls
282
+ const isTodoWrite = toolCall.title === "todo_write";
283
+ if (isTodoWrite) {
284
+ flushBatch();
285
+ currentBatch = [toolCall];
286
+ flushBatch();
287
+ }
288
+ else if (toolCall.batchId) {
274
289
  if (currentBatchId === toolCall.batchId) {
275
290
  // Same batch, add to current batch
276
291
  currentBatch.push(toolCall);
@@ -305,7 +320,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
305
320
  if (currentPosition < message.content.length) {
306
321
  const remainingText = message.content.slice(currentPosition);
307
322
  if (remainingText) {
308
- elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false }) }, "text-end"));
323
+ elements.push(_jsx("div", { children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false, sources: message.sources ?? [] }) }, "text-end"));
309
324
  }
310
325
  }
311
326
  // Render preliminary (selecting) tool calls at the end, grouped