mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
Add tabbed view support for chats and knowledge
This commit is contained in:
parent
097efb39b1
commit
383241b5b7
9 changed files with 1894 additions and 1036 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
201
apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
Normal file
201
apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
95
apps/x/apps/renderer/src/components/tab-bar.tsx
Normal file
95
apps/x/apps/renderer/src/components/tab-bar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
apps/x/apps/renderer/src/lib/chat-conversation.ts
Normal file
177
apps/x/apps/renderer/src/lib/chat-conversation.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue