@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.
- package/dist/core/hooks/use-chat-input.js +13 -6
- package/dist/core/hooks/use-chat-messages.d.ts +17 -0
- package/dist/core/hooks/use-chat-messages.js +294 -10
- package/dist/core/schemas/chat.d.ts +20 -0
- package/dist/core/schemas/chat.js +4 -0
- package/dist/core/schemas/index.d.ts +1 -0
- package/dist/core/schemas/index.js +1 -0
- package/dist/core/schemas/source.d.ts +22 -0
- package/dist/core/schemas/source.js +45 -0
- package/dist/core/store/chat-store.d.ts +4 -0
- package/dist/core/store/chat-store.js +54 -0
- package/dist/gui/components/Actions.d.ts +15 -0
- package/dist/gui/components/Actions.js +22 -0
- package/dist/gui/components/ChatInput.d.ts +9 -1
- package/dist/gui/components/ChatInput.js +24 -6
- package/dist/gui/components/ChatInputCommandMenu.d.ts +1 -0
- package/dist/gui/components/ChatInputCommandMenu.js +22 -5
- package/dist/gui/components/ChatInputParameters.d.ts +13 -0
- package/dist/gui/components/ChatInputParameters.js +67 -0
- package/dist/gui/components/ChatLayout.d.ts +2 -0
- package/dist/gui/components/ChatLayout.js +183 -61
- package/dist/gui/components/ChatPanelTabContent.d.ts +7 -0
- package/dist/gui/components/ChatPanelTabContent.js +17 -7
- package/dist/gui/components/ChatView.js +105 -15
- package/dist/gui/components/CitationChip.d.ts +15 -0
- package/dist/gui/components/CitationChip.js +72 -0
- package/dist/gui/components/EditableUserMessage.d.ts +18 -0
- package/dist/gui/components/EditableUserMessage.js +109 -0
- package/dist/gui/components/MessageActions.d.ts +16 -0
- package/dist/gui/components/MessageActions.js +97 -0
- package/dist/gui/components/MessageContent.js +22 -7
- package/dist/gui/components/Response.d.ts +3 -0
- package/dist/gui/components/Response.js +30 -3
- package/dist/gui/components/Sidebar.js +1 -1
- package/dist/gui/components/TodoSubline.js +1 -1
- package/dist/gui/components/WorkProgress.js +7 -0
- package/dist/gui/components/index.d.ts +6 -1
- package/dist/gui/components/index.js +6 -1
- package/dist/gui/hooks/index.d.ts +1 -0
- package/dist/gui/hooks/index.js +1 -0
- package/dist/gui/hooks/use-favicon.d.ts +6 -0
- package/dist/gui/hooks/use-favicon.js +47 -0
- package/dist/gui/hooks/use-scroll-to-bottom.d.ts +14 -0
- package/dist/gui/hooks/use-scroll-to-bottom.js +317 -1
- package/dist/gui/index.d.ts +1 -1
- package/dist/gui/index.js +1 -1
- package/dist/gui/lib/motion.js +6 -6
- package/dist/gui/lib/remark-citations.d.ts +28 -0
- package/dist/gui/lib/remark-citations.js +70 -0
- package/dist/sdk/client/acp-client.d.ts +38 -1
- package/dist/sdk/client/acp-client.js +67 -3
- package/dist/sdk/schemas/message.d.ts +40 -0
- package/dist/sdk/schemas/message.js +20 -0
- package/dist/sdk/transports/http.d.ts +24 -1
- package/dist/sdk/transports/http.js +189 -1
- package/dist/sdk/transports/stdio.d.ts +1 -0
- package/dist/sdk/transports/stdio.js +39 -0
- package/dist/sdk/transports/types.d.ts +46 -1
- package/dist/sdk/transports/websocket.d.ts +1 -0
- package/dist/sdk/transports/websocket.js +4 -0
- package/dist/tui/components/ChatView.js +3 -4
- package/package.json +5 -3
- 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
|
-
|
|
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-
|
|
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 === "
|
|
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";
|
package/dist/gui/hooks/index.js
CHANGED
|
@@ -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
|
-
}, [
|
|
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
|
}
|
package/dist/gui/index.d.ts
CHANGED
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";
|