@townco/ui 0.1.83 → 0.1.96

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,4 +1,5 @@
1
1
  import * as React from "react";
2
+ import type { Source } from "../../core/schemas/source.js";
2
3
  /**
3
4
  * Response component inspired by shadcn.io/ai
4
5
  * Streaming-optimized markdown renderer using streamdown for incremental parsing
@@ -12,5 +13,7 @@ export interface ResponseProps extends React.HTMLAttributes<HTMLDivElement> {
12
13
  showEmpty?: boolean;
13
14
  /** Custom empty state message */
14
15
  emptyMessage?: string;
16
+ /** Citation sources for rendering inline citations */
17
+ sources?: Source[];
15
18
  }
16
19
  export declare const Response: React.NamedExoticComponent<ResponseProps & React.RefAttributes<HTMLDivElement>>;
@@ -1,9 +1,35 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
+ import remarkGfm from "remark-gfm";
4
5
  import { Streamdown } from "streamdown";
6
+ import { remarkCitations } from "../lib/remark-citations.js";
5
7
  import { cn } from "../lib/utils.js";
6
- export const Response = React.memo(React.forwardRef(({ content, isStreaming = false, showEmpty = true, emptyMessage = "", className, ...props }, ref) => {
8
+ import { CitationChip } from "./CitationChip.js";
9
+ export const Response = React.memo(React.forwardRef(({ content, isStreaming = false, showEmpty = true, emptyMessage = "", sources = [], className, ...props }, ref) => {
10
+ // Memoize the remark plugins array to prevent re-renders
11
+ // remarkGfm adds support for tables, strikethrough, autolinks, task lists
12
+ const remarkPlugins = React.useMemo(() => [remarkGfm, remarkCitations], []);
13
+ // Memoize the custom components to prevent re-renders
14
+ // We use 'as Record<string, unknown>' because Streamdown's Components type
15
+ // doesn't include all the props we need
16
+ const customComponents = React.useMemo(() => ({
17
+ // Custom span component that intercepts citation markers
18
+ // The remark-citations plugin creates spans with class "citation-marker"
19
+ // and data-citation-id attribute
20
+ // Note: Streamdown may pass 'class' instead of 'className'
21
+ span: (spanProps) => {
22
+ const { className, class: classAttr, "data-citation-id": citationId, children, ...restProps } = spanProps;
23
+ // Check both className and class (Streamdown may use either)
24
+ const cssClass = className || classAttr;
25
+ // Check if this span is a citation marker
26
+ if (cssClass === "citation-marker" && citationId) {
27
+ return _jsx(CitationChip, { sourceId: citationId, sources: sources });
28
+ }
29
+ // Otherwise render as normal span
30
+ return (_jsx("span", { className: cssClass, ...restProps, children: children }));
31
+ },
32
+ }), [sources]);
7
33
  // Show empty state during streaming if no content yet
8
34
  if (!content && isStreaming && showEmpty) {
9
35
  return (_jsx("div", { ref: ref, className: cn("opacity-70 italic text-paragraph-sm", className), ...props, children: emptyMessage }));
@@ -11,6 +37,7 @@ export const Response = React.memo(React.forwardRef(({ content, isStreaming = fa
11
37
  if (!content) {
12
38
  return null;
13
39
  }
14
- return (_jsx("div", { ref: ref, ...props, children: _jsx(Streamdown, { className: cn("size-full", "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "[&_code]:whitespace-pre-wrap [&_code]:break-words", "[&_pre]:max-w-full [&_pre]:overflow-x-auto", className), children: content }) }));
15
- }), (prevProps, nextProps) => prevProps.content === nextProps.content);
40
+ return (_jsx("div", { ref: ref, ...props, children: _jsx(Streamdown, { className: cn("size-full", "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "[&_code]:whitespace-pre-wrap [&_code]:break-words", "[&_pre]:max-w-full [&_pre]:overflow-x-auto", className), remarkPlugins: remarkPlugins, components: customComponents, children: content }) }));
41
+ }), (prevProps, nextProps) => prevProps.content === nextProps.content &&
42
+ prevProps.sources === nextProps.sources);
16
43
  Response.displayName = "Response";
@@ -109,7 +109,7 @@ const SidebarInset = React.forwardRef(({ className, children, ...props }, ref) =
109
109
  const { isMobile, open } = useSidebar();
110
110
  // Desktop: Add padding when sidebar is open
111
111
  // Mobile: No changes (overlay handles visibility)
112
- return (_jsx("main", { className: cn("relative flex w-full flex-1 flex-col bg-background transition-all duration-500",
112
+ return (_jsx("main", { className: cn("relative flex w-full flex-1 flex-col bg-background transition-all duration-250",
113
113
  // Add left padding on desktop when sidebar is open
114
114
  !isMobile && open && "md:pl-64", className), ref: ref, ...props, children: children }));
115
115
  });
@@ -22,6 +22,6 @@ export function TodoSubline({ todos, className }) {
22
22
  const displayText = displayItem.status === "in_progress"
23
23
  ? displayItem.activeForm || displayItem.content
24
24
  : displayItem.content;
25
- return (_jsxs("span", { className: cn("flex items-center gap-1.5", className), children: [displayItem.status === "completed" ? (_jsx(CircleCheck, { className: "size-3 text-muted-foreground shrink-0" })) : displayItem.status === "in_progress" ? (_jsx("span", { className: "size-2 rounded-full bg-foreground animate-pulse-scale shrink-0" })) : (_jsx(Circle, { className: "size-3 text-foreground shrink-0" })), _jsx("span", { className: "truncate", children: displayText })
25
+ return (_jsxs("span", { className: cn("flex items-center gap-1.5", className), children: [displayItem.status === "completed" ? (_jsx(CircleCheck, { className: "size-3 text-muted-foreground shrink-0" })) : displayItem.status === "pending" ? (_jsx(Circle, { className: "size-3 text-foreground shrink-0" })) : null, _jsx("span", { className: "truncate", children: displayText })
26
26
  ] }));
27
27
  }
@@ -29,6 +29,13 @@ export function WorkProgress({ thinking, isThinkingStreaming = false, toolCalls
29
29
  }
30
30
  };
31
31
  for (const toolCall of toolCalls) {
32
+ // todo_write should never be grouped with other tool calls
33
+ const isTodoWrite = toolCall.title === "todo_write";
34
+ if (isTodoWrite) {
35
+ flushSelectingGroup();
36
+ result.push({ type: "single", toolCall });
37
+ continue;
38
+ }
32
39
  // Handle batch groups (parallel operations)
33
40
  if (toolCall.batchId) {
34
41
  flushSelectingGroup();
@@ -2,22 +2,26 @@ export { toast } from "sonner";
2
2
  export { MockFileSystemProvider, mockFileSystemData, } from "../data/mockFileSystemData.js";
3
3
  export { mockSourceData } from "../data/mockSourceData.js";
4
4
  export type { FileSystemData, FileSystemItem as FileSystemItemData, FileSystemItemType, FileSystemProvider, } from "../types/filesystem.js";
5
+ export { Action, type ActionProps, Actions, type ActionsProps, } from "./Actions.js";
5
6
  export { AppSidebar, type AppSidebarProps, } from "./AppSidebar.js";
6
7
  export { Button, type ButtonProps, buttonVariants } from "./Button.js";
7
8
  export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "./Card.js";
8
9
  export { ChatEmptyState, type ChatEmptyStateProps } from "./ChatEmptyState.js";
9
10
  export * as ChatHeader from "./ChatHeader.js";
10
- export { Actions as ChatInputActions, Attachment as ChatInputAttachment, type ChatInputActionsProps, type ChatInputAttachmentProps, type ChatInputCommandMenuProps, type ChatInputFieldProps, type ChatInputRootProps, type ChatInputSubmitProps, type ChatInputToolbarProps, type ChatInputVoiceInputProps, CommandMenu as ChatInputCommandMenu, type CommandMenuItem, Field as ChatInputField, Root as ChatInputRoot, Submit as ChatInputSubmit, Toolbar as ChatInputToolbar, VoiceInput as ChatInputVoiceInput, } from "./ChatInput.js";
11
+ export { Actions as ChatInputActions, Attachment as ChatInputAttachment, type ChatInputActionsProps, type ChatInputAttachmentProps, type ChatInputCommandMenuProps, type ChatInputFieldProps, type ChatInputRootProps, type ChatInputStopProps, type ChatInputSubmitProps, type ChatInputToolbarProps, type ChatInputVoiceInputProps, CommandMenu as ChatInputCommandMenu, type CommandMenuItem, Field as ChatInputField, Root as ChatInputRoot, Stop as ChatInputStop, Submit as ChatInputSubmit, Toolbar as ChatInputToolbar, VoiceInput as ChatInputVoiceInput, } from "./ChatInput.js";
12
+ export { ChatInputParameters, type ChatInputParametersProps, } from "./ChatInputParameters.js";
11
13
  export * as ChatLayout from "./ChatLayout.js";
12
14
  export { DatabaseTabContent, type DatabaseTabContentProps, FilesTabContent, type FilesTabContentProps, SettingsTabContent, type SettingsTabContentProps, SourcesTabContent, type SourcesTabContentProps, TodoTabContent, type TodoTabContentProps, } from "./ChatPanelTabContent.js";
13
15
  export { ChatSecondaryPanel, type ChatSecondaryPanelProps, } from "./ChatSecondaryPanel.js";
14
16
  export * as ChatSidebar from "./ChatSidebar.js";
15
17
  export { ChatStatus, type ChatStatusProps } from "./ChatStatus.js";
16
18
  export { ChatView, type ChatViewProps } from "./ChatView.js";
19
+ export { CitationChip, type CitationChipProps } from "./CitationChip.js";
17
20
  export { ContextUsageButton, type ContextUsageButtonProps, } from "./ContextUsageButton.js";
18
21
  export { Conversation, type ConversationProps } from "./Conversation.js";
19
22
  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } from "./Dialog.js";
20
23
  export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./DropdownMenu.js";
24
+ export { EditableUserMessage, type EditableUserMessageProps, } from "./EditableUserMessage.js";
21
25
  export { FileSystemItem, type FileSystemItemProps, } from "./FileSystemItem.js";
22
26
  export { FileSystemView, type FileSystemViewProps, } from "./FileSystemView.js";
23
27
  export { HeightTransition } from "./HeightTransition.js";
@@ -27,6 +31,7 @@ export { Input, type InputProps, inputVariants } from "./Input.js";
27
31
  export { Label } from "./Label.js";
28
32
  export { MarkdownRenderer } from "./MarkdownRenderer.js";
29
33
  export { Message, type MessageProps } from "./Message.js";
34
+ export { MessageActions, type MessageActionsProps, } from "./MessageActions.js";
30
35
  export { MessageContent, type MessageContentProps } from "./MessageContent.js";
31
36
  export type { DisplayMessage } from "./MessageList.js";
32
37
  export { PanelTabsHeader, type PanelTabsHeaderProps, } from "./PanelTabsHeader.js";
@@ -2,6 +2,7 @@
2
2
  export { toast } from "sonner";
3
3
  export { MockFileSystemProvider, mockFileSystemData, } from "../data/mockFileSystemData.js";
4
4
  export { mockSourceData } from "../data/mockSourceData.js";
5
+ export { Action, Actions, } from "./Actions.js";
5
6
  // Sidebar components
6
7
  export { AppSidebar, } from "./AppSidebar.js";
7
8
  export { Button, buttonVariants } from "./Button.js";
@@ -9,7 +10,8 @@ export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,
9
10
  export { ChatEmptyState } from "./ChatEmptyState.js";
10
11
  export * as ChatHeader from "./ChatHeader.js";
11
12
  // Chat components - composable primitives
12
- export { Actions as ChatInputActions, Attachment as ChatInputAttachment, CommandMenu as ChatInputCommandMenu, Field as ChatInputField, Root as ChatInputRoot, Submit as ChatInputSubmit, Toolbar as ChatInputToolbar, VoiceInput as ChatInputVoiceInput, } from "./ChatInput.js";
13
+ export { Actions as ChatInputActions, Attachment as ChatInputAttachment, CommandMenu as ChatInputCommandMenu, Field as ChatInputField, Root as ChatInputRoot, Stop as ChatInputStop, Submit as ChatInputSubmit, Toolbar as ChatInputToolbar, VoiceInput as ChatInputVoiceInput, } from "./ChatInput.js";
14
+ export { ChatInputParameters, } from "./ChatInputParameters.js";
13
15
  // Chat layout components
14
16
  export * as ChatLayout from "./ChatLayout.js";
15
17
  export { DatabaseTabContent, FilesTabContent, SettingsTabContent, SourcesTabContent, TodoTabContent, } from "./ChatPanelTabContent.js";
@@ -17,6 +19,7 @@ export { ChatSecondaryPanel, } from "./ChatSecondaryPanel.js";
17
19
  export * as ChatSidebar from "./ChatSidebar.js";
18
20
  export { ChatStatus } from "./ChatStatus.js";
19
21
  export { ChatView } from "./ChatView.js";
22
+ export { CitationChip } from "./CitationChip.js";
20
23
  export { ContextUsageButton, } from "./ContextUsageButton.js";
21
24
  // Chat components - shadcn.io/ai inspired primitives
22
25
  export { Conversation } from "./Conversation.js";
@@ -24,6 +27,7 @@ export { Conversation } from "./Conversation.js";
24
27
  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } from "./Dialog.js";
25
28
  // DropdownMenu components
26
29
  export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./DropdownMenu.js";
30
+ export { EditableUserMessage, } from "./EditableUserMessage.js";
27
31
  export { FileSystemItem, } from "./FileSystemItem.js";
28
32
  // FileSystem components
29
33
  export { FileSystemView, } from "./FileSystemView.js";
@@ -35,6 +39,7 @@ export { Input, inputVariants } from "./Input.js";
35
39
  export { Label } from "./Label.js";
36
40
  export { MarkdownRenderer } from "./MarkdownRenderer.js";
37
41
  export { Message } from "./Message.js";
42
+ export { MessageActions, } from "./MessageActions.js";
38
43
  export { MessageContent } from "./MessageContent.js";
39
44
  export { PanelTabsHeader, } from "./PanelTabsHeader.js";
40
45
  export { Reasoning } from "./Reasoning.js";
@@ -1,3 +1,4 @@
1
+ export { useDocumentTitle, useFavicon } from "./use-favicon.js";
1
2
  export { useLockBodyScroll } from "./use-lock-body-scroll.js";
2
3
  export { useIsMobile } from "./use-mobile.js";
3
4
  export { useScrollToBottom } from "./use-scroll-to-bottom.js";
@@ -1,3 +1,4 @@
1
+ export { useDocumentTitle, useFavicon } from "./use-favicon.js";
1
2
  export { useLockBodyScroll } from "./use-lock-body-scroll.js";
2
3
  export { useIsMobile } from "./use-mobile.js";
3
4
  export { useScrollToBottom } from "./use-scroll-to-bottom.js";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Hook that updates the document title to show loading dots when the agent is working
3
+ * @param agentName - The name of the agent to display in the title
4
+ */
5
+ export declare function useDocumentTitle(agentName: string | undefined): void;
6
+ export declare const useFavicon: typeof useDocumentTitle;
@@ -0,0 +1,47 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useChatStore } from "../../core/store/chat-store.js";
3
+ // Animation settings
4
+ const DOT_CYCLE_MS = 500; // Time between dot changes (500ms per dot)
5
+ /**
6
+ * Hook that updates the document title to show loading dots when the agent is working
7
+ * @param agentName - The name of the agent to display in the title
8
+ */
9
+ export function useDocumentTitle(agentName) {
10
+ const isStreaming = useChatStore((state) => state.isStreaming);
11
+ const intervalRef = useRef(null);
12
+ const dotCountRef = useRef(0);
13
+ useEffect(() => {
14
+ // Set base title when agent name changes
15
+ if (agentName && !isStreaming) {
16
+ document.title = agentName;
17
+ }
18
+ }, [agentName, isStreaming]);
19
+ useEffect(() => {
20
+ if (!agentName)
21
+ return;
22
+ const updateTitle = () => {
23
+ // Cycle through 0, 1, 2, 3 dots
24
+ const dots = ".".repeat(dotCountRef.current);
25
+ document.title = `${agentName}${dots}`;
26
+ dotCountRef.current = (dotCountRef.current + 1) % 4;
27
+ };
28
+ if (isStreaming) {
29
+ // Start dot animation
30
+ dotCountRef.current = 0;
31
+ updateTitle(); // Initial update
32
+ intervalRef.current = setInterval(updateTitle, DOT_CYCLE_MS);
33
+ return () => {
34
+ // Stop animation
35
+ if (intervalRef.current) {
36
+ clearInterval(intervalRef.current);
37
+ intervalRef.current = null;
38
+ }
39
+ // Restore title without dots
40
+ document.title = agentName;
41
+ };
42
+ }
43
+ return undefined;
44
+ }, [isStreaming, agentName]);
45
+ }
46
+ // Keep the old export name for backwards compatibility
47
+ export const useFavicon = useDocumentTitle;
@@ -7,6 +7,7 @@
7
7
  * - Auto-scrolls to bottom when content changes (streaming) if user was at bottom
8
8
  * - Respects user scrolling - won't auto-scroll if user is manually scrolling
9
9
  * - Uses MutationObserver for DOM changes and ResizeObserver for size changes
10
+ * - Tracks when the last user message is scrolled above the viewport
10
11
  */
11
12
  export declare function useScrollToBottom(): {
12
13
  containerRef: import("react").RefObject<HTMLDivElement | null>;
@@ -15,4 +16,17 @@ export declare function useScrollToBottom(): {
15
16
  scrollToBottom: (behavior?: ScrollBehavior) => void;
16
17
  onViewportEnter: () => void;
17
18
  onViewportLeave: () => void;
19
+ isUserMessageAboveFold: boolean;
20
+ hasMoreUserMessagesBelow: boolean;
21
+ userMessagesAboveCount: number;
22
+ userMessagesBelowCount: number;
23
+ scrollToPreviousUserMessage: (behavior?: ScrollBehavior) => void;
24
+ scrollToNextUserMessage: (behavior?: ScrollBehavior) => void;
25
+ scrollToUserMessageByIndex: (index: number, behavior?: ScrollBehavior) => void;
26
+ getUserMessagePreviews: () => {
27
+ index: number;
28
+ preview: string;
29
+ element: HTMLElement;
30
+ }[];
31
+ resetUserMessageCycle: () => void;
18
32
  };
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
8
8
  * - Auto-scrolls to bottom when content changes (streaming) if user was at bottom
9
9
  * - Respects user scrolling - won't auto-scroll if user is manually scrolling
10
10
  * - Uses MutationObserver for DOM changes and ResizeObserver for size changes
11
+ * - Tracks when the last user message is scrolled above the viewport
11
12
  */
12
13
  export function useScrollToBottom() {
13
14
  const containerRef = useRef(null);
@@ -15,6 +16,19 @@ export function useScrollToBottom() {
15
16
  const [isAtBottom, setIsAtBottom] = useState(true);
16
17
  const isAtBottomRef = useRef(true);
17
18
  const isUserScrollingRef = useRef(false);
19
+ // Track when any user message is above the fold
20
+ const [isUserMessageAboveFold, setIsUserMessageAboveFold] = useState(false);
21
+ // Track whether there are more user messages below the current one
22
+ const [hasMoreUserMessagesBelow, setHasMoreUserMessagesBelow] = useState(false);
23
+ // Track counts of messages above and below
24
+ const [userMessagesAboveCount, setUserMessagesAboveCount] = useState(0);
25
+ const [userMessagesBelowCount, setUserMessagesBelowCount] = useState(0);
26
+ // Track the index of the last user message we scrolled to (for cycling through messages)
27
+ // -1 means we haven't scrolled to any yet, so start from the bottom-most above fold
28
+ const lastScrolledUserMessageIndexRef = useRef(-1);
29
+ // Flag to prevent jittery updates during programmatic scroll
30
+ const isNavigatingRef = useRef(false);
31
+ const navigationTimeoutRef = useRef(null);
18
32
  // Keep ref in sync with state
19
33
  useEffect(() => {
20
34
  isAtBottomRef.current = isAtBottom;
@@ -27,6 +41,129 @@ export function useScrollToBottom() {
27
41
  // Consider "at bottom" if within 100px of the bottom
28
42
  return scrollTop + clientHeight >= scrollHeight - 100;
29
43
  }, []);
44
+ // Find all user message elements
45
+ const findAllUserMessages = useCallback(() => {
46
+ if (!containerRef.current) {
47
+ return [];
48
+ }
49
+ // User messages have aria-label="user message"
50
+ const userMessages = containerRef.current.querySelectorAll('[aria-label="user message"]');
51
+ return Array.from(userMessages);
52
+ }, []);
53
+ // Get user message data for messages above the fold (index, preview text, element)
54
+ const getUserMessagePreviews = useCallback(() => {
55
+ const container = containerRef.current;
56
+ if (!container) {
57
+ return [];
58
+ }
59
+ const containerRect = container.getBoundingClientRect();
60
+ const allUserMessages = findAllUserMessages();
61
+ // Only include messages that are above the fold
62
+ return allUserMessages
63
+ .map((element, index) => {
64
+ const messageRect = element.getBoundingClientRect();
65
+ const isAboveFold = messageRect.bottom < containerRect.top + 50;
66
+ // Get the text content, truncated for preview
67
+ const textContent = element.textContent || "";
68
+ const preview = textContent.slice(0, 100) + (textContent.length > 100 ? "..." : "");
69
+ return { index, preview, element, isAboveFold };
70
+ })
71
+ .filter(({ isAboveFold }) => isAboveFold)
72
+ .map(({ index, preview, element }) => ({ index, preview, element }));
73
+ }, [findAllUserMessages]);
74
+ // Scroll to a specific user message by index
75
+ const scrollToUserMessageByIndex = useCallback((index, behavior = "smooth") => {
76
+ const allUserMessages = findAllUserMessages();
77
+ if (index < 0 || index >= allUserMessages.length) {
78
+ return;
79
+ }
80
+ const targetMessage = allUserMessages[index];
81
+ if (targetMessage) {
82
+ // Set navigating flag to prevent jittery updates
83
+ isNavigatingRef.current = true;
84
+ if (navigationTimeoutRef.current) {
85
+ clearTimeout(navigationTimeoutRef.current);
86
+ }
87
+ targetMessage.scrollIntoView({
88
+ behavior,
89
+ block: "start",
90
+ });
91
+ lastScrolledUserMessageIndexRef.current = index;
92
+ // Update all states consistently
93
+ setUserMessagesAboveCount(index);
94
+ const belowCount = allUserMessages.length - 1 - index;
95
+ setUserMessagesBelowCount(belowCount);
96
+ setHasMoreUserMessagesBelow(belowCount > 0);
97
+ // Clear navigating flag after scroll animation completes
98
+ navigationTimeoutRef.current = setTimeout(() => {
99
+ isNavigatingRef.current = false;
100
+ }, 500);
101
+ }
102
+ }, [findAllUserMessages]);
103
+ // Find user messages that are above the visible viewport
104
+ const findUserMessagesAboveFold = useCallback(() => {
105
+ const container = containerRef.current;
106
+ if (!container) {
107
+ return [];
108
+ }
109
+ const containerRect = container.getBoundingClientRect();
110
+ const allUserMessages = findAllUserMessages();
111
+ // Filter to messages whose bottom edge is above the container's visible area
112
+ // We add a small threshold (50px) so the button appears before the message fully disappears
113
+ return allUserMessages.filter((message) => {
114
+ const messageRect = message.getBoundingClientRect();
115
+ return messageRect.bottom < containerRect.top + 50;
116
+ });
117
+ }, [findAllUserMessages]);
118
+ // Check if any user message is above the visible viewport
119
+ const _checkIfUserMessageAboveFold = useCallback(() => {
120
+ const messagesAbove = findUserMessagesAboveFold();
121
+ return messagesAbove.length > 0;
122
+ }, [findUserMessagesAboveFold]);
123
+ // Find the user message currently visible at the top of the viewport
124
+ const findCurrentUserMessageIndex = useCallback(() => {
125
+ const container = containerRef.current;
126
+ if (!container) {
127
+ return -1;
128
+ }
129
+ const containerRect = container.getBoundingClientRect();
130
+ const allUserMessages = findAllUserMessages();
131
+ // Find the user message that's currently at or near the top of the viewport
132
+ for (let i = 0; i < allUserMessages.length; i++) {
133
+ const messageRect = allUserMessages[i]?.getBoundingClientRect();
134
+ if (!messageRect)
135
+ continue;
136
+ // Message is "current" if its top is near the top of the container (within 100px)
137
+ // or if it spans across the top of the viewport
138
+ if ((messageRect.top >= containerRect.top - 50 &&
139
+ messageRect.top < containerRect.top + 150) ||
140
+ (messageRect.top < containerRect.top &&
141
+ messageRect.bottom > containerRect.top + 50)) {
142
+ return i;
143
+ }
144
+ }
145
+ // If no message is at the top, find the last message that's above the fold
146
+ for (let i = allUserMessages.length - 1; i >= 0; i--) {
147
+ const messageRect = allUserMessages[i]?.getBoundingClientRect();
148
+ if (!messageRect)
149
+ continue;
150
+ if (messageRect.bottom < containerRect.top + 50) {
151
+ return i;
152
+ }
153
+ }
154
+ return -1;
155
+ }, [findAllUserMessages]);
156
+ // Check if there are more user messages below the current one
157
+ const _checkHasMoreUserMessagesBelow = useCallback(() => {
158
+ const allUserMessages = findAllUserMessages();
159
+ const currentIndex = lastScrolledUserMessageIndexRef.current !== -1
160
+ ? lastScrolledUserMessageIndexRef.current
161
+ : findCurrentUserMessageIndex();
162
+ if (currentIndex === -1) {
163
+ return false;
164
+ }
165
+ return currentIndex < allUserMessages.length - 1;
166
+ }, [findAllUserMessages, findCurrentUserMessageIndex]);
30
167
  const scrollToBottom = useCallback((behavior = "smooth") => {
31
168
  if (!containerRef.current) {
32
169
  return;
@@ -35,6 +172,142 @@ export function useScrollToBottom() {
35
172
  top: containerRef.current.scrollHeight,
36
173
  behavior,
37
174
  });
175
+ // Reset user message cycle when scrolling to bottom
176
+ lastScrolledUserMessageIndexRef.current = -1;
177
+ }, []);
178
+ // Scroll to the previous user message (up - older messages)
179
+ const scrollToPreviousUserMessage = useCallback((behavior = "smooth") => {
180
+ const container = containerRef.current;
181
+ if (!container) {
182
+ return;
183
+ }
184
+ const allUserMessages = findAllUserMessages();
185
+ if (allUserMessages.length === 0) {
186
+ return;
187
+ }
188
+ const containerRect = container.getBoundingClientRect();
189
+ // Find user messages that are above the fold (not visible)
190
+ const messagesAboveFold = [];
191
+ for (let i = 0; i < allUserMessages.length; i++) {
192
+ const messageRect = allUserMessages[i]?.getBoundingClientRect();
193
+ if (!messageRect)
194
+ continue;
195
+ if (messageRect.bottom < containerRect.top + 50) {
196
+ messagesAboveFold.push(i);
197
+ }
198
+ }
199
+ if (messagesAboveFold.length === 0) {
200
+ return; // No messages above to scroll to
201
+ }
202
+ let targetIndex;
203
+ if (lastScrolledUserMessageIndexRef.current === -1) {
204
+ // First click: go to the most recent message above the fold (closest to viewport)
205
+ const lastAbove = messagesAboveFold[messagesAboveFold.length - 1];
206
+ if (lastAbove === undefined)
207
+ return;
208
+ targetIndex = lastAbove;
209
+ }
210
+ else {
211
+ // Find messages above the fold that are also above our current position
212
+ const messagesAboveCurrentPosition = messagesAboveFold.filter((i) => i < lastScrolledUserMessageIndexRef.current);
213
+ if (messagesAboveCurrentPosition.length > 0) {
214
+ // Go to the closest one above current position
215
+ const lastAbovePosition = messagesAboveCurrentPosition[messagesAboveCurrentPosition.length - 1];
216
+ if (lastAbovePosition === undefined)
217
+ return;
218
+ targetIndex = lastAbovePosition;
219
+ }
220
+ else {
221
+ // Already at the top, stay at the first message above fold
222
+ const firstAbove = messagesAboveFold[0];
223
+ if (firstAbove === undefined)
224
+ return;
225
+ targetIndex = firstAbove;
226
+ }
227
+ }
228
+ if (targetIndex >= 0 && targetIndex < allUserMessages.length) {
229
+ const targetMessage = allUserMessages[targetIndex];
230
+ if (targetMessage) {
231
+ // Set navigating flag to prevent jittery updates
232
+ isNavigatingRef.current = true;
233
+ if (navigationTimeoutRef.current) {
234
+ clearTimeout(navigationTimeoutRef.current);
235
+ }
236
+ targetMessage.scrollIntoView({
237
+ behavior,
238
+ block: "start",
239
+ });
240
+ lastScrolledUserMessageIndexRef.current = targetIndex;
241
+ // Update all states consistently
242
+ setUserMessagesAboveCount(targetIndex);
243
+ const belowCount = allUserMessages.length - 1 - targetIndex;
244
+ setUserMessagesBelowCount(belowCount);
245
+ setHasMoreUserMessagesBelow(belowCount > 0);
246
+ // Clear navigating flag after scroll animation completes
247
+ navigationTimeoutRef.current = setTimeout(() => {
248
+ isNavigatingRef.current = false;
249
+ }, 500);
250
+ }
251
+ }
252
+ }, [findAllUserMessages]);
253
+ // Scroll to the next user message (down - newer messages)
254
+ const scrollToNextUserMessage = useCallback((behavior = "smooth") => {
255
+ const container = containerRef.current;
256
+ if (!container) {
257
+ return;
258
+ }
259
+ const allUserMessages = findAllUserMessages();
260
+ if (allUserMessages.length === 0) {
261
+ return;
262
+ }
263
+ // If we have a tracked index, go to the next one
264
+ if (lastScrolledUserMessageIndexRef.current !== -1) {
265
+ const nextIndex = lastScrolledUserMessageIndexRef.current + 1;
266
+ if (nextIndex < allUserMessages.length) {
267
+ const targetMessage = allUserMessages[nextIndex];
268
+ if (targetMessage) {
269
+ // Set navigating flag to prevent jittery updates
270
+ isNavigatingRef.current = true;
271
+ if (navigationTimeoutRef.current) {
272
+ clearTimeout(navigationTimeoutRef.current);
273
+ }
274
+ targetMessage.scrollIntoView({
275
+ behavior,
276
+ block: "start",
277
+ });
278
+ lastScrolledUserMessageIndexRef.current = nextIndex;
279
+ // Update all states consistently
280
+ setUserMessagesAboveCount(nextIndex);
281
+ const belowCount = allUserMessages.length - 1 - nextIndex;
282
+ setUserMessagesBelowCount(belowCount);
283
+ setHasMoreUserMessagesBelow(belowCount > 0);
284
+ // Clear navigating flag after scroll animation completes
285
+ navigationTimeoutRef.current = setTimeout(() => {
286
+ isNavigatingRef.current = false;
287
+ }, 500);
288
+ }
289
+ }
290
+ }
291
+ }, [findAllUserMessages]);
292
+ // Check if we can navigate to previous (older) user message
293
+ const _canScrollToPreviousUserMessage = useCallback(() => {
294
+ if (lastScrolledUserMessageIndexRef.current > 0) {
295
+ return true;
296
+ }
297
+ // Also check if there are messages above the fold
298
+ return findUserMessagesAboveFold().length > 0;
299
+ }, [findUserMessagesAboveFold]);
300
+ // Check if we can navigate to next (newer) user message
301
+ const _canScrollToNextUserMessage = useCallback(() => {
302
+ const allUserMessages = findAllUserMessages();
303
+ if (lastScrolledUserMessageIndexRef.current === -1) {
304
+ return false;
305
+ }
306
+ return lastScrolledUserMessageIndexRef.current < allUserMessages.length - 1;
307
+ }, [findAllUserMessages]);
308
+ // Reset the cycle when user scrolls to bottom or scrolls down significantly
309
+ const resetUserMessageCycle = useCallback(() => {
310
+ lastScrolledUserMessageIndexRef.current = -1;
38
311
  }, []);
39
312
  // Handle user scroll events
40
313
  useEffect(() => {
@@ -51,6 +324,34 @@ export function useScrollToBottom() {
51
324
  const atBottom = checkIfAtBottom();
52
325
  setIsAtBottom(atBottom);
53
326
  isAtBottomRef.current = atBottom;
327
+ // Check if there are any messages above the fold (component visibility)
328
+ const messagesAboveFold = findUserMessagesAboveFold();
329
+ const userMessageAbove = messagesAboveFold.length > 0;
330
+ setIsUserMessageAboveFold(userMessageAbove);
331
+ // Skip count updates if we're in the middle of a programmatic scroll
332
+ // This prevents jittery number changes during animation
333
+ if (!isNavigatingRef.current) {
334
+ // Update the current user message index based on scroll position
335
+ const currentIndex = findCurrentUserMessageIndex();
336
+ if (currentIndex !== -1) {
337
+ lastScrolledUserMessageIndexRef.current = currentIndex;
338
+ }
339
+ // All counts and states
340
+ const allMessages = findAllUserMessages();
341
+ // Above count: number of messages above the fold (what you can scroll up to)
342
+ setUserMessagesAboveCount(messagesAboveFold.length);
343
+ // Get the effective current index for below count
344
+ const effectiveIndex = lastScrolledUserMessageIndexRef.current !== -1
345
+ ? lastScrolledUserMessageIndexRef.current
346
+ : currentIndex;
347
+ // Below count: how many messages are after current position (can navigate down to)
348
+ const belowCount = effectiveIndex !== -1 && effectiveIndex < allMessages.length - 1
349
+ ? allMessages.length - 1 - effectiveIndex
350
+ : 0;
351
+ setUserMessagesBelowCount(belowCount);
352
+ // Has more below: consistent with below count
353
+ setHasMoreUserMessagesBelow(belowCount > 0);
354
+ }
54
355
  // Reset user scrolling flag after scroll ends (150ms debounce)
55
356
  scrollTimeout = setTimeout(() => {
56
357
  isUserScrollingRef.current = false;
@@ -61,7 +362,12 @@ export function useScrollToBottom() {
61
362
  container.removeEventListener("scroll", handleScroll);
62
363
  clearTimeout(scrollTimeout);
63
364
  };
64
- }, [checkIfAtBottom]);
365
+ }, [
366
+ checkIfAtBottom,
367
+ findCurrentUserMessageIndex,
368
+ findAllUserMessages,
369
+ findUserMessagesAboveFold,
370
+ ]);
65
371
  // Auto-scroll when content changes
66
372
  useEffect(() => {
67
373
  const container = containerRef.current;
@@ -116,5 +422,15 @@ export function useScrollToBottom() {
116
422
  scrollToBottom,
117
423
  onViewportEnter,
118
424
  onViewportLeave,
425
+ // User message navigation
426
+ isUserMessageAboveFold,
427
+ hasMoreUserMessagesBelow,
428
+ userMessagesAboveCount,
429
+ userMessagesBelowCount,
430
+ scrollToPreviousUserMessage,
431
+ scrollToNextUserMessage,
432
+ scrollToUserMessageByIndex,
433
+ getUserMessagePreviews,
434
+ resetUserMessageCycle,
119
435
  };
120
436
  }
@@ -1,3 +1,3 @@
1
1
  export * from "./components/index.js";
2
- export { useIsMobile } from "./hooks/index.js";
2
+ export { useDocumentTitle, useFavicon, useIsMobile } from "./hooks/index.js";
3
3
  export { cn } from "./lib/utils.js";
package/dist/gui/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Re-export all components
2
2
  export * from "./components/index.js";
3
3
  // Re-export hooks
4
- export { useIsMobile } from "./hooks/index.js";
4
+ export { useDocumentTitle, useFavicon, useIsMobile } from "./hooks/index.js";
5
5
  // Re-export utilities
6
6
  export { cn } from "./lib/utils.js";