mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +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.
|
||||
*/
|
||||
export const ScrollPositionPreserver = () => {
|
||||
const { isAtBottom } = useStickToBottomContext();
|
||||
const { isAtBottom, scrollRef } = useStickToBottomContext();
|
||||
const preservationContext = useContext(ScrollPreservationContext);
|
||||
const containerFoundRef = useRef(false);
|
||||
|
||||
|
|
@ -110,29 +110,13 @@ export const ScrollPositionPreserver = () => {
|
|||
useLayoutEffect(() => {
|
||||
if (containerFoundRef.current || !preservationContext) return;
|
||||
|
||||
// Find the scroll container (StickToBottom creates one)
|
||||
// It's the first parent with overflow-y scroll/auto
|
||||
const findScrollContainer = (): HTMLElement | null => {
|
||||
const candidates = document.querySelectorAll('[role="log"]');
|
||||
for (const candidate of candidates) {
|
||||
// The scroll container is a direct child of the role="log" element
|
||||
const children = candidate.children;
|
||||
for (const child of children) {
|
||||
const style = window.getComputedStyle(child);
|
||||
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||||
return child as HTMLElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = findScrollContainer();
|
||||
// Use the local StickToBottom scroll container for this conversation instance.
|
||||
const container = scrollRef.current;
|
||||
if (container) {
|
||||
preservationContext.registerScrollContainer(container);
|
||||
containerFoundRef.current = true;
|
||||
}
|
||||
}, [preservationContext]);
|
||||
}, [preservationContext, scrollRef]);
|
||||
|
||||
// Track engagement based on scroll position
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -931,7 +931,13 @@ export const PromptInputTextarea = ({
|
|||
if (autoFocus || focusTrigger !== undefined) {
|
||||
// Small delay to ensure the element is fully mounted and visible
|
||||
const timer = setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
try {
|
||||
textarea.focus({ preventScroll: true });
|
||||
} catch {
|
||||
textarea.focus();
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ import {
|
|||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FilePlus,
|
||||
FolderPlus,
|
||||
AlertTriangle,
|
||||
|
|
@ -105,6 +106,7 @@ type KnowledgeActions = {
|
|||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
copyPath: (path: string) => void
|
||||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
type RunListItem = {
|
||||
|
|
@ -149,6 +151,7 @@ type TasksActions = {
|
|||
onNewChat: () => void
|
||||
onSelectRun: (runId: string) => void
|
||||
onDeleteRun: (runId: string) => void
|
||||
onOpenInNewTab?: (runId: string) => void
|
||||
onSelectBackgroundTask?: (taskName: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -981,6 +984,15 @@ function Tree({
|
|||
<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}>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
|
|
@ -1033,12 +1045,20 @@ function Tree({
|
|||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuItem className="group/file-item">
|
||||
<SidebarMenuButton
|
||||
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>
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
|
|
@ -1162,12 +1182,18 @@ function TasksSection({
|
|||
</div>
|
||||
<SidebarMenu>
|
||||
{runs.map((run) => (
|
||||
<SidebarMenuItem key={run.id}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<ContextMenu key={run.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem className="group/chat-item">
|
||||
<SidebarMenuButton
|
||||
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">
|
||||
{processingRunIds?.has(run.id) ? (
|
||||
|
|
@ -1181,19 +1207,29 @@ function TasksSection({
|
|||
) : null}
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={processingRunIds?.has(run.id)}
|
||||
onClick={() => setPendingDeleteRunId(run.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{actions?.onOpenInNewTab && (
|
||||
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(run.id)}>
|
||||
<ExternalLink className="mr-2 size-4" />
|
||||
Open in new tab
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
{!processingRunIds?.has(run.id) && (
|
||||
<>
|
||||
{actions?.onOpenInNewTab && <ContextMenuSeparator />}
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setPendingDeleteRunId(run.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue