mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
Merge remote-tracking branch 'origin/dev' into billing-changes
This commit is contained in:
commit
62ef1efc82
4 changed files with 283 additions and 139 deletions
|
|
@ -20,7 +20,7 @@ import {
|
|||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ScrollPositionPreserver,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation';
|
||||
import {
|
||||
Message,
|
||||
|
|
@ -62,6 +62,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
|
|||
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ChatViewportAnchorState,
|
||||
type ChatTabViewState,
|
||||
type ConversationItem,
|
||||
type ToolCall,
|
||||
|
|
@ -841,6 +842,7 @@ function App() {
|
|||
const chatDraftsRef = useRef(new Map<string, string>())
|
||||
const chatScrollTopByTabRef = useRef(new Map<string, number>())
|
||||
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
|
||||
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
|
||||
const activeChatTabIdRef = useRef(activeChatTabId)
|
||||
activeChatTabIdRef.current = activeChatTabId
|
||||
const setChatDraftForTab = useCallback((tabId: string, text: string) => {
|
||||
|
|
@ -866,6 +868,18 @@ function App() {
|
|||
}
|
||||
})
|
||||
}, [])
|
||||
const setChatViewportAnchor = useCallback((tabId: string, messageId: string | null) => {
|
||||
setChatViewportAnchorByTab((prev) => {
|
||||
const prevForTab = prev[tabId]
|
||||
return {
|
||||
...prev,
|
||||
[tabId]: {
|
||||
messageId,
|
||||
requestKey: (prevForTab?.requestKey ?? 0) + 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
const getChatScrollContainer = useCallback((tabId: string): HTMLElement | null => {
|
||||
if (typeof document === 'undefined') return null
|
||||
const panel = document.querySelector<HTMLElement>(
|
||||
|
|
@ -967,6 +981,22 @@ function App() {
|
|||
})
|
||||
}, [chatTabs])
|
||||
|
||||
useEffect(() => {
|
||||
const tabIds = new Set(chatTabs.map((tab) => tab.id))
|
||||
setChatViewportAnchorByTab((prev) => {
|
||||
let changed = false
|
||||
const next: Record<string, ChatViewportAnchorState> = {}
|
||||
for (const [tabId, state] of Object.entries(prev)) {
|
||||
if (tabIds.has(tabId)) {
|
||||
next[tabId] = state
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [chatTabs])
|
||||
|
||||
// Workspace root for full paths
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
|
||||
|
||||
|
|
@ -2095,6 +2125,7 @@ function App() {
|
|||
) => {
|
||||
if (isProcessing) return
|
||||
|
||||
const submitTabId = activeChatTabIdRef.current
|
||||
const { text } = message
|
||||
const userMessage = text.trim()
|
||||
const hasAttachments = stagedAttachments.length > 0
|
||||
|
|
@ -2119,6 +2150,7 @@ function App() {
|
|||
attachments: displayAttachments,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
setChatViewportAnchor(submitTabId, userMessageId)
|
||||
|
||||
try {
|
||||
let currentRunId = runId
|
||||
|
|
@ -2133,7 +2165,7 @@ function App() {
|
|||
setRunId(currentRunId)
|
||||
// Update active chat tab's runId to the new run
|
||||
setChatTabs((prev) => prev.map((tab) => (
|
||||
tab.id === activeChatTabId
|
||||
tab.id === submitTabId
|
||||
? { ...tab, runId: currentRunId }
|
||||
: tab
|
||||
)))
|
||||
|
|
@ -2329,11 +2361,12 @@ function App() {
|
|||
setAllPermissionRequests(new Map())
|
||||
setPermissionResponses(new Map())
|
||||
setSelectedBackgroundTask(null)
|
||||
setChatViewportAnchor(activeChatTabIdRef.current, null)
|
||||
setChatViewStateByTab(prev => ({
|
||||
...prev,
|
||||
[activeChatTabIdRef.current]: createEmptyChatTabViewState(),
|
||||
}))
|
||||
}, [])
|
||||
}, [setChatViewportAnchor])
|
||||
|
||||
// Chat tab operations
|
||||
const applyChatTab = useCallback((tab: ChatTab) => {
|
||||
|
|
@ -2351,8 +2384,9 @@ function App() {
|
|||
setPendingAskHumanRequests(new Map())
|
||||
setAllPermissionRequests(new Map())
|
||||
setPermissionResponses(new Map())
|
||||
setChatViewportAnchor(tab.id, null)
|
||||
}
|
||||
}, [loadRun])
|
||||
}, [loadRun, setChatViewportAnchor])
|
||||
|
||||
const restoreChatTabState = useCallback((tabId: string, fallbackRunId: string | null): boolean => {
|
||||
const cached = chatViewStateByTabRef.current[tabId]
|
||||
|
|
@ -3779,7 +3813,7 @@ function App() {
|
|||
if (item.role === 'user') {
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
|
|
@ -3791,7 +3825,7 @@ function App() {
|
|||
}
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
|
|
@ -3811,7 +3845,7 @@ function App() {
|
|||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
|
||||
</MessageContent>
|
||||
|
|
@ -3875,7 +3909,7 @@ function App() {
|
|||
|
||||
if (isErrorMessage(item)) {
|
||||
return (
|
||||
<Message key={item.id} from="assistant">
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">{item.message}</pre>
|
||||
</MessageContent>
|
||||
|
|
@ -4321,8 +4355,11 @@ function App() {
|
|||
data-chat-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<ScrollPositionPreserver />
|
||||
<Conversation
|
||||
anchorMessageId={chatViewportAnchorByTab[tab.id]?.messageId}
|
||||
anchorRequestKey={chatViewportAnchorByTab[tab.id]?.requestKey}
|
||||
className="relative flex-1"
|
||||
>
|
||||
<ConversationContent className={tabConversationContentClassName}>
|
||||
{!tabHasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
|
|
@ -4384,6 +4421,7 @@ function App() {
|
|||
</>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -4460,6 +4498,7 @@ function App() {
|
|||
conversation={conversation}
|
||||
currentAssistantMessage={currentAssistantMessage}
|
||||
chatTabStates={chatViewStateByTab}
|
||||
viewportAnchors={chatViewportAnchorByTab}
|
||||
isProcessing={isProcessing}
|
||||
isStopping={isStopping}
|
||||
onStop={handleStop}
|
||||
|
|
|
|||
|
|
@ -3,163 +3,254 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import type { ComponentProps, ReactNode, RefObject } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// Context to share scroll preservation state
|
||||
interface ScrollPreservationContextValue {
|
||||
registerScrollContainer: (container: HTMLElement | null) => void;
|
||||
markUserEngaged: () => void;
|
||||
resetEngagement: () => void;
|
||||
const BOTTOM_THRESHOLD_PX = 8;
|
||||
const MAX_ANCHOR_RETRIES = 6;
|
||||
|
||||
interface ConversationContextValue {
|
||||
contentRef: RefObject<HTMLDivElement | null>;
|
||||
isAtBottom: boolean;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
const ScrollPreservationContext = createContext<ScrollPreservationContextValue | null>(null);
|
||||
const ConversationContext = createContext<ConversationContextValue | null>(null);
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
|
||||
export type ConversationProps = ComponentProps<"div"> & {
|
||||
anchorMessageId?: string | null;
|
||||
anchorRequestKey?: number;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Conversation = ({ className, children, ...props }: ConversationProps) => {
|
||||
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null);
|
||||
const isUserEngagedRef = useRef(false);
|
||||
const savedScrollTopRef = useRef<number>(0);
|
||||
const lastScrollHeightRef = useRef<number>(0);
|
||||
export const Conversation = ({
|
||||
anchorMessageId = null,
|
||||
anchorRequestKey,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ConversationProps) => {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const spacerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
const contextValue: ScrollPreservationContextValue = {
|
||||
registerScrollContainer: (container) => {
|
||||
setScrollContainer(container);
|
||||
},
|
||||
markUserEngaged: () => {
|
||||
// Only save position on first engagement, not on repeated calls
|
||||
if (!isUserEngagedRef.current && scrollContainer) {
|
||||
savedScrollTopRef.current = scrollContainer.scrollTop;
|
||||
lastScrollHeightRef.current = scrollContainer.scrollHeight;
|
||||
const updateBottomState = useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD_PX);
|
||||
}, []);
|
||||
|
||||
const applyAnchorLayout = useCallback(
|
||||
(scrollToAnchor: boolean): boolean => {
|
||||
const container = scrollRef.current;
|
||||
const content = contentRef.current;
|
||||
const spacer = spacerRef.current;
|
||||
|
||||
if (!container || !content || !spacer) {
|
||||
return false;
|
||||
}
|
||||
isUserEngagedRef.current = true;
|
||||
},
|
||||
resetEngagement: () => {
|
||||
isUserEngagedRef.current = false;
|
||||
},
|
||||
};
|
||||
|
||||
// Watch for content changes and restore scroll position if user was engaged
|
||||
if (!anchorMessageId) {
|
||||
spacer.style.height = "0px";
|
||||
updateBottomState();
|
||||
return true;
|
||||
}
|
||||
|
||||
const anchor = content.querySelector<HTMLElement>(
|
||||
`[data-message-id="${anchorMessageId}"]`
|
||||
);
|
||||
|
||||
if (!anchor) {
|
||||
spacer.style.height = "0px";
|
||||
updateBottomState();
|
||||
return false;
|
||||
}
|
||||
|
||||
spacer.style.height = "0px";
|
||||
|
||||
const contentPaddingTop = Number.parseFloat(
|
||||
window.getComputedStyle(content).paddingTop || "0"
|
||||
);
|
||||
const anchorTop = anchor.offsetTop;
|
||||
const targetScrollTop = Math.max(0, anchorTop - contentPaddingTop);
|
||||
const requiredSlack = Math.max(
|
||||
0,
|
||||
targetScrollTop - (content.scrollHeight - container.clientHeight)
|
||||
);
|
||||
|
||||
spacer.style.height = `${Math.ceil(requiredSlack)}px`;
|
||||
|
||||
if (scrollToAnchor) {
|
||||
container.scrollTop = targetScrollTop;
|
||||
}
|
||||
|
||||
updateBottomState();
|
||||
return true;
|
||||
},
|
||||
[anchorMessageId, updateBottomState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
updateBottomState();
|
||||
};
|
||||
|
||||
handleScroll();
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [updateBottomState]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!container || !content) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const checkAndRestoreScroll = () => {
|
||||
if (!isUserEngagedRef.current) return;
|
||||
|
||||
const currentScrollTop = scrollContainer.scrollTop;
|
||||
const currentScrollHeight = scrollContainer.scrollHeight;
|
||||
const savedScrollTop = savedScrollTopRef.current;
|
||||
|
||||
// If scroll position jumped significantly (auto-scroll happened)
|
||||
// and scroll height also changed (content changed), restore position
|
||||
if (
|
||||
Math.abs(currentScrollTop - savedScrollTop) > 50 &&
|
||||
currentScrollHeight !== lastScrollHeightRef.current
|
||||
) {
|
||||
scrollContainer.scrollTop = savedScrollTop;
|
||||
const schedule = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
|
||||
lastScrollHeightRef.current = currentScrollHeight;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
applyAnchorLayout(false);
|
||||
});
|
||||
};
|
||||
|
||||
// Use ResizeObserver to detect content changes
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(checkAndRestoreScroll);
|
||||
});
|
||||
|
||||
resizeObserver.observe(scrollContainer);
|
||||
const observer = new ResizeObserver(schedule);
|
||||
observer.observe(container);
|
||||
observer.observe(content);
|
||||
schedule();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
observer.disconnect();
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [scrollContainer]);
|
||||
}, [applyAnchorLayout]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (anchorRequestKey === undefined) return;
|
||||
|
||||
let attempts = 0;
|
||||
let rafId: number | null = null;
|
||||
|
||||
const tryAnchor = () => {
|
||||
if (applyAnchorLayout(true)) {
|
||||
return;
|
||||
}
|
||||
if (attempts >= MAX_ANCHOR_RETRIES) {
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
rafId = requestAnimationFrame(tryAnchor);
|
||||
};
|
||||
|
||||
tryAnchor();
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [anchorRequestKey, applyAnchorLayout]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
updateBottomState();
|
||||
}, [updateBottomState]);
|
||||
|
||||
const contextValue = useMemo<ConversationContextValue>(
|
||||
() => ({
|
||||
contentRef,
|
||||
isAtBottom,
|
||||
scrollRef,
|
||||
scrollToBottom,
|
||||
}),
|
||||
[isAtBottom, scrollToBottom]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollPreservationContext.Provider value={contextValue}>
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
<ConversationContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("relative flex-1 overflow-hidden", className)}
|
||||
role="log"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StickToBottom>
|
||||
</ScrollPreservationContext.Provider>
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto [scrollbar-gutter:stable]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{children}
|
||||
<div ref={spacerRef} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that tracks scroll engagement and preserves position.
|
||||
* Must be used inside Conversation component.
|
||||
*/
|
||||
export const ScrollPositionPreserver = () => {
|
||||
const { isAtBottom, scrollRef } = useStickToBottomContext();
|
||||
const preservationContext = useContext(ScrollPreservationContext);
|
||||
const containerFoundRef = useRef(false);
|
||||
const useConversationContext = () => {
|
||||
const context = useContext(ConversationContext);
|
||||
|
||||
// Find and register scroll container on mount
|
||||
useLayoutEffect(() => {
|
||||
if (containerFoundRef.current || !preservationContext) return;
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"Conversation components must be used within a Conversation component."
|
||||
);
|
||||
}
|
||||
|
||||
// Use the local StickToBottom scroll container for this conversation instance.
|
||||
const container = scrollRef.current;
|
||||
if (container) {
|
||||
preservationContext.registerScrollContainer(container);
|
||||
containerFoundRef.current = true;
|
||||
}
|
||||
}, [preservationContext, scrollRef]);
|
||||
|
||||
// Track engagement based on scroll position
|
||||
useEffect(() => {
|
||||
if (!preservationContext) return;
|
||||
|
||||
if (!isAtBottom) {
|
||||
// User is not at bottom - mark as engaged
|
||||
preservationContext.markUserEngaged();
|
||||
} else {
|
||||
// User is back at bottom - reset
|
||||
preservationContext.resetEngagement();
|
||||
}
|
||||
}, [isAtBottom, preservationContext]);
|
||||
|
||||
return null;
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
export type ConversationContentProps = ComponentProps<"div">;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}: ConversationContentProps) => {
|
||||
const { contentRef } = useConversationContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
ref={contentRef}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
children,
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
title = "No messages yet",
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
|
|
@ -183,13 +274,15 @@ export const ConversationEmptyState = ({
|
|||
</div>
|
||||
);
|
||||
|
||||
export const ScrollPositionPreserver = () => null;
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
const { isAtBottom, scrollToBottom } = useConversationContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
|
|
@ -199,16 +292,16 @@ export const ConversationScrollButton = ({
|
|||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
"absolute bottom-6 left-[50%] z-10 h-12 w-12 translate-x-[-50%] rounded-full border border-border/70 bg-background/95 text-foreground shadow-lg backdrop-blur-sm transition hover:bg-background",
|
||||
className
|
||||
)}
|
||||
aria-label="Scroll to latest message"
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
<ArrowDownIcon className="size-6" strokeWidth={1.75} />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ScrollPositionPreserver,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import {
|
||||
Message,
|
||||
|
|
@ -30,6 +30,7 @@ import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-
|
|||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import {
|
||||
type ChatViewportAnchorState,
|
||||
type ChatTabViewState,
|
||||
type ConversationItem,
|
||||
type PermissionResponse,
|
||||
|
|
@ -90,6 +91,7 @@ interface ChatSidebarProps {
|
|||
conversation: ConversationItem[]
|
||||
currentAssistantMessage: string
|
||||
chatTabStates?: Record<string, ChatTabViewState>
|
||||
viewportAnchors?: Record<string, ChatViewportAnchorState>
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
|
|
@ -142,6 +144,7 @@ export function ChatSidebar({
|
|||
conversation,
|
||||
currentAssistantMessage,
|
||||
chatTabStates = {},
|
||||
viewportAnchors = {},
|
||||
isProcessing,
|
||||
isStopping,
|
||||
onStop,
|
||||
|
|
@ -289,7 +292,7 @@ export function ChatSidebar({
|
|||
if (item.role === 'user') {
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
|
|
@ -301,7 +304,7 @@ export function ChatSidebar({
|
|||
}
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
|
|
@ -321,7 +324,7 @@ export function ChatSidebar({
|
|||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<Message key={item.id} from={item.role} data-message-id={item.id}>
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
|
||||
</MessageContent>
|
||||
|
|
@ -376,7 +379,7 @@ export function ChatSidebar({
|
|||
|
||||
if (isErrorMessage(item)) {
|
||||
return (
|
||||
<Message key={item.id} from="assistant">
|
||||
<Message key={item.id} from="assistant" data-message-id={item.id}>
|
||||
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">{item.message}</pre>
|
||||
</MessageContent>
|
||||
|
|
@ -485,9 +488,12 @@ export function ChatSidebar({
|
|||
)}
|
||||
data-chat-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<ScrollPositionPreserver />
|
||||
>
|
||||
<Conversation
|
||||
anchorMessageId={viewportAnchors[tab.id]?.messageId}
|
||||
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
||||
className="relative flex-1"
|
||||
>
|
||||
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
|
||||
{!tabHasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
|
|
@ -545,10 +551,11 @@ export function ChatSidebar({
|
|||
</Message>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ export type ChatTabViewState = {
|
|||
permissionResponses: Map<string, PermissionResponse>
|
||||
}
|
||||
|
||||
export type ChatViewportAnchorState = {
|
||||
messageId: string | null
|
||||
requestKey: number
|
||||
}
|
||||
|
||||
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
||||
runId: null,
|
||||
conversation: [],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue