enhance App and Chat components with improved state management for chat tabs, scroll position preservation, and active state handling; introduce new props for tool management and chat input behavior

This commit is contained in:
tusharmagar 2026-02-18 21:02:25 +05:30
parent 6495a1132a
commit df32d3f822
5 changed files with 417 additions and 223 deletions

View file

@ -458,6 +458,8 @@ function ContentHeader({
} }
function App() { function App() {
type ShortcutPane = 'left' | 'right'
// File browser state (for Knowledge section) // File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null) const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileContent, setFileContent] = useState<string>('') const [fileContent, setFileContent] = useState<string>('')
@ -478,6 +480,7 @@ function App() {
const [graphError, setGraphError] = useState<string | null>(null) const [graphError, setGraphError] = useState<string | null>(null)
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true) const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false) const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false)
const [activeShortcutPane, setActiveShortcutPane] = useState<ShortcutPane>('left')
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac')
const collapsedLeftPaddingPx = const collapsedLeftPaddingPx =
(isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0) + (isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0) +
@ -534,16 +537,55 @@ function App() {
const chatTabIdCounterRef = useRef(0) const chatTabIdCounterRef = useRef(0)
const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}` const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
const chatDraftsRef = useRef(new Map<string, string>()) const chatDraftsRef = useRef(new Map<string, string>())
const chatScrollTopByTabRef = useRef(new Map<string, number>())
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
const activeChatTabIdRef = useRef(activeChatTabId) const activeChatTabIdRef = useRef(activeChatTabId)
activeChatTabIdRef.current = activeChatTabId activeChatTabIdRef.current = activeChatTabId
const handleDraftChange = useCallback((text: string) => { const setChatDraftForTab = useCallback((tabId: string, text: string) => {
const tabId = activeChatTabIdRef.current
if (text) { if (text) {
chatDraftsRef.current.set(tabId, text) chatDraftsRef.current.set(tabId, text)
} else { } else {
chatDraftsRef.current.delete(tabId) chatDraftsRef.current.delete(tabId)
} }
}, []) }, [])
const isToolOpenForTab = useCallback((tabId: string, toolId: string): boolean => {
return toolOpenByTab[tabId]?.[toolId] ?? false
}, [toolOpenByTab])
const setToolOpenForTab = useCallback((tabId: string, toolId: string, open: boolean) => {
setToolOpenByTab((prev) => {
const prevForTab = prev[tabId] ?? {}
if (prevForTab[toolId] === open) return prev
return {
...prev,
[tabId]: {
...prevForTab,
[toolId]: open,
},
}
})
}, [])
const getChatScrollContainer = useCallback((tabId: string): HTMLElement | null => {
if (typeof document === 'undefined') return null
const panel = document.querySelector<HTMLElement>(
`[data-chat-tab-panel="${tabId}"][aria-hidden="false"]`
)
if (!panel) return null
const logRoot = panel.querySelector<HTMLElement>('[role="log"]')
if (!logRoot) return null
const children = Array.from(logRoot.children) as HTMLElement[]
for (const child of children) {
const style = window.getComputedStyle(child)
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
return child
}
}
return null
}, [])
const saveChatScrollForTab = useCallback((tabId: string) => {
const container = getChatScrollContainer(tabId)
if (!container) return
chatScrollTopByTabRef.current.set(tabId, container.scrollTop)
}, [getChatScrollContainer])
const getChatTabTitle = useCallback((tab: ChatTab) => { const getChatTabTitle = useCallback((tab: ChatTab) => {
if (!tab.runId) return 'New chat' if (!tab.runId) return 'New chat'
@ -1701,6 +1743,7 @@ function App() {
const tab = chatTabs.find(t => t.id === tabId) const tab = chatTabs.find(t => t.id === tabId)
if (!tab) return if (!tab) return
if (tabId === activeChatTabId) return if (tabId === activeChatTabId) return
saveChatScrollForTab(activeChatTabId)
setActiveChatTabId(tabId) setActiveChatTabId(tabId)
const restored = restoreChatTabState(tabId, tab.runId) const restored = restoreChatTabState(tabId, tab.runId)
if (tab.runId && processingRunIdsRef.current.has(tab.runId)) { if (tab.runId && processingRunIdsRef.current.has(tab.runId)) {
@ -1710,12 +1753,13 @@ function App() {
if (!restored) { if (!restored) {
applyChatTab(tab) applyChatTab(tab)
} }
}, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState]) }, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
const closeChatTab = useCallback((tabId: string) => { const closeChatTab = useCallback((tabId: string) => {
if (chatTabs.length <= 1) return if (chatTabs.length <= 1) return
const idx = chatTabs.findIndex(t => t.id === tabId) const idx = chatTabs.findIndex(t => t.id === tabId)
if (idx === -1) return if (idx === -1) return
saveChatScrollForTab(tabId)
const nextTabs = chatTabs.filter(t => t.id !== tabId) const nextTabs = chatTabs.filter(t => t.id !== tabId)
setChatTabs(nextTabs) setChatTabs(nextTabs)
setChatViewStateByTab(prev => { setChatViewStateByTab(prev => {
@ -1725,6 +1769,13 @@ function App() {
return next return next
}) })
chatDraftsRef.current.delete(tabId) chatDraftsRef.current.delete(tabId)
chatScrollTopByTabRef.current.delete(tabId)
setToolOpenByTab((prev) => {
if (!(tabId in prev)) return prev
const next = { ...prev }
delete next[tabId]
return next
})
if (tabId === activeChatTabId && nextTabs.length > 0) { if (tabId === activeChatTabId && nextTabs.length > 0) {
const newIdx = Math.min(idx, nextTabs.length - 1) const newIdx = Math.min(idx, nextTabs.length - 1)
@ -1737,7 +1788,81 @@ function App() {
applyChatTab(newActiveTab) applyChatTab(newActiveTab)
} }
} }
}, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState]) }, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
useEffect(() => {
let cleanupScrollListener: (() => void) | undefined
let pollRaf: number | undefined
let restoreRafA: number | undefined
let restoreRafB: number | undefined
let restoreTimeout: ReturnType<typeof setTimeout> | undefined
let cancelled = false
const restoreScrollTop = (container: HTMLElement, top: number) => {
const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight)
const clampedTop = clampNumber(top, 0, maxScroll)
container.scrollTop = clampedTop
}
const attach = (): boolean => {
if (cancelled) return true
const container = getChatScrollContainer(activeChatTabId)
if (!container) return false
const savedTop = chatScrollTopByTabRef.current.get(activeChatTabId)
if (savedTop !== undefined) {
// Reinforce restoration across a couple frames because stick-to-bottom
// may schedule scroll adjustments during mount/resize.
restoreScrollTop(container, savedTop)
restoreRafA = requestAnimationFrame(() => {
restoreScrollTop(container, savedTop)
restoreRafB = requestAnimationFrame(() => {
restoreScrollTop(container, savedTop)
})
})
restoreTimeout = setTimeout(() => {
restoreScrollTop(container, savedTop)
}, 220)
}
const onScroll = () => {
chatScrollTopByTabRef.current.set(activeChatTabId, container.scrollTop)
}
container.addEventListener('scroll', onScroll, { passive: true })
cleanupScrollListener = () => {
chatScrollTopByTabRef.current.set(activeChatTabId, container.scrollTop)
container.removeEventListener('scroll', onScroll)
}
return true
}
let attempts = 0
const maxAttempts = 60
const pollAttach = () => {
if (cancelled) return
if (attach()) return
if (attempts >= maxAttempts) return
attempts += 1
pollRaf = requestAnimationFrame(pollAttach)
}
pollAttach()
return () => {
cancelled = true
cleanupScrollListener?.()
if (pollRaf !== undefined) cancelAnimationFrame(pollRaf)
if (restoreRafA !== undefined) cancelAnimationFrame(restoreRafA)
if (restoreRafB !== undefined) cancelAnimationFrame(restoreRafB)
if (restoreTimeout !== undefined) clearTimeout(restoreTimeout)
}
}, [
activeChatTabId,
selectedPath,
isGraphOpen,
isChatSidebarOpen,
isRightPaneMaximized,
getChatScrollContainer,
])
// File tab operations // File tab operations
const openFileInNewTab = useCallback((path: string) => { const openFileInNewTab = useCallback((path: string) => {
@ -2086,13 +2211,18 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => { const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey const mod = e.metaKey || e.ctrlKey
if (!mod) return if (!mod) return
const inFileView = Boolean(selectedPath) const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen)
const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath)
const targetFileTabId = activeFileTabId ?? fileTabs.find((tab) => tab.path === selectedPath)?.id ?? null
// Cmd+W — close active tab // Cmd+W — close active tab
if (e.key === 'w') { if (e.key === 'w') {
e.preventDefault() e.preventDefault()
if (inFileView && activeFileTabId) { if (inFileView && targetFileTabId) {
closeFileTab(activeFileTabId) closeFileTab(targetFileTabId)
} else { } else {
closeChatTab(activeChatTabId) closeChatTab(activeChatTabId)
} }
@ -2120,7 +2250,7 @@ function App() {
e.preventDefault() e.preventDefault()
const direction = e.key === ']' ? 1 : -1 const direction = e.key === ']' ? 1 : -1
if (inFileView) { if (inFileView) {
const currentIdx = fileTabs.findIndex(t => t.id === activeFileTabId) const currentIdx = fileTabs.findIndex(t => t.id === targetFileTabId)
if (currentIdx === -1) return if (currentIdx === -1) return
const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length
switchFileTab(fileTabs[nextIdx].id) switchFileTab(fileTabs[nextIdx].id)
@ -2135,7 +2265,7 @@ function App() {
} }
document.addEventListener('keydown', handleTabKeyDown) document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) }, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
const toggleExpand = (path: string, kind: 'file' | 'dir') => { const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') { if (kind === 'file') {
@ -2496,7 +2626,7 @@ function App() {
} }
}, [isGraphOpen, knowledgeFilePaths]) }, [isGraphOpen, knowledgeFilePaths])
const renderConversationItem = (item: ConversationItem) => { const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
if (item.role === 'user') { if (item.role === 'user') {
const { message, files } = parseAttachedFiles(item.content) const { message, files } = parseAttachedFiles(item.content)
@ -2567,7 +2697,11 @@ function App() {
const output = normalizeToolOutput(item.result, item.status) const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input) const input = normalizeToolInput(item.input)
return ( return (
<Tool key={item.id}> <Tool
key={item.id}
open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
>
<ToolHeader <ToolHeader
title={item.name} title={item.name}
type={`tool-${item.name}`} type={`tool-${item.name}`}
@ -2719,7 +2853,11 @@ function App() {
selectedBackgroundTask={selectedBackgroundTask} selectedBackgroundTask={selectedBackgroundTask}
/> />
{!isRightPaneOnlyMode && ( {!isRightPaneOnlyMode && (
<SidebarInset className="overflow-hidden! min-h-0 min-w-0"> <SidebarInset
className="overflow-hidden! min-h-0 min-w-0"
onMouseDownCapture={() => setActiveShortcutPane('left')}
onFocusCapture={() => setActiveShortcutPane('left')}
>
{/* Header - also serves as titlebar drag region, adjusts padding when sidebar collapsed */} {/* Header - also serves as titlebar drag region, adjusts padding when sidebar collapsed */}
<ContentHeader <ContentHeader
onNavigateBack={() => { void navigateBack() }} onNavigateBack={() => { void navigateBack() }}
@ -2881,85 +3019,92 @@ function App() {
) : ( ) : (
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}> <FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
{chatTabs.map((tab) => { <div className="relative min-h-0 flex-1">
const isActive = tab.id === activeChatTabId {chatTabs.map((tab) => {
const tabState = getChatTabStateForRender(tab.id) const isActive = tab.id === activeChatTabId
const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage const tabState = getChatTabStateForRender(tab.id)
const tabConversationContentClassName = tabHasConversation const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
? "mx-auto w-full max-w-4xl pb-28" const tabConversationContentClassName = tabHasConversation
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" ? "mx-auto w-full max-w-4xl pb-28"
return ( : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
<div return (
key={tab.id} <div
className={cn('min-h-0 flex-1 flex-col', isActive ? 'flex' : 'hidden')} key={tab.id}
data-chat-tab-panel={tab.id} className={cn(
aria-hidden={!isActive} 'min-h-0 h-full flex-col',
> isActive
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]"> ? 'flex'
<ScrollPositionPreserver /> : 'pointer-events-none invisible absolute inset-0 flex'
<ConversationContent className={tabConversationContentClassName}> )}
{!tabHasConversation ? ( data-chat-tab-panel={tab.id}
<ConversationEmptyState className="h-auto"> aria-hidden={!isActive}
<div className="text-2xl font-semibold tracking-tight text-foreground/80 sm:text-3xl md:text-4xl"> >
What are we working on? <Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
</div> <ScrollPositionPreserver />
</ConversationEmptyState> <ConversationContent className={tabConversationContentClassName}>
) : ( {!tabHasConversation ? (
<> <ConversationEmptyState className="h-auto">
{tabState.conversation.map(item => { <div className="text-2xl font-semibold tracking-tight text-foreground/80 sm:text-3xl md:text-4xl">
const rendered = renderConversationItem(item) What are we working on?
if (isToolCall(item)) { </div>
const permRequest = tabState.allPermissionRequests.get(item.id) </ConversationEmptyState>
if (permRequest) { ) : (
const response = tabState.permissionResponses.get(item.id) || null <>
return ( {tabState.conversation.map(item => {
<React.Fragment key={item.id}> const rendered = renderConversationItem(item, tab.id)
{rendered} if (isToolCall(item)) {
<PermissionRequest const permRequest = tabState.allPermissionRequests.get(item.id)
toolCall={permRequest.toolCall} if (permRequest) {
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} const response = tabState.permissionResponses.get(item.id) || null
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} return (
isProcessing={isActive && isProcessing} <React.Fragment key={item.id}>
response={response} {rendered}
/> <PermissionRequest
</React.Fragment> toolCall={permRequest.toolCall}
) onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
</React.Fragment>
)
}
} }
} return rendered
return rendered })}
})}
{Array.from(tabState.pendingAskHumanRequests.values()).map((request) => ( {Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest <AskHumanRequest
key={request.toolCallId} key={request.toolCallId}
query={request.query} query={request.query}
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)} onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isActive && isProcessing} isProcessing={isActive && isProcessing}
/> />
))} ))}
{tabState.currentAssistantMessage && ( {tabState.currentAssistantMessage && (
<Message from="assistant"> <Message from="assistant">
<MessageContent> <MessageContent>
<MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse> <MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse>
</MessageContent> </MessageContent>
</Message> </Message>
)} )}
{isActive && isProcessing && !tabState.currentAssistantMessage && ( {isActive && isProcessing && !tabState.currentAssistantMessage && (
<Message from="assistant"> <Message from="assistant">
<MessageContent> <MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer> <Shimmer duration={1}>Thinking...</Shimmer>
</MessageContent> </MessageContent>
</Message> </Message>
)} )}
</> </>
)} )}
</ConversationContent> </ConversationContent>
</Conversation> </Conversation>
</div> </div>
) )
})} })}
</div>
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg"> <div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" /> <div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
@ -2967,21 +3112,34 @@ function App() {
{!hasConversation && ( {!hasConversation && (
<Suggestions onSelect={setPresetMessage} className="mb-3 justify-center" /> <Suggestions onSelect={setPresetMessage} className="mb-3 justify-center" />
)} )}
<ChatInputWithMentions {chatTabs.map((tab) => {
key={activeChatTabId} const isActive = tab.id === activeChatTabId
knowledgeFiles={knowledgeFiles} const tabState = getChatTabStateForRender(tab.id)
recentFiles={recentWikiFiles} return (
visibleFiles={visibleKnowledgeFiles} <div
onSubmit={handlePromptSubmit} key={tab.id}
onStop={handleStop} className={isActive ? 'block' : 'hidden'}
isProcessing={isProcessing} data-chat-input-panel={tab.id}
isStopping={isStopping} aria-hidden={!isActive}
presetMessage={presetMessage} >
onPresetMessageConsumed={() => setPresetMessage(undefined)} <ChatInputWithMentions
runId={runId} knowledgeFiles={knowledgeFiles}
initialDraft={chatDraftsRef.current.get(activeChatTabId)} recentFiles={recentWikiFiles}
onDraftChange={handleDraftChange} visibleFiles={visibleKnowledgeFiles}
/> onSubmit={handlePromptSubmit}
onStop={handleStop}
isProcessing={isActive && isProcessing}
isStopping={isActive && isStopping}
isActive={isActive}
presetMessage={isActive ? presetMessage : undefined}
onPresetMessageConsumed={isActive ? () => setPresetMessage(undefined) : undefined}
runId={tabState.runId}
initialDraft={chatDraftsRef.current.get(tab.id)}
onDraftChange={(text) => setChatDraftForTab(tab.id, text)}
/>
</div>
)
})}
</div> </div>
</div> </div>
</div> </div>
@ -3017,14 +3175,17 @@ function App() {
runId={runId} runId={runId}
presetMessage={presetMessage} presetMessage={presetMessage}
onPresetMessageConsumed={() => setPresetMessage(undefined)} onPresetMessageConsumed={() => setPresetMessage(undefined)}
initialDraft={chatDraftsRef.current.get(activeChatTabId)} getInitialDraft={(tabId) => chatDraftsRef.current.get(tabId)}
onDraftChange={handleDraftChange} onDraftChangeForTab={setChatDraftForTab}
pendingAskHumanRequests={pendingAskHumanRequests} pendingAskHumanRequests={pendingAskHumanRequests}
allPermissionRequests={allPermissionRequests} allPermissionRequests={allPermissionRequests}
permissionResponses={permissionResponses} permissionResponses={permissionResponses}
onPermissionResponse={handlePermissionResponse} onPermissionResponse={handlePermissionResponse}
onAskHumanResponse={handleAskHumanResponse} onAskHumanResponse={handleAskHumanResponse}
isToolOpenForTab={isToolOpenForTab}
onToolOpenChangeForTab={setToolOpenForTab}
onOpenKnowledgeFile={(path) => { navigateToFile(path) }} onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
onActivate={() => setActiveShortcutPane('right')}
/> />
)} )}
{/* Rendered last so its no-drag region paints over the sidebar drag region */} {/* Rendered last so its no-drag region paints over the sidebar drag region */}

View file

@ -102,7 +102,7 @@ export const Conversation = ({ className, children, ...props }: ConversationProp
* Must be used inside Conversation component. * Must be used inside Conversation component.
*/ */
export const ScrollPositionPreserver = () => { export const ScrollPositionPreserver = () => {
const { isAtBottom } = useStickToBottomContext(); const { isAtBottom, scrollRef } = useStickToBottomContext();
const preservationContext = useContext(ScrollPreservationContext); const preservationContext = useContext(ScrollPreservationContext);
const containerFoundRef = useRef(false); const containerFoundRef = useRef(false);
@ -110,29 +110,13 @@ export const ScrollPositionPreserver = () => {
useLayoutEffect(() => { useLayoutEffect(() => {
if (containerFoundRef.current || !preservationContext) return; if (containerFoundRef.current || !preservationContext) return;
// Find the scroll container (StickToBottom creates one) // Use the local StickToBottom scroll container for this conversation instance.
// It's the first parent with overflow-y scroll/auto const container = scrollRef.current;
const findScrollContainer = (): HTMLElement | null => {
const candidates = document.querySelectorAll('[role="log"]');
for (const candidate of candidates) {
// The scroll container is a direct child of the role="log" element
const children = candidate.children;
for (const child of children) {
const style = window.getComputedStyle(child);
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
return child as HTMLElement;
}
}
}
return null;
};
const container = findScrollContainer();
if (container) { if (container) {
preservationContext.registerScrollContainer(container); preservationContext.registerScrollContainer(container);
containerFoundRef.current = true; containerFoundRef.current = true;
} }
}, [preservationContext]); }, [preservationContext, scrollRef]);
// Track engagement based on scroll position // Track engagement based on scroll position
useEffect(() => { useEffect(() => {

View file

@ -931,7 +931,13 @@ export const PromptInputTextarea = ({
if (autoFocus || focusTrigger !== undefined) { if (autoFocus || focusTrigger !== undefined) {
// Small delay to ensure the element is fully mounted and visible // Small delay to ensure the element is fully mounted and visible
const timer = setTimeout(() => { const timer = setTimeout(() => {
textareaRef.current?.focus(); const textarea = textareaRef.current;
if (!textarea) return;
try {
textarea.focus({ preventScroll: true });
} catch {
textarea.focus();
}
}, 50); }, 50);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }

View file

@ -16,6 +16,7 @@ interface ChatInputInnerProps {
onStop?: () => void onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
isActive: boolean
presetMessage?: string presetMessage?: string
onPresetMessageConsumed?: () => void onPresetMessageConsumed?: () => void
runId?: string | null runId?: string | null
@ -28,6 +29,7 @@ function ChatInputInner({
onStop, onStop,
isProcessing, isProcessing,
isStopping, isStopping,
isActive,
presetMessage, presetMessage,
onPresetMessageConsumed, onPresetMessageConsumed,
runId, runId,
@ -72,6 +74,7 @@ function ChatInputInner({
}, [handleSubmit]) }, [handleSubmit])
useEffect(() => { useEffect(() => {
if (!isActive) return
const onDragOver = (e: DragEvent) => { const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) { if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault() e.preventDefault()
@ -100,15 +103,15 @@ function ChatInputInner({
document.removeEventListener('dragover', onDragOver) document.removeEventListener('dragover', onDragOver)
document.removeEventListener('drop', onDrop) document.removeEventListener('drop', onDrop)
} }
}, [controller]) }, [controller, isActive])
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-4 shadow-none"> <div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-4 shadow-none">
<PromptInputTextarea <PromptInputTextarea
placeholder="Type your message..." placeholder="Type your message..."
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoFocus autoFocus={isActive}
focusTrigger={runId} focusTrigger={isActive ? runId : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0" className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/> />
{isProcessing ? ( {isProcessing ? (
@ -156,6 +159,7 @@ export interface ChatInputWithMentionsProps {
onStop?: () => void onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
isActive?: boolean
presetMessage?: string presetMessage?: string
onPresetMessageConsumed?: () => void onPresetMessageConsumed?: () => void
runId?: string | null runId?: string | null
@ -171,6 +175,7 @@ export function ChatInputWithMentions({
onStop, onStop,
isProcessing, isProcessing,
isStopping, isStopping,
isActive = true,
presetMessage, presetMessage,
onPresetMessageConsumed, onPresetMessageConsumed,
runId, runId,
@ -184,6 +189,7 @@ export function ChatInputWithMentions({
onStop={onStop} onStop={onStop}
isProcessing={isProcessing} isProcessing={isProcessing}
isStopping={isStopping} isStopping={isStopping}
isActive={isActive}
presetMessage={presetMessage} presetMessage={presetMessage}
onPresetMessageConsumed={onPresetMessageConsumed} onPresetMessageConsumed={onPresetMessageConsumed}
runId={runId} runId={runId}

View file

@ -55,6 +55,7 @@ interface ErrorMessage {
type ConversationItem = ChatMessage | ToolCall | ErrorMessage type ConversationItem = ChatMessage | ToolCall | ErrorMessage
type ChatTabViewState = { type ChatTabViewState = {
runId: string | null
conversation: ConversationItem[] conversation: ConversationItem[]
currentAssistantMessage: string currentAssistantMessage: string
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>> pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
@ -160,14 +161,17 @@ interface ChatSidebarProps {
runId?: string | null runId?: string | null
presetMessage?: string presetMessage?: string
onPresetMessageConsumed?: () => void onPresetMessageConsumed?: () => void
initialDraft?: string getInitialDraft?: (tabId: string) => string | undefined
onDraftChange?: (text: string) => void onDraftChangeForTab?: (tabId: string, text: string) => void
pendingAskHumanRequests?: Map<string, z.infer<typeof AskHumanRequestEvent>> pendingAskHumanRequests?: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>> allPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses?: Map<string, 'approve' | 'deny'> permissionResponses?: Map<string, 'approve' | 'deny'>
onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
onOpenKnowledgeFile?: (path: string) => void onOpenKnowledgeFile?: (path: string) => void
onActivate?: () => void
} }
export function ChatSidebar({ export function ChatSidebar({
@ -195,14 +199,17 @@ export function ChatSidebar({
runId, runId,
presetMessage, presetMessage,
onPresetMessageConsumed, onPresetMessageConsumed,
initialDraft, getInitialDraft,
onDraftChange, onDraftChangeForTab,
pendingAskHumanRequests = new Map(), pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(), allPermissionRequests = new Map(),
permissionResponses = new Map(), permissionResponses = new Map(),
onPermissionResponse, onPermissionResponse,
onAskHumanResponse, onAskHumanResponse,
isToolOpenForTab,
onToolOpenChangeForTab,
onOpenKnowledgeFile, onOpenKnowledgeFile,
onActivate,
}: ChatSidebarProps) { }: ChatSidebarProps) {
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth)) const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
@ -284,12 +291,14 @@ export function ChatSidebar({
}, [width, getMaxAllowedWidth]) }, [width, getMaxAllowedWidth])
const activeTabState = useMemo<ChatTabViewState>(() => ({ const activeTabState = useMemo<ChatTabViewState>(() => ({
runId: runId ?? null,
conversation, conversation,
currentAssistantMessage, currentAssistantMessage,
pendingAskHumanRequests, pendingAskHumanRequests,
allPermissionRequests, allPermissionRequests,
permissionResponses, permissionResponses,
}), [ }), [
runId,
conversation, conversation,
currentAssistantMessage, currentAssistantMessage,
pendingAskHumanRequests, pendingAskHumanRequests,
@ -297,6 +306,7 @@ export function ChatSidebar({
permissionResponses, permissionResponses,
]) ])
const emptyTabState = useMemo<ChatTabViewState>(() => ({ const emptyTabState = useMemo<ChatTabViewState>(() => ({
runId: null,
conversation: [], conversation: [],
currentAssistantMessage: '', currentAssistantMessage: '',
pendingAskHumanRequests: new Map(), pendingAskHumanRequests: new Map(),
@ -309,7 +319,7 @@ export function ChatSidebar({
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState]) }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage) const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
const renderConversationItem = (item: ConversationItem) => { const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
return ( return (
<Message key={item.id} from={item.role}> <Message key={item.id} from={item.role}>
@ -329,7 +339,11 @@ export function ChatSidebar({
const output = normalizeToolOutput(item.result, item.status) const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input) const input = normalizeToolInput(item.input)
return ( return (
<Tool key={item.id}> <Tool
key={item.id}
open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
>
<ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} /> <ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent> <ToolContent>
<ToolInput input={input} /> <ToolInput input={input} />
@ -367,6 +381,8 @@ export function ChatSidebar({
return ( return (
<div <div
ref={paneRef} ref={paneRef}
onMouseDownCapture={onActivate}
onFocusCapture={onActivate}
className={cn( className={cn(
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background', 'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
!isResizing && 'transition-[width] duration-200 ease-linear' !isResizing && 'transition-[width] duration-200 ease-linear'
@ -418,12 +434,13 @@ export function ChatSidebar({
size="icon" size="icon"
onClick={onOpenFullScreen} onClick={onOpenFullScreen}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
> >
{isMaximized ? <Shrink className="size-5" /> : <Expand className="size-5" />} {isMaximized ? <Shrink className="size-5" /> : <Expand className="size-5" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
{isMaximized ? 'Restore two-pane view' : 'Maximize right pane'} {isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
@ -431,80 +448,87 @@ export function ChatSidebar({
<FileCardProvider onOpenKnowledgeFile={onOpenKnowledgeFile ?? (() => {})}> <FileCardProvider onOpenKnowledgeFile={onOpenKnowledgeFile ?? (() => {})}>
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
{chatTabs.map((tab) => { <div className="relative min-h-0 flex-1">
const isActive = tab.id === activeChatTabId {chatTabs.map((tab) => {
const tabState = getTabState(tab.id) const isActive = tab.id === activeChatTabId
const tabHasConversation = tabState.conversation.length > 0 || Boolean(tabState.currentAssistantMessage) const tabState = getTabState(tab.id)
return ( const tabHasConversation = tabState.conversation.length > 0 || Boolean(tabState.currentAssistantMessage)
<div return (
key={tab.id} <div
className={cn('min-h-0 flex-1 flex-col', isActive ? 'flex' : 'hidden')} key={tab.id}
data-chat-tab-panel={tab.id} className={cn(
aria-hidden={!isActive} 'min-h-0 h-full flex-col',
> isActive
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]"> ? 'flex'
<ScrollPositionPreserver /> : 'pointer-events-none invisible absolute inset-0 flex'
<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 ? ( data-chat-tab-panel={tab.id}
<ConversationEmptyState className="h-auto"> aria-hidden={!isActive}
<div className="text-sm text-muted-foreground">Ask anything...</div> >
</ConversationEmptyState> <Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
) : ( <ScrollPositionPreserver />
<> <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'}>
{tabState.conversation.map((item) => { {!tabHasConversation ? (
const rendered = renderConversationItem(item) <ConversationEmptyState className="h-auto">
if (isToolCall(item) && onPermissionResponse) { <div className="text-sm text-muted-foreground">Ask anything...</div>
const permRequest = tabState.allPermissionRequests.get(item.id) </ConversationEmptyState>
if (permRequest) { ) : (
const response = tabState.permissionResponses.get(item.id) || null <>
return ( {tabState.conversation.map((item) => {
<React.Fragment key={item.id}> const rendered = renderConversationItem(item, tab.id)
{rendered} if (isToolCall(item) && onPermissionResponse) {
<PermissionRequest const permRequest = tabState.allPermissionRequests.get(item.id)
toolCall={permRequest.toolCall} if (permRequest) {
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} const response = tabState.permissionResponses.get(item.id) || null
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} return (
isProcessing={isActive && isProcessing} <React.Fragment key={item.id}>
response={response} {rendered}
/> <PermissionRequest
</React.Fragment> toolCall={permRequest.toolCall}
) onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isActive && isProcessing}
response={response}
/>
</React.Fragment>
)
}
} }
} return rendered
return rendered })}
})}
{onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => ( {onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest <AskHumanRequest
key={request.toolCallId} key={request.toolCallId}
query={request.query} query={request.query}
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)} onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isActive && isProcessing} isProcessing={isActive && isProcessing}
/> />
))} ))}
{tabState.currentAssistantMessage && ( {tabState.currentAssistantMessage && (
<Message from="assistant"> <Message from="assistant">
<MessageContent> <MessageContent>
<MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse> <MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse>
</MessageContent> </MessageContent>
</Message> </Message>
)} )}
{isActive && isProcessing && !tabState.currentAssistantMessage && ( {isActive && isProcessing && !tabState.currentAssistantMessage && (
<Message from="assistant"> <Message from="assistant">
<MessageContent> <MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer> <Shimmer duration={1}>Thinking...</Shimmer>
</MessageContent> </MessageContent>
</Message> </Message>
)} )}
</> </>
)} )}
</ConversationContent> </ConversationContent>
</Conversation> </Conversation>
</div> </div>
) )
})} })}
</div>
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg"> <div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" /> <div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
@ -512,24 +536,37 @@ export function ChatSidebar({
{!hasConversation && ( {!hasConversation && (
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" /> <Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
)} )}
<ChatInputWithMentions {chatTabs.map((tab) => {
key={activeChatTabId} const isActive = tab.id === activeChatTabId
knowledgeFiles={knowledgeFiles} const tabState = getTabState(tab.id)
recentFiles={recentFiles} return (
visibleFiles={visibleFiles} <div
onSubmit={onSubmit} key={tab.id}
onStop={onStop} className={isActive ? 'block' : 'hidden'}
isProcessing={isProcessing} data-chat-input-panel={tab.id}
isStopping={isStopping} aria-hidden={!isActive}
presetMessage={localPresetMessage ?? presetMessage} >
onPresetMessageConsumed={() => { <ChatInputWithMentions
setLocalPresetMessage(undefined) knowledgeFiles={knowledgeFiles}
onPresetMessageConsumed?.() recentFiles={recentFiles}
}} visibleFiles={visibleFiles}
runId={runId} onSubmit={onSubmit}
initialDraft={initialDraft} onStop={onStop}
onDraftChange={onDraftChange} isProcessing={isActive && isProcessing}
/> isStopping={isActive && isStopping}
isActive={isActive}
presetMessage={isActive ? (localPresetMessage ?? presetMessage) : undefined}
onPresetMessageConsumed={isActive ? () => {
setLocalPresetMessage(undefined)
onPresetMessageConsumed?.()
} : undefined}
runId={tabState.runId}
initialDraft={getInitialDraft?.(tab.id)}
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
/>
</div>
)
})}
</div> </div>
</div> </div>
</div> </div>