mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 09:56:23 +02:00
feat: add interactive file path cards in chat UI
Render filepath code blocks as rich, clickable cards with three variants: knowledge files (navigate to editor), audio files (inline play/pause), and system files (open externally). Adds shell:openPath and shell:readFileBase64 IPC channels, FileCardProvider context, and Streamdown pre override. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
35ca1b69c0
commit
0de9589a7d
9 changed files with 369 additions and 7 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
import { ipcMain, BrowserWindow } from 'electron';
|
import { ipcMain, BrowserWindow, shell } from 'electron';
|
||||||
import { ipc } from '@x/shared';
|
import { ipc } from '@x/shared';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
import {
|
import {
|
||||||
connectProvider,
|
connectProvider,
|
||||||
disconnectProvider,
|
disconnectProvider,
|
||||||
|
|
@ -455,5 +457,37 @@ export function setupIpcHandlers() {
|
||||||
await stateRepo.deleteAgentState(args.agentName);
|
await stateRepo.deleteAgentState(args.agentName);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
// Shell integration handlers
|
||||||
|
'shell:openPath': async (_event, args) => {
|
||||||
|
let filePath = args.path;
|
||||||
|
if (filePath.startsWith('~')) {
|
||||||
|
filePath = path.join(os.homedir(), filePath.slice(1));
|
||||||
|
}
|
||||||
|
const error = await shell.openPath(filePath);
|
||||||
|
return { error: error || undefined };
|
||||||
|
},
|
||||||
|
'shell:readFileBase64': async (_event, args) => {
|
||||||
|
let filePath = args.path;
|
||||||
|
if (filePath.startsWith('~')) {
|
||||||
|
filePath = path.join(os.homedir(), filePath.slice(1));
|
||||||
|
}
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
if (stat.size > 10 * 1024 * 1024) {
|
||||||
|
throw new Error('File too large (>10MB)');
|
||||||
|
}
|
||||||
|
const buffer = await fs.readFile(filePath);
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
||||||
|
'.bmp': 'image/bmp', '.ico': 'image/x-icon',
|
||||||
|
'.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4',
|
||||||
|
'.ogg': 'audio/ogg', '.flac': 'audio/flac', '.aac': 'audio/aac',
|
||||||
|
'.pdf': 'application/pdf', '.json': 'application/json',
|
||||||
|
'.txt': 'text/plain', '.md': 'text/markdown',
|
||||||
|
};
|
||||||
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||||
|
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ import { Toaster } from "@/components/ui/sonner"
|
||||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||||
import { OnboardingModal } from '@/components/onboarding-modal'
|
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||||
|
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||||
|
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||||
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
|
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
|
||||||
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
||||||
|
|
||||||
|
|
@ -108,6 +110,8 @@ const toToolState = (status: ToolCall['status']): ToolState => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||||
|
|
||||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||||
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
|
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
|
||||||
const graphPalette = [
|
const graphPalette = [
|
||||||
|
|
@ -1766,7 +1770,7 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<Message key={item.id} from={item.role}>
|
<Message key={item.id} from={item.role}>
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
<MessageResponse>{item.content}</MessageResponse>
|
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</Message>
|
</Message>
|
||||||
)
|
)
|
||||||
|
|
@ -1948,6 +1952,7 @@ function App() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<FileCardProvider onOpenKnowledgeFile={(path) => { setSelectedPath(path); setIsGraphOpen(false) }}>
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
<ScrollPositionPreserver />
|
<ScrollPositionPreserver />
|
||||||
|
|
@ -2004,7 +2009,7 @@ function App() {
|
||||||
{currentAssistantMessage && (
|
{currentAssistantMessage && (
|
||||||
<Message from="assistant">
|
<Message from="assistant">
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
<MessageResponse>{currentAssistantMessage}</MessageResponse>
|
<MessageResponse components={streamdownComponents}>{currentAssistantMessage}</MessageResponse>
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</Message>
|
</Message>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2042,6 +2047,7 @@ function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FileCardProvider>
|
||||||
)}
|
)}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|
||||||
|
|
@ -2071,6 +2077,7 @@ function App() {
|
||||||
permissionResponses={permissionResponses}
|
permissionResponses={permissionResponses}
|
||||||
onPermissionResponse={handlePermissionResponse}
|
onPermissionResponse={handlePermissionResponse}
|
||||||
onAskHumanResponse={handleAskHumanResponse}
|
onAskHumanResponse={handleAskHumanResponse}
|
||||||
|
onOpenKnowledgeFile={(path) => { setSelectedPath(path); setIsGraphOpen(false) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { BookOpen, FileIcon, FileText, Image, Music, Pause, Play, Video } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useFileCard } from '@/contexts/file-card-context'
|
||||||
|
import { useSidebarSection } from '@/contexts/sidebar-context'
|
||||||
|
import { wikiLabel } from '@/lib/wiki-links'
|
||||||
|
|
||||||
|
const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.m4a', '.ogg', '.flac', '.aac'])
|
||||||
|
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
|
||||||
|
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm'])
|
||||||
|
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.rtf', '.csv'])
|
||||||
|
|
||||||
|
function getExtension(filePath: string): string {
|
||||||
|
const dot = filePath.lastIndexOf('.')
|
||||||
|
return dot >= 0 ? filePath.slice(dot).toLowerCase() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileNameWithoutExt(filePath: string): string {
|
||||||
|
const name = filePath.split('/').pop() || filePath
|
||||||
|
const dot = name.lastIndexOf('.')
|
||||||
|
return dot > 0 ? name.slice(0, dot) : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileCategory(ext: string): { label: string; icon: typeof FileIcon } {
|
||||||
|
if (AUDIO_EXTENSIONS.has(ext)) return { label: 'Audio', icon: Music }
|
||||||
|
if (IMAGE_EXTENSIONS.has(ext)) return { label: 'Image', icon: Image }
|
||||||
|
if (VIDEO_EXTENSIONS.has(ext)) return { label: 'Video', icon: Video }
|
||||||
|
if (DOCUMENT_EXTENSIONS.has(ext)) return { label: 'Document', icon: FileText }
|
||||||
|
if (ext === '.md') return { label: 'Markdown', icon: FileText }
|
||||||
|
return { label: 'File', icon: FileIcon }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtLabel(ext: string): string {
|
||||||
|
return ext ? ext.slice(1).toUpperCase() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared card shell used by all variants
|
||||||
|
function CardShell({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onClick,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
onClick?: () => void
|
||||||
|
action?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={onClick ? 'button' : undefined}
|
||||||
|
tabIndex={onClick ? 0 : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3 pr-4 text-left transition-colors hover:bg-accent/50 cursor-pointer w-full my-2"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Knowledge File Card ---
|
||||||
|
|
||||||
|
function KnowledgeFileCard({ filePath }: { filePath: string }) {
|
||||||
|
const { onOpenKnowledgeFile } = useFileCard()
|
||||||
|
const { setActiveSection } = useSidebarSection()
|
||||||
|
const label = wikiLabel(filePath)
|
||||||
|
const ext = getExtension(filePath)
|
||||||
|
const extLabel = getExtLabel(ext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
icon={<BookOpen className="h-5 w-5 text-muted-foreground" />}
|
||||||
|
title={label}
|
||||||
|
subtitle={extLabel ? `Knowledge \u00b7 ${extLabel}` : 'Knowledge'}
|
||||||
|
onClick={() => { setActiveSection('knowledge'); onOpenKnowledgeFile(filePath) }}
|
||||||
|
action={
|
||||||
|
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audio File Card ---
|
||||||
|
|
||||||
|
function AudioFileCard({ filePath }: { filePath: string }) {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const ext = getExtension(filePath)
|
||||||
|
const extLabel = getExtLabel(ext)
|
||||||
|
|
||||||
|
const handlePlayPause = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isPlaying && audioRef.current) {
|
||||||
|
audioRef.current.pause()
|
||||||
|
setIsPlaying(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioRef.current) {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath })
|
||||||
|
const dataUrl = `data:${result.mimeType};base64,${result.data}`
|
||||||
|
const audio = new Audio(dataUrl)
|
||||||
|
audio.addEventListener('ended', () => setIsPlaying(false))
|
||||||
|
audioRef.current = audio
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load audio:', err)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRef.current.play()
|
||||||
|
setIsPlaying(true)
|
||||||
|
}, [filePath, isPlaying])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause()
|
||||||
|
audioRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
await window.ipc.invoke('shell:openPath', { path: filePath })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
icon={
|
||||||
|
<button
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
>
|
||||||
|
{isPlaying
|
||||||
|
? <Pause className="h-5 w-5 text-muted-foreground" />
|
||||||
|
: <Play className="h-5 w-5 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
title={getFileNameWithoutExt(filePath)}
|
||||||
|
subtitle={`Audio \u00b7 ${extLabel}`}
|
||||||
|
onClick={handleOpen}
|
||||||
|
action={
|
||||||
|
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- System File Card ---
|
||||||
|
|
||||||
|
function SystemFileCard({ filePath }: { filePath: string }) {
|
||||||
|
const ext = getExtension(filePath)
|
||||||
|
const isImage = IMAGE_EXTENSIONS.has(ext)
|
||||||
|
const [thumbnail, setThumbnail] = useState<string | null>(null)
|
||||||
|
const { label: categoryLabel, icon: CategoryIcon } = getFileCategory(ext)
|
||||||
|
const extLabel = getExtLabel(ext)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isImage) return
|
||||||
|
let cancelled = false
|
||||||
|
window.ipc.invoke('shell:readFileBase64', { path: filePath })
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setThumbnail(`data:${result.mimeType};base64,${result.data}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {/* ignore thumbnail failures */})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [filePath, isImage])
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
await window.ipc.invoke('shell:openPath', { path: filePath })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
icon={
|
||||||
|
thumbnail
|
||||||
|
? <img src={thumbnail} alt="" className="h-10 w-10 rounded-lg object-cover" />
|
||||||
|
: <CategoryIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
title={getFileNameWithoutExt(filePath)}
|
||||||
|
subtitle={extLabel ? `${categoryLabel} \u00b7 ${extLabel}` : categoryLabel}
|
||||||
|
onClick={handleOpen}
|
||||||
|
action={
|
||||||
|
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main FilePathCard ---
|
||||||
|
|
||||||
|
export function FilePathCard({ filePath }: { filePath: string }) {
|
||||||
|
const trimmed = filePath.trim()
|
||||||
|
|
||||||
|
if (trimmed.startsWith('knowledge/')) {
|
||||||
|
return <KnowledgeFileCard filePath={trimmed} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = getExtension(trimmed)
|
||||||
|
if (AUDIO_EXTENSIONS.has(ext)) {
|
||||||
|
return <AudioFileCard filePath={trimmed} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SystemFileCard filePath={trimmed} />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { isValidElement, type JSX } from 'react'
|
||||||
|
import { FilePathCard } from './file-path-card'
|
||||||
|
|
||||||
|
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
||||||
|
const { children, ...rest } = props
|
||||||
|
|
||||||
|
// Check if the child is a <code> with className "language-filepath"
|
||||||
|
if (isValidElement(children)) {
|
||||||
|
const childProps = children.props as { className?: string; children?: unknown }
|
||||||
|
if (
|
||||||
|
typeof childProps.className === 'string' &&
|
||||||
|
childProps.className.includes('language-filepath')
|
||||||
|
) {
|
||||||
|
// Extract the text content from the code element
|
||||||
|
const text = typeof childProps.children === 'string'
|
||||||
|
? childProps.children.trim()
|
||||||
|
: ''
|
||||||
|
if (text) {
|
||||||
|
return <FilePathCard filePath={text} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passthrough for all other code blocks - return children directly
|
||||||
|
// so Streamdown's own rendering (syntax highlighting, etc.) is preserved
|
||||||
|
return <pre {...rest}>{children}</pre>
|
||||||
|
}
|
||||||
|
|
@ -50,7 +50,7 @@ export const MessageContent = ({
|
||||||
className={cn(
|
className={cn(
|
||||||
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
|
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
|
||||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||||
"group-[.is-assistant]:text-foreground",
|
"group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import { getMentionHighlightSegments } from '@/lib/mention-highlights'
|
||||||
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
|
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
|
||||||
import z from 'zod'
|
import z from 'zod'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||||
|
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -103,6 +105,8 @@ const normalizeToolOutput = (output: ToolCall['result'] | undefined, status: Too
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||||
|
|
||||||
const MIN_WIDTH = 300
|
const MIN_WIDTH = 300
|
||||||
const MAX_WIDTH = 700
|
const MAX_WIDTH = 700
|
||||||
const DEFAULT_WIDTH = 400
|
const DEFAULT_WIDTH = 400
|
||||||
|
|
@ -131,6 +135,7 @@ interface ChatSidebarProps {
|
||||||
permissionResponses?: Map<string, 'approve' | 'deny'>
|
permissionResponses?: Map<string, 'approve' | 'deny'>
|
||||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void
|
onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void
|
||||||
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
||||||
|
onOpenKnowledgeFile?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatSidebar({
|
export function ChatSidebar({
|
||||||
|
|
@ -156,6 +161,7 @@ export function ChatSidebar({
|
||||||
permissionResponses = new Map(),
|
permissionResponses = new Map(),
|
||||||
onPermissionResponse,
|
onPermissionResponse,
|
||||||
onAskHumanResponse,
|
onAskHumanResponse,
|
||||||
|
onOpenKnowledgeFile,
|
||||||
}: ChatSidebarProps) {
|
}: ChatSidebarProps) {
|
||||||
const [width, setWidth] = useState(defaultWidth)
|
const [width, setWidth] = useState(defaultWidth)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
|
@ -391,7 +397,7 @@ export function ChatSidebar({
|
||||||
<Message key={item.id} from={item.role}>
|
<Message key={item.id} from={item.role}>
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
{item.role === 'assistant' ? (
|
{item.role === 'assistant' ? (
|
||||||
<MessageResponse>{item.content}</MessageResponse>
|
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
|
||||||
) : (
|
) : (
|
||||||
item.content
|
item.content
|
||||||
)}
|
)}
|
||||||
|
|
@ -480,6 +486,7 @@ export function ChatSidebar({
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Conversation area */}
|
{/* Conversation area */}
|
||||||
|
<FileCardProvider onOpenKnowledgeFile={onOpenKnowledgeFile ?? (() => {})}>
|
||||||
<div className="flex min-h-0 flex-1 flex-col relative">
|
<div className="flex min-h-0 flex-1 flex-col relative">
|
||||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
<ScrollPositionPreserver />
|
<ScrollPositionPreserver />
|
||||||
|
|
@ -538,7 +545,7 @@ export function ChatSidebar({
|
||||||
{currentAssistantMessage && (
|
{currentAssistantMessage && (
|
||||||
<Message from="assistant">
|
<Message from="assistant">
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
<MessageResponse>{currentAssistantMessage}</MessageResponse>
|
<MessageResponse components={streamdownComponents}>{currentAssistantMessage}</MessageResponse>
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</Message>
|
</Message>
|
||||||
)}
|
)}
|
||||||
|
|
@ -650,6 +657,7 @@ export function ChatSidebar({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FileCardProvider>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
27
apps/x/apps/renderer/src/contexts/file-card-context.tsx
Normal file
27
apps/x/apps/renderer/src/contexts/file-card-context.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { createContext, useContext, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface FileCardContextType {
|
||||||
|
onOpenKnowledgeFile: (path: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileCardContext = createContext<FileCardContextType | null>(null)
|
||||||
|
|
||||||
|
export function useFileCard() {
|
||||||
|
const ctx = useContext(FileCardContext)
|
||||||
|
if (!ctx) throw new Error('useFileCard must be used within FileCardProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileCardProvider({
|
||||||
|
onOpenKnowledgeFile,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onOpenKnowledgeFile: (path: string) => void
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FileCardContext.Provider value={{ onOpenKnowledgeFile }}>
|
||||||
|
{children}
|
||||||
|
</FileCardContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -179,4 +179,23 @@ When a user asks for ANY task that might require external capabilities (web sear
|
||||||
|
|
||||||
**Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
|
**Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
|
||||||
|
|
||||||
Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do.`;
|
Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do.
|
||||||
|
|
||||||
|
## File Path References
|
||||||
|
|
||||||
|
When you reference a file path in your response (whether a knowledge base file or a file on the user's system), ALWAYS wrap it in a filepath code block:
|
||||||
|
|
||||||
|
\`\`\`filepath
|
||||||
|
knowledge/People/Sarah Chen.md
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`filepath
|
||||||
|
~/Desktop/report.pdf
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
This renders as an interactive card in the UI. Use this format for:
|
||||||
|
- Knowledge base file paths (knowledge/...)
|
||||||
|
- Files on the user's machine (~/Desktop/..., /Users/..., etc.)
|
||||||
|
- Audio files, images, documents, or any file reference
|
||||||
|
|
||||||
|
Never output raw file paths in plain text when they could be wrapped in a filepath block.`;
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,15 @@ const ipcSchemas = {
|
||||||
success: z.literal(true),
|
success: z.literal(true),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// Shell integration channels
|
||||||
|
'shell:openPath': {
|
||||||
|
req: z.object({ path: z.string() }),
|
||||||
|
res: z.object({ error: z.string().optional() }),
|
||||||
|
},
|
||||||
|
'shell:readFileBase64': {
|
||||||
|
req: z.object({ path: z.string() }),
|
||||||
|
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue