Add tabbed view support for chats and knowledge

This commit is contained in:
Tushar 2026-02-18 22:23:20 +05:30 committed by Ramnique Singh
parent 097efb39b1
commit 383241b5b7
9 changed files with 1894 additions and 1036 deletions

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,201 @@
import { useCallback, useEffect } from 'react'
import { ArrowUp, LoaderIcon, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
type FileMention,
type PromptInputMessage,
PromptInputProvider,
PromptInputTextarea,
usePromptInputController,
} from '@/components/ai-elements/prompt-input'
interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
isActive: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
initialDraft?: string
onDraftChange?: (text: string) => void
}
function ChatInputInner({
onSubmit,
onStop,
isProcessing,
isStopping,
isActive,
presetMessage,
onPresetMessageConsumed,
runId,
initialDraft,
onDraftChange,
}: ChatInputInnerProps) {
const controller = usePromptInputController()
const message = controller.textInput.value
const canSubmit = Boolean(message.trim()) && !isProcessing
// Restore the tab draft when this input mounts.
useEffect(() => {
if (initialDraft) {
controller.textInput.setInput(initialDraft)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
onDraftChange?.(message)
}, [message, onDraftChange])
useEffect(() => {
if (presetMessage) {
controller.textInput.setInput(presetMessage)
onPresetMessageConsumed?.()
}
}, [presetMessage, controller.textInput, onPresetMessageConsumed])
const handleSubmit = useCallback(() => {
if (!canSubmit) return
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions)
controller.textInput.clear()
controller.mentions.clearMentions()
}, [canSubmit, message, onSubmit, controller])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}, [handleSubmit])
useEffect(() => {
if (!isActive) return
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault()
}
}
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault()
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const paths = Array.from(e.dataTransfer.files)
.map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean)
if (paths.length > 0) {
const currentText = controller.textInput.value
const pathText = paths.join(' ')
controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText)
}
}
}
document.addEventListener('dragover', onDragOver)
document.addEventListener('drop', onDrop)
return () => {
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('drop', onDrop)
}
}, [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={isActive}
focusTrigger={isActive ? runId : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/>
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
isStopping
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
canSubmit
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground'
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
)
}
export interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
isActive?: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
initialDraft?: string
onDraftChange?: (text: string) => void
}
export function ChatInputWithMentions({
knowledgeFiles,
recentFiles,
visibleFiles,
onSubmit,
onStop,
isProcessing,
isStopping,
isActive = true,
presetMessage,
onPresetMessageConsumed,
runId,
initialDraft,
onDraftChange,
}: ChatInputWithMentionsProps) {
return (
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
<ChatInputInner
onSubmit={onSubmit}
onStop={onStop}
isProcessing={isProcessing}
isStopping={isStopping}
isActive={isActive}
presetMessage={presetMessage}
onPresetMessageConsumed={onPresetMessageConsumed}
runId={runId}
initialDraft={initialDraft}
onDraftChange={onDraftChange}
/>
</PromptInputProvider>
)
}

View file

@ -1,13 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUp, Expand, LoaderIcon, SquarePen, Square } from 'lucide-react' import { Expand, Shrink, SquarePen } from 'lucide-react'
import type { ToolUIPart } from 'ai'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
@ -19,233 +15,193 @@ import {
MessageContent, MessageContent,
MessageResponse, MessageResponse,
} from '@/components/ai-elements/message' } from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer' import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { PermissionRequest } from '@/components/ai-elements/permission-request' import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions' import { Suggestions } from '@/components/ai-elements/suggestions'
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
import { useMentionDetection } from '@/hooks/use-mention-detection'
import { MentionPopover } from '@/components/mention-popover'
import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { getMentionHighlightSegments } from '@/lib/mention-highlights'
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
import z from 'zod'
import React from 'react'
import { FileCardProvider } from '@/contexts/file-card-context' import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { TabBar, type ChatTab } from '@/components/tab-bar'
interface ChatMessage { import { ChatInputWithMentions } from '@/components/chat-input-with-mentions'
id: string import { wikiLabel } from '@/lib/wiki-links'
role: 'user' | 'assistant' import {
content: string type ChatTabViewState,
timestamp: number type ConversationItem,
} type PermissionResponse,
createEmptyChatTabViewState,
interface ToolCall { getWebSearchCardData,
id: string isChatMessage,
name: string isErrorMessage,
input: ToolUIPart['input'] isToolCall,
result?: ToolUIPart['output'] normalizeToolInput,
status: 'pending' | 'running' | 'completed' | 'error' normalizeToolOutput,
timestamp: number parseAttachedFiles,
} toToolState,
} from '@/lib/chat-conversation'
interface ErrorMessage {
id: string
kind: 'error'
message: string
timestamp: number
}
type ConversationItem = ChatMessage | ToolCall | ErrorMessage
type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error'
const toToolState = (status: ToolCall['status']): ToolState => {
switch (status) {
case 'pending':
return 'input-streaming'
case 'running':
return 'input-available'
case 'completed':
return 'output-available'
case 'error':
return 'output-error'
default:
return 'input-available'
}
}
const normalizeToolInput = (input: ToolCall['input'] | string | undefined): ToolCall['input'] => {
if (input === undefined || input === null) return {}
if (typeof input === 'string') {
const trimmed = input.trim()
if (!trimmed) return {}
try {
return JSON.parse(trimmed)
} catch {
return input
}
}
return input
}
const normalizeToolOutput = (output: ToolCall['result'] | undefined, status: ToolCall['status']) => {
if (output === undefined || output === null) {
return status === 'completed' ? 'No output returned.' : null
}
if (output === '') return '(empty output)'
if (typeof output === 'boolean' || typeof output === 'number') return String(output)
return output
}
const streamdownComponents = { pre: MarkdownPreOverride } const streamdownComponents = { pre: MarkdownPreOverride }
const MIN_WIDTH = 300 const MIN_WIDTH = 360
const MAX_WIDTH = 700 const MAX_WIDTH = 1600
const DEFAULT_WIDTH = 400 const MIN_MAIN_PANE_WIDTH = 420
const MIN_MAIN_PANE_RATIO = 0.3
const DEFAULT_WIDTH = 460
const RIGHT_PANE_WIDTH_STORAGE_KEY = 'x:right-pane-width'
function clampPaneWidth(width: number, maxWidth: number = MAX_WIDTH): number {
const boundedMax = Math.max(0, Math.min(MAX_WIDTH, maxWidth))
const boundedMin = Math.min(MIN_WIDTH, boundedMax)
return Math.min(boundedMax, Math.max(boundedMin, width))
}
function getInitialPaneWidth(defaultWidth: number): number {
const fallback = clampPaneWidth(defaultWidth)
if (typeof window === 'undefined') return fallback
try {
const raw = window.localStorage.getItem(RIGHT_PANE_WIDTH_STORAGE_KEY)
if (!raw) return fallback
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return fallback
return clampPaneWidth(parsed)
} catch {
return fallback
}
}
interface ChatSidebarProps { interface ChatSidebarProps {
defaultWidth?: number defaultWidth?: number
isOpen?: boolean isOpen?: boolean
onNewChat: () => void isMaximized?: boolean
chatTabs: ChatTab[]
activeChatTabId: string
getChatTabTitle: (tab: ChatTab) => string
isChatTabProcessing: (tab: ChatTab) => boolean
onSwitchChatTab: (tabId: string) => void
onCloseChatTab: (tabId: string) => void
onNewChatTab: () => void
onOpenFullScreen?: () => void onOpenFullScreen?: () => void
conversation: ConversationItem[] conversation: ConversationItem[]
currentAssistantMessage: string currentAssistantMessage: string
chatTabStates?: Record<string, ChatTabViewState>
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
onStop?: () => void onStop?: () => void
message: string
onMessageChange: (message: string) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
knowledgeFiles?: string[] knowledgeFiles?: string[]
recentFiles?: string[] recentFiles?: string[]
visibleFiles?: string[] visibleFiles?: string[]
selectedPath?: string | null runId?: string | null
pendingPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>> presetMessage?: string
pendingAskHumanRequests?: Map<string, z.infer<typeof AskHumanRequestEvent>> onPresetMessageConsumed?: () => void
allPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>> getInitialDraft?: (tabId: string) => string | undefined
permissionResponses?: Map<string, 'approve' | 'deny'> onDraftChangeForTab?: (tabId: string, text: string) => void
onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
permissionResponses?: ChatTabViewState['permissionResponses']
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse) => 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({
defaultWidth = DEFAULT_WIDTH, defaultWidth = DEFAULT_WIDTH,
isOpen = true, isOpen = true,
onNewChat, isMaximized = false,
chatTabs,
activeChatTabId,
getChatTabTitle,
isChatTabProcessing,
onSwitchChatTab,
onCloseChatTab,
onNewChatTab,
onOpenFullScreen, onOpenFullScreen,
conversation, conversation,
currentAssistantMessage, currentAssistantMessage,
chatTabStates = {},
isProcessing, isProcessing,
isStopping, isStopping,
onStop, onStop,
message,
onMessageChange,
onSubmit, onSubmit,
knowledgeFiles = [], knowledgeFiles = [],
recentFiles = [], recentFiles = [],
visibleFiles = [], visibleFiles = [],
selectedPath, runId,
presetMessage,
onPresetMessageConsumed,
getInitialDraft,
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(defaultWidth) const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
const [showContent, setShowContent] = useState(isOpen) const [showContent, setShowContent] = useState(isOpen)
const [localPresetMessage, setLocalPresetMessage] = useState<string | undefined>(undefined)
const paneRef = useRef<HTMLDivElement>(null)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
const getMaxAllowedWidth = useCallback(() => {
if (typeof window === 'undefined') return MAX_WIDTH
const paneElement = paneRef.current
const splitContainer = paneElement?.parentElement
const mainPane = splitContainer?.querySelector<HTMLElement>('[data-slot="sidebar-inset"]')
const paneWidth = paneElement?.getBoundingClientRect().width ?? 0
const mainPaneWidth = mainPane?.getBoundingClientRect().width ?? 0
const splitWidth = paneWidth + mainPaneWidth
const fallbackWidth = splitContainer?.clientWidth ?? window.innerWidth
const availableSplitWidth = splitWidth > 0 ? splitWidth : fallbackWidth
const minMainPaneWidth = Math.min(
availableSplitWidth,
Math.max(
MIN_MAIN_PANE_WIDTH,
Math.floor(availableSplitWidth * MIN_MAIN_PANE_RATIO)
)
)
return Math.max(0, availableSplitWidth - minMainPaneWidth)
}, [])
// Delay showing content when opening, hide immediately when closing
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
const timer = setTimeout(() => setShowContent(true), 150) const timer = setTimeout(() => setShowContent(true), 150)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} else {
setShowContent(false)
} }
setShowContent(false)
}, [isOpen]) }, [isOpen])
const startXRef = useRef(0)
const startWidthRef = useRef(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const highlightRef = useRef<HTMLDivElement>(null)
const [mentions, setMentions] = useState<FileMention[]>([])
const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null)
const lastSelectedPathRef = useRef<string | null>(null)
// Build mention labels for highlighting (handles multi-word names like "AI Agents")
const mentionLabels = useMemo(() => {
if (knowledgeFiles.length === 0) return []
const labels = knowledgeFiles
.map((path) => wikiLabel(path))
.map((label) => label.trim())
.filter(Boolean)
return Array.from(new Set(labels))
}, [knowledgeFiles])
const { activeMention, cursorCoords } = useMentionDetection(
textareaRef,
message,
knowledgeFiles.length > 0
)
// Use proper regex-based highlight segmentation that handles multi-word names
const mentionHighlights = useMemo(
() => getMentionHighlightSegments(message, activeMention, mentionLabels),
[message, activeMention, mentionLabels]
)
// Sync highlight overlay scroll with textarea
const syncHighlightScroll = useCallback(() => {
const textarea = textareaRef.current
const highlight = highlightRef.current
if (!textarea || !highlight) return
highlight.scrollTop = textarea.scrollTop
highlight.scrollLeft = textarea.scrollLeft
}, [])
useEffect(() => { useEffect(() => {
syncHighlightScroll() if (typeof window === 'undefined') return
}, [message, mentionHighlights.hasHighlights, syncHighlightScroll]) try {
window.localStorage.setItem(RIGHT_PANE_WIDTH_STORAGE_KEY, String(width))
} catch {
// Ignore persistence failures and keep in-memory behavior.
}
}, [width])
const handleMentionSelect = useCallback( useEffect(() => {
(path: string, displayName: string) => { const clampToAvailableWidth = () => {
if (!activeMention) return const maxAllowedWidth = getMaxAllowedWidth()
setWidth((prev) => clampPaneWidth(prev, maxAllowedWidth))
const beforeAt = message.substring(0, activeMention.triggerIndex)
const afterQuery = message.substring(
activeMention.triggerIndex + 1 + activeMention.query.length
)
const newText = `${beforeAt}@${displayName} ${afterQuery}`
onMessageChange(newText)
const fullPath = toKnowledgePath(path)
if (fullPath) {
setMentions(prev => {
if (prev.some(m => m.path === fullPath)) return prev
return [...prev, { id: `mention-${Date.now()}`, path: fullPath, displayName }]
})
} }
textareaRef.current?.focus() clampToAvailableWidth()
}, window.addEventListener('resize', clampToAvailableWidth)
[activeMention, message, onMessageChange] return () => window.removeEventListener('resize', clampToAvailableWidth)
) }, [getMaxAllowedWidth])
const handleMentionClose = useCallback(() => {
// The popover handles its own closing
}, [])
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
@ -253,10 +209,10 @@ export function ChatSidebar({
startWidthRef.current = width startWidthRef.current = width
setIsResizing(true) setIsResizing(true)
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (event: MouseEvent) => {
const delta = startXRef.current - e.clientX const delta = startXRef.current - event.clientX
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + delta)) const maxAllowedWidth = getMaxAllowedWidth()
setWidth(newWidth) setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
} }
const handleMouseUp = () => { const handleMouseUp = () => {
@ -267,159 +223,89 @@ export function ChatSidebar({
document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp) document.addEventListener('mouseup', handleMouseUp)
}, [width]) }, [width, getMaxAllowedWidth])
// Auto-focus textarea when sidebar opens or when conversation is cleared (new chat) const activeTabState = useMemo<ChatTabViewState>(() => ({
useEffect(() => { runId: runId ?? null,
// Focus when conversation is empty (new chat started) conversation,
if (conversation.length === 0) { currentAssistantMessage,
const timer = setTimeout(() => { pendingAskHumanRequests,
textareaRef.current?.focus() allPermissionRequests,
}, 50) permissionResponses,
return () => clearTimeout(timer) }), [
} runId,
}, [conversation.length]) conversation,
currentAssistantMessage,
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
])
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
const getTabState = useCallback((tabId: string): ChatTabViewState => {
if (tabId === activeChatTabId) return activeTabState
return chatTabStates[tabId] ?? emptyTabState
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
// Auto-populate with @currentfile when switching knowledge files const renderConversationItem = (item: ConversationItem, tabId: string) => {
useEffect(() => {
if (selectedPath === lastSelectedPathRef.current) return
lastSelectedPathRef.current = selectedPath ?? null
if (!selectedPath || !selectedPath.startsWith('knowledge/') || !selectedPath.endsWith('.md')) {
return
}
const displayName = wikiLabel(selectedPath)
const previousAuto = autoMentionRef.current
const trimmed = message.trim()
const previousToken = previousAuto ? `@${previousAuto.displayName}` : null
const shouldReplace = !trimmed || (previousToken && trimmed === previousToken)
if (!shouldReplace) {
return
}
const nextText = `@${displayName} `
if (message !== nextText) {
onMessageChange(nextText)
}
setMentions((prev) => {
const withoutPrevious = previousAuto
? prev.filter((mention) => mention.path !== previousAuto.path)
: prev
if (withoutPrevious.some((mention) => mention.path === selectedPath)) {
return withoutPrevious
}
return [
...withoutPrevious,
{
id: `mention-auto-${Date.now()}`,
path: selectedPath,
displayName,
},
]
})
autoMentionRef.current = { path: selectedPath, displayName }
}, [selectedPath, message, onMessageChange])
const hasConversation = conversation.length > 0 || currentAssistantMessage
const canSubmit = Boolean(message.trim()) && !isProcessing
const handleSubmit = () => {
const trimmed = message.trim()
if (trimmed && !isProcessing) {
onSubmit({ text: trimmed, files: [] }, mentions)
setMentions([])
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// If mention popover is open, let it handle navigation keys
if (activeMention && ['ArrowDown', 'ArrowUp', 'Tab', 'Escape'].includes(e.key)) {
return
}
if (e.key === 'Enter') {
// If mention popover is open, Enter should select the item
if (activeMention) {
return
}
if (!e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
// Handle backspace to delete entire mention at once
if (e.key === 'Backspace') {
const textarea = e.currentTarget
const cursorPos = textarea.selectionStart
const selectionEnd = textarea.selectionEnd
// Only handle if no text is selected (cursor is at a single position)
if (cursorPos !== selectionEnd) return
// Check if cursor is right after a mention
for (const label of mentionLabels) {
const mentionText = `@${label}`
const startPos = cursorPos - mentionText.length
if (startPos >= 0) {
const textBefore = message.substring(startPos, cursorPos)
if (textBefore === mentionText) {
// Check if it's at word boundary (start of string or preceded by whitespace)
if (startPos === 0 || /\s/.test(message[startPos - 1])) {
e.preventDefault()
const newText = message.substring(0, startPos) + message.substring(cursorPos)
onMessageChange(newText)
// Remove the mention from state
setMentions(prev => prev.filter(m => m.displayName !== label))
// Set cursor position after React updates
setTimeout(() => {
textarea.selectionStart = startPos
textarea.selectionEnd = startPos
}, 0)
return
}
}
}
}
}
}
const renderConversationItem = (item: ConversationItem) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
if (item.role === 'user') {
const { message, files } = parseAttachedFiles(item.content)
return ( return (
<Message key={item.id} from={item.role}> <Message key={item.id} from={item.role}>
<MessageContent> <MessageContent>
{item.role === 'assistant' ? ( {files.length > 0 && (
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse> <div className="mb-2 flex flex-wrap gap-1.5">
) : ( {files.map((filePath, index) => (
item.content <span
key={index}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
>
@{wikiLabel(filePath)}
</span>
))}
</div>
)} )}
{message}
</MessageContent>
</Message>
)
}
return (
<Message key={item.id} from={item.role}>
<MessageContent>
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
</MessageContent> </MessageContent>
</Message> </Message>
) )
} }
if (isToolCall(item)) { if (isToolCall(item)) {
const webSearchData = getWebSearchCardData(item)
if (webSearchData) {
return (
<WebSearchResult
key={item.id}
query={webSearchData.query}
results={webSearchData.results}
status={item.status}
title={webSearchData.title}
/>
)
}
const errorText = item.status === 'error' ? 'Tool error' : '' const errorText = item.status === 'error' ? 'Tool error' : ''
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
<ToolHeader key={item.id}
title={item.name} open={isToolOpenForTab?.(tabId, item.id) ?? false}
type={`tool-${item.name}`} onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
state={toToolState(item.status)} >
/> <ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent> <ToolContent>
<ToolInput input={input} /> <ToolInput input={input} />
{output !== null ? ( {output !== null ? <ToolOutput output={output} errorText={errorText} /> : null}
<ToolOutput output={output} errorText={errorText} />
) : null}
</ToolContent> </ToolContent>
</Tool> </Tool>
) )
@ -438,75 +324,120 @@ export function ChatSidebar({
return null return null
} }
const displayWidth = isOpen ? width : 0 const paneStyle = useMemo<React.CSSProperties>(() => {
if (!isOpen) {
return { width: 0, flex: '0 0 auto' }
}
if (isMaximized) {
// In maximize mode the pane should grow into the freed left space,
// not add extra width to the right and overflow the app viewport.
return { width: 0, flex: '1 1 auto' }
}
return { width, flex: '0 0 auto' }
}, [isOpen, isMaximized, width])
return ( return (
<div <div
ref={paneRef}
onMouseDownCapture={onActivate}
onFocusCapture={onActivate}
className={cn( className={cn(
"relative flex flex-col border-l border-border bg-background shrink-0 overflow-hidden", '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'
)} )}
style={{ width: displayWidth }} style={paneStyle}
> >
{/* Resize handle */} {!isMaximized && (
<div <div
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
className={cn( className={cn(
"absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize", 'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
"after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors", 'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
"hover:after:bg-sidebar-border", 'hover:after:bg-sidebar-border',
isResizing && "after:bg-primary" isResizing && 'after:bg-primary'
)} )}
/> />
)}
{/* Content - delayed on open, hidden immediately on close to avoid layout issues during animation */}
{showContent && ( {showContent && (
<> <>
{/* Header - minimal, expand and new chat buttons */} <header className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar">
<header className="titlebar-drag-region flex h-10 shrink-0 items-center justify-end gap-1 px-2 bg-sidebar"> <TabBar
tabs={chatTabs}
activeTabId={activeChatTabId}
getTabTitle={getChatTabTitle}
getTabId={(tab) => tab.id}
isProcessing={isChatTabProcessing}
onSwitchTab={onSwitchChatTab}
onCloseTab={onCloseChatTab}
/>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onNewChat} className="titlebar-no-drag h-8 w-8 text-muted-foreground hover:text-foreground"> <Button
<SquarePen className="h-4 w-4" /> variant="ghost"
size="icon"
onClick={onNewChatTab}
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
>
<SquarePen className="size-5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent> <TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip> </Tooltip>
{onOpenFullScreen && ( {onOpenFullScreen && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onOpenFullScreen} className="titlebar-no-drag h-8 w-8 text-muted-foreground hover:text-foreground"> <Button
<Expand className="h-4 w-4" /> variant="ghost"
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> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom">Full screen chat</TooltipContent> <TooltipContent side="bottom">
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
</TooltipContent>
</Tooltip> </Tooltip>
)} )}
</header> </header>
{/* Conversation area */}
<FileCardProvider onOpenKnowledgeFile={onOpenKnowledgeFile ?? (() => {})}> <FileCardProvider onOpenKnowledgeFile={onOpenKnowledgeFile ?? (() => {})}>
<div className="flex min-h-0 flex-1 flex-col relative"> <div className="flex min-h-0 flex-1 flex-col">
<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]"> <Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<ScrollPositionPreserver /> <ScrollPositionPreserver />
<ConversationContent className={hasConversation ? "px-4 pb-24" : "px-4 min-h-full items-center justify-center"}> <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'}>
{!hasConversation ? ( {!tabHasConversation ? (
<ConversationEmptyState className="h-auto"> <ConversationEmptyState className="h-auto">
<div className="flex flex-col items-center gap-1 text-center"> <div className="text-sm text-muted-foreground">Ask anything...</div>
<div className="text-sm text-muted-foreground">
Ask anything...
</div>
</div>
</ConversationEmptyState> </ConversationEmptyState>
) : ( ) : (
<> <>
{conversation.map(item => { {tabState.conversation.map((item) => {
const rendered = renderConversationItem(item) const rendered = renderConversationItem(item, tab.id)
// If this is a tool call, check for permission request (pending or responded)
if (isToolCall(item) && onPermissionResponse) { if (isToolCall(item) && onPermissionResponse) {
const permRequest = allPermissionRequests.get(item.id) const permRequest = tabState.allPermissionRequests.get(item.id)
if (permRequest) { if (permRequest) {
const response = permissionResponses.get(item.id) || null const response = tabState.permissionResponses.get(item.id) || null
return ( return (
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
{rendered} {rendered}
@ -514,7 +445,7 @@ export function ChatSidebar({
toolCall={permRequest.toolCall} toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isProcessing} isProcessing={isActive && isProcessing}
response={response} response={response}
/> />
</React.Fragment> </React.Fragment>
@ -524,25 +455,24 @@ export function ChatSidebar({
return rendered return rendered
})} })}
{/* Render pending ask-human requests */} {onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
{onAskHumanResponse && Array.from(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={isProcessing} isProcessing={isActive && isProcessing}
/> />
))} ))}
{currentAssistantMessage && ( {tabState.currentAssistantMessage && (
<Message from="assistant"> <Message from="assistant">
<MessageContent> <MessageContent>
<MessageResponse components={streamdownComponents}>{currentAssistantMessage}</MessageResponse> <MessageResponse components={streamdownComponents}>{tabState.currentAssistantMessage}</MessageResponse>
</MessageContent> </MessageContent>
</Message> </Message>
)} )}
{isProcessing && !currentAssistantMessage && ( {isActive && isProcessing && !tabState.currentAssistantMessage && (
<Message from="assistant"> <Message from="assistant">
<MessageContent> <MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer> <Shimmer duration={1}>Thinking...</Shimmer>
@ -553,100 +483,49 @@ export function ChatSidebar({
)} )}
</ConversationContent> </ConversationContent>
</Conversation> </Conversation>
</div>
{/* Input area - responsive to sidebar width, matches floating bar position exactly */}
<div className="absolute bottom-6 left-14 right-6 z-10" ref={containerRef}>
{!hasConversation && (
<Suggestions
onSelect={(prompt) => {
onMessageChange(prompt)
setTimeout(() => textareaRef.current?.focus(), 0)
}}
vertical
className="mb-3"
/>
)}
<div className="flex items-center gap-2 bg-background border border-border rounded-lg shadow-none px-4 py-2.5">
<div className="relative flex-1 min-w-0">
{mentionHighlights.hasHighlights && (
<div
ref={highlightRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap wrap-break-word text-sm text-transparent"
>
{mentionHighlights.segments.map((segment, index) =>
segment.highlighted ? (
<span
key={`mention-${index}`}
className="rounded bg-primary/20 text-transparent [box-decoration-break:clone] shadow-[inset_0_0_0_1px_hsl(var(--primary)/0.15),-3px_0_0_hsl(var(--primary)/0.2),3px_0_0_hsl(var(--primary)/0.2),0_-2px_0_hsl(var(--primary)/0.2),0_2px_0_hsl(var(--primary)/0.2)]"
>
{segment.text}
</span>
) : (
<span key={`text-${index}`}>{segment.text}</span>
) )
)} })}
</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" />
<div className="mx-auto w-full max-w-4xl px-3">
{!hasConversation && (
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
)} )}
<textarea {chatTabs.map((tab) => {
ref={textareaRef} const isActive = tab.id === activeChatTabId
value={message} const tabState = getTabState(tab.id)
onChange={(e) => onMessageChange(e.target.value)} return (
onKeyDown={handleKeyDown} <div
onScroll={syncHighlightScroll} key={tab.id}
placeholder="Ask anything..." className={isActive ? 'block' : 'hidden'}
rows={1} data-chat-input-panel={tab.id}
className="relative z-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground resize-none max-h-32 min-h-6" aria-hidden={!isActive}
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
</div>
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? "Click again to force stop" : "Stop generation"}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
isStopping
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
> >
{isStopping ? ( <ChatInputWithMentions
<LoaderIcon className="h-4 w-4 animate-spin" /> knowledgeFiles={knowledgeFiles}
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
{knowledgeFiles.length > 0 && (
<MentionPopover
files={knowledgeFiles}
recentFiles={recentFiles} recentFiles={recentFiles}
visibleFiles={visibleFiles} visibleFiles={visibleFiles}
query={activeMention?.query ?? ''} onSubmit={onSubmit}
position={cursorCoords} onStop={onStop}
containerRef={containerRef} isProcessing={isActive && isProcessing}
onSelect={handleMentionSelect} isStopping={isActive && isStopping}
onClose={handleMentionClose} isActive={isActive}
open={Boolean(activeMention)} 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>
</FileCardProvider> </FileCardProvider>

View file

@ -8,6 +8,7 @@ import {
ChevronsDownUp, ChevronsDownUp,
ChevronsUpDown, ChevronsUpDown,
Copy, Copy,
ExternalLink,
FilePlus, FilePlus,
FolderPlus, FolderPlus,
AlertTriangle, AlertTriangle,
@ -105,6 +106,7 @@ type KnowledgeActions = {
rename: (path: string, newName: string, isDir: boolean) => Promise<void> rename: (path: string, newName: string, isDir: boolean) => Promise<void>
remove: (path: string) => Promise<void> remove: (path: string) => Promise<void>
copyPath: (path: string) => void copyPath: (path: string) => void
onOpenInNewTab?: (path: string) => void
} }
type RunListItem = { type RunListItem = {
@ -149,6 +151,7 @@ type TasksActions = {
onNewChat: () => void onNewChat: () => void
onSelectRun: (runId: string) => void onSelectRun: (runId: string) => void
onDeleteRun: (runId: string) => void onDeleteRun: (runId: string) => void
onOpenInNewTab?: (runId: string) => void
onSelectBackgroundTask?: (taskName: string) => void onSelectBackgroundTask?: (taskName: string) => void
} }
@ -981,6 +984,15 @@ function Tree({
<ContextMenuSeparator /> <ContextMenuSeparator />
</> </>
)} )}
{!isDir && actions.onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(item.path)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onClick={handleCopyPath}> <ContextMenuItem onClick={handleCopyPath}>
<Copy className="mr-2 size-4" /> <Copy className="mr-2 size-4" />
Copy Path Copy Path
@ -1033,12 +1045,20 @@ function Tree({
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarMenuItem> <SidebarMenuItem className="group/file-item">
<SidebarMenuButton <SidebarMenuButton
isActive={isSelected} isActive={isSelected}
onClick={() => onSelect(item.path, item.kind)} onClick={(e) => {
if (e.metaKey && actions.onOpenInNewTab) {
actions.onOpenInNewTab(item.path)
} else {
onSelect(item.path, item.kind)
}
}}
> >
<span>{item.name}</span> <div className="flex w-full items-center gap-1 min-w-0">
<span className="min-w-0 flex-1 truncate">{item.name}</span>
</div>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</ContextMenuTrigger> </ContextMenuTrigger>
@ -1162,12 +1182,18 @@ function TasksSection({
</div> </div>
<SidebarMenu> <SidebarMenu>
{runs.map((run) => ( {runs.map((run) => (
<SidebarMenuItem key={run.id}> <ContextMenu key={run.id}>
<ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarMenuItem className="group/chat-item">
<SidebarMenuButton <SidebarMenuButton
isActive={currentRunId === run.id} isActive={currentRunId === run.id}
onClick={() => actions?.onSelectRun(run.id)} onClick={(e) => {
if (e.metaKey && actions?.onOpenInNewTab) {
actions.onOpenInNewTab(run.id)
} else {
actions?.onSelectRun(run.id)
}
}}
> >
<div className="flex w-full items-center gap-2 min-w-0"> <div className="flex w-full items-center gap-2 min-w-0">
{processingRunIds?.has(run.id) ? ( {processingRunIds?.has(run.id) ? (
@ -1181,19 +1207,29 @@ function TasksSection({
) : null} ) : null}
</div> </div>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="w-48"> <ContextMenuContent className="w-48">
{actions?.onOpenInNewTab && (
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(run.id)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
)}
{!processingRunIds?.has(run.id) && (
<>
{actions?.onOpenInNewTab && <ContextMenuSeparator />}
<ContextMenuItem <ContextMenuItem
variant="destructive" variant="destructive"
disabled={processingRunIds?.has(run.id)}
onClick={() => setPendingDeleteRunId(run.id)} onClick={() => setPendingDeleteRunId(run.id)}
> >
<Trash2 className="mr-2 size-4" /> <Trash2 className="mr-2 size-4" />
Delete Delete
</ContextMenuItem> </ContextMenuItem>
</>
)}
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>
</> </>

View file

@ -0,0 +1,95 @@
import * as React from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
export type ChatTab = {
id: string
runId: string | null
}
export type FileTab = {
id: string
path: string
}
interface TabBarProps<T> {
tabs: T[]
activeTabId: string
getTabTitle: (tab: T) => string
getTabId: (tab: T) => string
isProcessing?: (tab: T) => boolean
onSwitchTab: (tabId: string) => void
onCloseTab: (tabId: string) => void
layout?: 'fill' | 'scroll'
}
export function TabBar<T>({
tabs,
activeTabId,
getTabTitle,
getTabId,
isProcessing,
onSwitchTab,
onCloseTab,
layout = 'fill',
}: TabBarProps<T>) {
return (
<div
className={cn(
'flex flex-1 self-stretch min-w-0',
layout === 'scroll'
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
: 'overflow-hidden'
)}
>
{tabs.map((tab, index) => {
const tabId = getTabId(tab)
const isActive = tabId === activeTabId
const processing = isProcessing?.(tab) ?? false
const title = getTabTitle(tab)
return (
<React.Fragment key={tabId}>
{index > 0 && (
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
)}
<button
type="button"
onClick={() => onSwitchTab(tabId)}
className={cn(
'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
isActive
? 'bg-background text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
)}
style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }}
>
{processing && (
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
)}
<span className="truncate flex-1 text-left">{title}</span>
{tabs.length > 1 && (
<span
role="button"
className="shrink-0 flex items-center justify-center rounded-sm p-0.5 opacity-0 group-hover/tab:opacity-60 hover:opacity-100! hover:bg-foreground/10 transition-all"
onClick={(e) => {
e.stopPropagation()
onCloseTab(tabId)
}}
aria-label="Close tab"
>
<X className="size-3" />
</span>
)}
</button>
{/* Right edge divider after last tab to close off the section */}
{index === tabs.length - 1 && (
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
)}
</React.Fragment>
)
})}
</div>
)
}

View file

@ -0,0 +1,177 @@
import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: number
}
export interface ToolCall {
id: string
name: string
input: ToolUIPart['input']
result?: ToolUIPart['output']
status: 'pending' | 'running' | 'completed' | 'error'
timestamp: number
}
export interface ErrorMessage {
id: string
kind: 'error'
message: string
timestamp: number
}
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage
export type PermissionResponse = 'approve' | 'deny'
export type ChatTabViewState = {
runId: string | null
conversation: ConversationItem[]
currentAssistantMessage: string
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses: Map<string, PermissionResponse>
}
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
runId: null,
conversation: [],
currentAssistantMessage: '',
pendingAskHumanRequests: new Map(),
allPermissionRequests: new Map(),
permissionResponses: new Map(),
})
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
export const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
'kind' in item && item.kind === 'error'
export const toToolState = (status: ToolCall['status']): ToolState => {
switch (status) {
case 'pending':
return 'input-streaming'
case 'running':
return 'input-available'
case 'completed':
return 'output-available'
case 'error':
return 'output-error'
default:
return 'input-available'
}
}
export const normalizeToolInput = (
input: ToolCall['input'] | string | undefined
): ToolCall['input'] => {
if (input === undefined || input === null) return {}
if (typeof input === 'string') {
const trimmed = input.trim()
if (!trimmed) return {}
try {
return JSON.parse(trimmed)
} catch {
return input
}
}
return input
}
export const normalizeToolOutput = (
output: ToolCall['result'] | undefined,
status: ToolCall['status']
) => {
if (output === undefined || output === null) {
return status === 'completed' ? 'No output returned.' : null
}
if (output === '') return '(empty output)'
if (typeof output === 'boolean' || typeof output === 'number') return String(output)
return output
}
export type WebSearchCardResult = { title: string; url: string; description: string }
export type WebSearchCardData = {
query: string
results: WebSearchCardResult[]
title?: string
}
export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
if (tool.name === 'web-search') {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
return {
query: (input?.query as string) || '',
results: (result?.results as WebSearchCardResult[]) || [],
}
}
if (tool.name === 'research-search') {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const rawResults = (result?.results as Array<{
title: string
url: string
highlights?: string[]
text?: string
}>) || []
const mapped = rawResults.map((entry) => ({
title: entry.title,
url: entry.url,
description: entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
}))
const category = input?.category as string | undefined
return {
query: (input?.query as string) || '',
results: mapped,
title: category
? `${category.charAt(0).toUpperCase() + category.slice(1)} search`
: 'Researched the web',
}
}
return null
}
// Parse attached files from message content and return clean message + file paths.
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
const match = content.match(attachedFilesRegex)
if (!match) {
return { message: content, files: [] }
}
const filesXml = match[1]
const filePathRegex = /<file path="([^"]+)">/g
const files: string[] = []
let fileMatch
while ((fileMatch = filePathRegex.exec(filesXml)) !== null) {
files.push(fileMatch[1])
}
let cleanMessage = content.replace(attachedFilesRegex, '').trim()
for (const filePath of files) {
const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || ''
if (!fileName) continue
const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
cleanMessage = cleanMessage.replace(mentionRegex, '')
}
return { message: cleanMessage.trim(), files }
}
export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim()
if (!normalized) return undefined
return normalized.length > 100 ? normalized.substring(0, 100) : normalized
}

View file

@ -245,6 +245,16 @@
align-self: center; align-self: center;
} }
/* Keep knowledge text width readable while margins collapse on narrow panes. */
.tiptap-editor .ProseMirror {
width: 100%;
max-width: min(56rem, calc(100% - clamp(0.5rem, 2.5vw, 2rem)));
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
padding-left: clamp(0.5rem, 1.5vw, 1rem);
padding-right: clamp(0.5rem, 1.5vw, 1rem);
}
.wiki-link-anchor { .wiki-link-anchor {
position: absolute; position: absolute;
height: 0; height: 0;