mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-28 19:05:31 +02:00
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:
parent
6495a1132a
commit
df32d3f822
5 changed files with 417 additions and 223 deletions
|
|
@ -458,6 +458,8 @@ function ContentHeader({
|
|||
}
|
||||
|
||||
function App() {
|
||||
type ShortcutPane = 'left' | 'right'
|
||||
|
||||
// File browser state (for Knowledge section)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string>('')
|
||||
|
|
@ -478,6 +480,7 @@ function App() {
|
|||
const [graphError, setGraphError] = useState<string | null>(null)
|
||||
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
|
||||
const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false)
|
||||
const [activeShortcutPane, setActiveShortcutPane] = useState<ShortcutPane>('left')
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac')
|
||||
const collapsedLeftPaddingPx =
|
||||
(isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0) +
|
||||
|
|
@ -534,16 +537,55 @@ function App() {
|
|||
const chatTabIdCounterRef = useRef(0)
|
||||
const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
|
||||
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)
|
||||
activeChatTabIdRef.current = activeChatTabId
|
||||
const handleDraftChange = useCallback((text: string) => {
|
||||
const tabId = activeChatTabIdRef.current
|
||||
const setChatDraftForTab = useCallback((tabId: string, text: string) => {
|
||||
if (text) {
|
||||
chatDraftsRef.current.set(tabId, text)
|
||||
} else {
|
||||
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) => {
|
||||
if (!tab.runId) return 'New chat'
|
||||
|
|
@ -1701,6 +1743,7 @@ function App() {
|
|||
const tab = chatTabs.find(t => t.id === tabId)
|
||||
if (!tab) return
|
||||
if (tabId === activeChatTabId) return
|
||||
saveChatScrollForTab(activeChatTabId)
|
||||
setActiveChatTabId(tabId)
|
||||
const restored = restoreChatTabState(tabId, tab.runId)
|
||||
if (tab.runId && processingRunIdsRef.current.has(tab.runId)) {
|
||||
|
|
@ -1710,12 +1753,13 @@ function App() {
|
|||
if (!restored) {
|
||||
applyChatTab(tab)
|
||||
}
|
||||
}, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState])
|
||||
}, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
|
||||
|
||||
const closeChatTab = useCallback((tabId: string) => {
|
||||
if (chatTabs.length <= 1) return
|
||||
const idx = chatTabs.findIndex(t => t.id === tabId)
|
||||
if (idx === -1) return
|
||||
saveChatScrollForTab(tabId)
|
||||
const nextTabs = chatTabs.filter(t => t.id !== tabId)
|
||||
setChatTabs(nextTabs)
|
||||
setChatViewStateByTab(prev => {
|
||||
|
|
@ -1725,6 +1769,13 @@ function App() {
|
|||
return next
|
||||
})
|
||||
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) {
|
||||
const newIdx = Math.min(idx, nextTabs.length - 1)
|
||||
|
|
@ -1737,7 +1788,81 @@ function App() {
|
|||
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
|
||||
const openFileInNewTab = useCallback((path: string) => {
|
||||
|
|
@ -2086,13 +2211,18 @@ function App() {
|
|||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
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
|
||||
if (e.key === 'w') {
|
||||
e.preventDefault()
|
||||
if (inFileView && activeFileTabId) {
|
||||
closeFileTab(activeFileTabId)
|
||||
if (inFileView && targetFileTabId) {
|
||||
closeFileTab(targetFileTabId)
|
||||
} else {
|
||||
closeChatTab(activeChatTabId)
|
||||
}
|
||||
|
|
@ -2120,7 +2250,7 @@ function App() {
|
|||
e.preventDefault()
|
||||
const direction = e.key === ']' ? 1 : -1
|
||||
if (inFileView) {
|
||||
const currentIdx = fileTabs.findIndex(t => t.id === activeFileTabId)
|
||||
const currentIdx = fileTabs.findIndex(t => t.id === targetFileTabId)
|
||||
if (currentIdx === -1) return
|
||||
const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length
|
||||
switchFileTab(fileTabs[nextIdx].id)
|
||||
|
|
@ -2135,7 +2265,7 @@ function App() {
|
|||
}
|
||||
document.addEventListener('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') => {
|
||||
if (kind === 'file') {
|
||||
|
|
@ -2496,7 +2626,7 @@ function App() {
|
|||
}
|
||||
}, [isGraphOpen, knowledgeFilePaths])
|
||||
|
||||
const renderConversationItem = (item: ConversationItem) => {
|
||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||
if (isChatMessage(item)) {
|
||||
if (item.role === 'user') {
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
|
|
@ -2567,7 +2697,11 @@ function App() {
|
|||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
return (
|
||||
<Tool key={item.id}>
|
||||
<Tool
|
||||
key={item.id}
|
||||
open={isToolOpenForTab(tabId, item.id)}
|
||||
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
||||
>
|
||||
<ToolHeader
|
||||
title={item.name}
|
||||
type={`tool-${item.name}`}
|
||||
|
|
@ -2719,7 +2853,11 @@ function App() {
|
|||
selectedBackgroundTask={selectedBackgroundTask}
|
||||
/>
|
||||
{!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 */}
|
||||
<ContentHeader
|
||||
onNavigateBack={() => { void navigateBack() }}
|
||||
|
|
@ -2881,85 +3019,92 @@ function App() {
|
|||
) : (
|
||||
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{chatTabs.map((tab) => {
|
||||
const isActive = tab.id === activeChatTabId
|
||||
const tabState = getChatTabStateForRender(tab.id)
|
||||
const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
|
||||
const tabConversationContentClassName = tabHasConversation
|
||||
? "mx-auto w-full max-w-4xl pb-28"
|
||||
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn('min-h-0 flex-1 flex-col', isActive ? 'flex' : 'hidden')}
|
||||
data-chat-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<ScrollPositionPreserver />
|
||||
<ConversationContent className={tabConversationContentClassName}>
|
||||
{!tabHasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
<div className="text-2xl font-semibold tracking-tight text-foreground/80 sm:text-3xl md:text-4xl">
|
||||
What are we working on?
|
||||
</div>
|
||||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{tabState.conversation.map(item => {
|
||||
const rendered = renderConversationItem(item)
|
||||
if (isToolCall(item)) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
if (permRequest) {
|
||||
const response = tabState.permissionResponses.get(item.id) || null
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
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>
|
||||
)
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{chatTabs.map((tab) => {
|
||||
const isActive = tab.id === activeChatTabId
|
||||
const tabState = getChatTabStateForRender(tab.id)
|
||||
const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
|
||||
const tabConversationContentClassName = tabHasConversation
|
||||
? "mx-auto w-full max-w-4xl pb-28"
|
||||
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'min-h-0 h-full flex-col',
|
||||
isActive
|
||||
? 'flex'
|
||||
: 'pointer-events-none invisible absolute inset-0 flex'
|
||||
)}
|
||||
data-chat-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<ScrollPositionPreserver />
|
||||
<ConversationContent className={tabConversationContentClassName}>
|
||||
{!tabHasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
<div className="text-2xl font-semibold tracking-tight text-foreground/80 sm:text-3xl md:text-4xl">
|
||||
What are we working on?
|
||||
</div>
|
||||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{tabState.conversation.map(item => {
|
||||
const rendered = renderConversationItem(item, tab.id)
|
||||
if (isToolCall(item)) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
if (permRequest) {
|
||||
const response = tabState.permissionResponses.get(item.id) || null
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
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) => (
|
||||
<AskHumanRequest
|
||||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={isActive && isProcessing}
|
||||
/>
|
||||
))}
|
||||
{Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
|
||||
<AskHumanRequest
|
||||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={isActive && isProcessing}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
{tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{isActive && isProcessing && !tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{isActive && isProcessing && !tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
|
|
@ -2967,21 +3112,34 @@ function App() {
|
|||
{!hasConversation && (
|
||||
<Suggestions onSelect={setPresetMessage} className="mb-3 justify-center" />
|
||||
)}
|
||||
<ChatInputWithMentions
|
||||
key={activeChatTabId}
|
||||
knowledgeFiles={knowledgeFiles}
|
||||
recentFiles={recentWikiFiles}
|
||||
visibleFiles={visibleKnowledgeFiles}
|
||||
onSubmit={handlePromptSubmit}
|
||||
onStop={handleStop}
|
||||
isProcessing={isProcessing}
|
||||
isStopping={isStopping}
|
||||
presetMessage={presetMessage}
|
||||
onPresetMessageConsumed={() => setPresetMessage(undefined)}
|
||||
runId={runId}
|
||||
initialDraft={chatDraftsRef.current.get(activeChatTabId)}
|
||||
onDraftChange={handleDraftChange}
|
||||
/>
|
||||
{chatTabs.map((tab) => {
|
||||
const isActive = tab.id === activeChatTabId
|
||||
const tabState = getChatTabStateForRender(tab.id)
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={isActive ? 'block' : 'hidden'}
|
||||
data-chat-input-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<ChatInputWithMentions
|
||||
knowledgeFiles={knowledgeFiles}
|
||||
recentFiles={recentWikiFiles}
|
||||
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>
|
||||
|
|
@ -3017,14 +3175,17 @@ function App() {
|
|||
runId={runId}
|
||||
presetMessage={presetMessage}
|
||||
onPresetMessageConsumed={() => setPresetMessage(undefined)}
|
||||
initialDraft={chatDraftsRef.current.get(activeChatTabId)}
|
||||
onDraftChange={handleDraftChange}
|
||||
getInitialDraft={(tabId) => chatDraftsRef.current.get(tabId)}
|
||||
onDraftChangeForTab={setChatDraftForTab}
|
||||
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||
allPermissionRequests={allPermissionRequests}
|
||||
permissionResponses={permissionResponses}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
onAskHumanResponse={handleAskHumanResponse}
|
||||
isToolOpenForTab={isToolOpenForTab}
|
||||
onToolOpenChangeForTab={setToolOpenForTab}
|
||||
onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
|
||||
onActivate={() => setActiveShortcutPane('right')}
|
||||
/>
|
||||
)}
|
||||
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export const Conversation = ({ className, children, ...props }: ConversationProp
|
|||
* Must be used inside Conversation component.
|
||||
*/
|
||||
export const ScrollPositionPreserver = () => {
|
||||
const { isAtBottom } = useStickToBottomContext();
|
||||
const { isAtBottom, scrollRef } = useStickToBottomContext();
|
||||
const preservationContext = useContext(ScrollPreservationContext);
|
||||
const containerFoundRef = useRef(false);
|
||||
|
||||
|
|
@ -110,29 +110,13 @@ export const ScrollPositionPreserver = () => {
|
|||
useLayoutEffect(() => {
|
||||
if (containerFoundRef.current || !preservationContext) return;
|
||||
|
||||
// Find the scroll container (StickToBottom creates one)
|
||||
// It's the first parent with overflow-y scroll/auto
|
||||
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();
|
||||
// Use the local StickToBottom scroll container for this conversation instance.
|
||||
const container = scrollRef.current;
|
||||
if (container) {
|
||||
preservationContext.registerScrollContainer(container);
|
||||
containerFoundRef.current = true;
|
||||
}
|
||||
}, [preservationContext]);
|
||||
}, [preservationContext, scrollRef]);
|
||||
|
||||
// Track engagement based on scroll position
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -931,7 +931,13 @@ export const PromptInputTextarea = ({
|
|||
if (autoFocus || focusTrigger !== undefined) {
|
||||
// Small delay to ensure the element is fully mounted and visible
|
||||
const timer = setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
try {
|
||||
textarea.focus({ preventScroll: true });
|
||||
} catch {
|
||||
textarea.focus();
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface ChatInputInnerProps {
|
|||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
isActive: boolean
|
||||
presetMessage?: string
|
||||
onPresetMessageConsumed?: () => void
|
||||
runId?: string | null
|
||||
|
|
@ -28,6 +29,7 @@ function ChatInputInner({
|
|||
onStop,
|
||||
isProcessing,
|
||||
isStopping,
|
||||
isActive,
|
||||
presetMessage,
|
||||
onPresetMessageConsumed,
|
||||
runId,
|
||||
|
|
@ -72,6 +74,7 @@ function ChatInputInner({
|
|||
}, [handleSubmit])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
e.preventDefault()
|
||||
|
|
@ -100,15 +103,15 @@ function ChatInputInner({
|
|||
document.removeEventListener('dragover', onDragOver)
|
||||
document.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [controller])
|
||||
}, [controller, isActive])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-4 shadow-none">
|
||||
<PromptInputTextarea
|
||||
placeholder="Type your message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
focusTrigger={runId}
|
||||
autoFocus={isActive}
|
||||
focusTrigger={isActive ? runId : undefined}
|
||||
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
{isProcessing ? (
|
||||
|
|
@ -156,6 +159,7 @@ export interface ChatInputWithMentionsProps {
|
|||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
isActive?: boolean
|
||||
presetMessage?: string
|
||||
onPresetMessageConsumed?: () => void
|
||||
runId?: string | null
|
||||
|
|
@ -171,6 +175,7 @@ export function ChatInputWithMentions({
|
|||
onStop,
|
||||
isProcessing,
|
||||
isStopping,
|
||||
isActive = true,
|
||||
presetMessage,
|
||||
onPresetMessageConsumed,
|
||||
runId,
|
||||
|
|
@ -184,6 +189,7 @@ export function ChatInputWithMentions({
|
|||
onStop={onStop}
|
||||
isProcessing={isProcessing}
|
||||
isStopping={isStopping}
|
||||
isActive={isActive}
|
||||
presetMessage={presetMessage}
|
||||
onPresetMessageConsumed={onPresetMessageConsumed}
|
||||
runId={runId}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ interface ErrorMessage {
|
|||
type ConversationItem = ChatMessage | ToolCall | ErrorMessage
|
||||
|
||||
type ChatTabViewState = {
|
||||
runId: string | null
|
||||
conversation: ConversationItem[]
|
||||
currentAssistantMessage: string
|
||||
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||
|
|
@ -160,14 +161,17 @@ interface ChatSidebarProps {
|
|||
runId?: string | null
|
||||
presetMessage?: string
|
||||
onPresetMessageConsumed?: () => void
|
||||
initialDraft?: string
|
||||
onDraftChange?: (text: string) => void
|
||||
getInitialDraft?: (tabId: string) => string | undefined
|
||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
pendingAskHumanRequests?: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||
allPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||
permissionResponses?: Map<string, 'approve' | 'deny'>
|
||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => 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
|
||||
onActivate?: () => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
|
|
@ -195,14 +199,17 @@ export function ChatSidebar({
|
|||
runId,
|
||||
presetMessage,
|
||||
onPresetMessageConsumed,
|
||||
initialDraft,
|
||||
onDraftChange,
|
||||
getInitialDraft,
|
||||
onDraftChangeForTab,
|
||||
pendingAskHumanRequests = new Map(),
|
||||
allPermissionRequests = new Map(),
|
||||
permissionResponses = new Map(),
|
||||
onPermissionResponse,
|
||||
onAskHumanResponse,
|
||||
isToolOpenForTab,
|
||||
onToolOpenChangeForTab,
|
||||
onOpenKnowledgeFile,
|
||||
onActivate,
|
||||
}: ChatSidebarProps) {
|
||||
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
|
|
@ -284,12 +291,14 @@ export function ChatSidebar({
|
|||
}, [width, getMaxAllowedWidth])
|
||||
|
||||
const activeTabState = useMemo<ChatTabViewState>(() => ({
|
||||
runId: runId ?? null,
|
||||
conversation,
|
||||
currentAssistantMessage,
|
||||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
}), [
|
||||
runId,
|
||||
conversation,
|
||||
currentAssistantMessage,
|
||||
pendingAskHumanRequests,
|
||||
|
|
@ -297,6 +306,7 @@ export function ChatSidebar({
|
|||
permissionResponses,
|
||||
])
|
||||
const emptyTabState = useMemo<ChatTabViewState>(() => ({
|
||||
runId: null,
|
||||
conversation: [],
|
||||
currentAssistantMessage: '',
|
||||
pendingAskHumanRequests: new Map(),
|
||||
|
|
@ -309,7 +319,7 @@ export function ChatSidebar({
|
|||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
|
||||
|
||||
const renderConversationItem = (item: ConversationItem) => {
|
||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||
if (isChatMessage(item)) {
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
|
|
@ -329,7 +339,11 @@ export function ChatSidebar({
|
|||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
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)} />
|
||||
<ToolContent>
|
||||
<ToolInput input={input} />
|
||||
|
|
@ -367,6 +381,8 @@ export function ChatSidebar({
|
|||
return (
|
||||
<div
|
||||
ref={paneRef}
|
||||
onMouseDownCapture={onActivate}
|
||||
onFocusCapture={onActivate}
|
||||
className={cn(
|
||||
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
|
||||
!isResizing && 'transition-[width] duration-200 ease-linear'
|
||||
|
|
@ -418,12 +434,13 @@ export function ChatSidebar({
|
|||
size="icon"
|
||||
onClick={onOpenFullScreen}
|
||||
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" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isMaximized ? 'Restore two-pane view' : 'Maximize right pane'}
|
||||
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -431,80 +448,87 @@ export function ChatSidebar({
|
|||
|
||||
<FileCardProvider onOpenKnowledgeFile={onOpenKnowledgeFile ?? (() => {})}>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{chatTabs.map((tab) => {
|
||||
const isActive = tab.id === activeChatTabId
|
||||
const tabState = getTabState(tab.id)
|
||||
const tabHasConversation = tabState.conversation.length > 0 || Boolean(tabState.currentAssistantMessage)
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn('min-h-0 flex-1 flex-col', isActive ? 'flex' : 'hidden')}
|
||||
data-chat-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<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'}>
|
||||
{!tabHasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
<div className="text-sm text-muted-foreground">Ask anything...</div>
|
||||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{tabState.conversation.map((item) => {
|
||||
const rendered = renderConversationItem(item)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
if (permRequest) {
|
||||
const response = tabState.permissionResponses.get(item.id) || null
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
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>
|
||||
)
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{chatTabs.map((tab) => {
|
||||
const isActive = tab.id === activeChatTabId
|
||||
const tabState = getTabState(tab.id)
|
||||
const tabHasConversation = tabState.conversation.length > 0 || Boolean(tabState.currentAssistantMessage)
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'min-h-0 h-full flex-col',
|
||||
isActive
|
||||
? 'flex'
|
||||
: 'pointer-events-none invisible absolute inset-0 flex'
|
||||
)}
|
||||
data-chat-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<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'}>
|
||||
{!tabHasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
<div className="text-sm text-muted-foreground">Ask anything...</div>
|
||||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{tabState.conversation.map((item) => {
|
||||
const rendered = renderConversationItem(item, tab.id)
|
||||
if (isToolCall(item) && onPermissionResponse) {
|
||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||
if (permRequest) {
|
||||
const response = tabState.permissionResponses.get(item.id) || null
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{rendered}
|
||||
<PermissionRequest
|
||||
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) => (
|
||||
<AskHumanRequest
|
||||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={isActive && isProcessing}
|
||||
/>
|
||||
))}
|
||||
{onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
|
||||
<AskHumanRequest
|
||||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={isActive && isProcessing}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
{tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{isActive && isProcessing && !tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{isActive && isProcessing && !tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
|
|
@ -512,24 +536,37 @@ export function ChatSidebar({
|
|||
{!hasConversation && (
|
||||
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
|
||||
)}
|
||||
<ChatInputWithMentions
|
||||
key={activeChatTabId}
|
||||
knowledgeFiles={knowledgeFiles}
|
||||
recentFiles={recentFiles}
|
||||
visibleFiles={visibleFiles}
|
||||
onSubmit={onSubmit}
|
||||
onStop={onStop}
|
||||
isProcessing={isProcessing}
|
||||
isStopping={isStopping}
|
||||
presetMessage={localPresetMessage ?? presetMessage}
|
||||
onPresetMessageConsumed={() => {
|
||||
setLocalPresetMessage(undefined)
|
||||
onPresetMessageConsumed?.()
|
||||
}}
|
||||
runId={runId}
|
||||
initialDraft={initialDraft}
|
||||
onDraftChange={onDraftChange}
|
||||
/>
|
||||
{chatTabs.map((tab) => {
|
||||
const isActive = tab.id === activeChatTabId
|
||||
const tabState = getTabState(tab.id)
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={isActive ? 'block' : 'hidden'}
|
||||
data-chat-input-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<ChatInputWithMentions
|
||||
knowledgeFiles={knowledgeFiles}
|
||||
recentFiles={recentFiles}
|
||||
visibleFiles={visibleFiles}
|
||||
onSubmit={onSubmit}
|
||||
onStop={onStop}
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue