file cards in ui

This commit is contained in:
Arjun 2026-02-06 23:44:06 +05:30
parent 6425dbcf28
commit 0c26903ade
8 changed files with 313 additions and 6 deletions

View file

@ -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,
@ -420,5 +422,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 };
},
}); });
} }

View file

@ -52,6 +52,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'
@ -110,6 +112,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 = [
@ -1728,7 +1732,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>
) )
@ -1939,6 +1943,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 />
@ -1995,7 +2000,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>
)} )}
@ -2033,6 +2038,7 @@ function App() {
</div> </div>
</div> </div>
</div> </div>
</FileCardProvider>
)} )}
</SidebarInset> </SidebarInset>
@ -2062,6 +2068,7 @@ function App() {
permissionResponses={permissionResponses} permissionResponses={permissionResponses}
onPermissionResponse={handlePermissionResponse} onPermissionResponse={handlePermissionResponse}
onAskHumanResponse={handleAskHumanResponse} onAskHumanResponse={handleAskHumanResponse}
onOpenKnowledgeFile={(path) => { setSelectedPath(path); setIsGraphOpen(false) }}
/> />
)} )}
</SidebarProvider> </SidebarProvider>

View file

@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { BookOpen, ExternalLink, FileIcon, Pause, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useFileCard } from '@/contexts/file-card-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'])
function getExtension(filePath: string): string {
const dot = filePath.lastIndexOf('.')
return dot >= 0 ? filePath.slice(dot).toLowerCase() : ''
}
function getFileName(filePath: string): string {
return filePath.split('/').pop() || filePath
}
function truncatePath(filePath: string, maxLen = 40): string {
if (filePath.length <= maxLen) return filePath
const parts = filePath.split('/')
if (parts.length <= 2) return filePath
return `.../${parts.slice(-2).join('/')}`
}
// --- Knowledge File Card ---
function KnowledgeFileCard({ filePath }: { filePath: string }) {
const { onOpenKnowledgeFile } = useFileCard()
const label = wikiLabel(filePath)
return (
<button
onClick={() => onOpenKnowledgeFile(filePath)}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 hover:bg-accent max-w-xs text-left transition-colors cursor-pointer w-full"
>
<BookOpen className="h-5 w-5 shrink-0 text-primary" />
<span className="truncate text-sm font-medium">{label}</span>
</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 handlePlayPause = useCallback(async () => {
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 (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 max-w-xs w-full">
<Button
size="icon"
variant="ghost"
onClick={handlePlayPause}
disabled={isLoading}
className="h-8 w-8 shrink-0"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium">{getFileName(filePath)}</div>
<div className="truncate text-xs text-muted-foreground">{truncatePath(filePath)}</div>
</div>
<Button
size="icon"
variant="ghost"
onClick={handleOpen}
className="h-7 w-7 shrink-0"
title="Open externally"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
)
}
// --- 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)
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 (
<button
onClick={handleOpen}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 hover:bg-accent max-w-xs text-left transition-colors cursor-pointer w-full"
>
{thumbnail ? (
<img src={thumbnail} alt="" className="h-10 w-10 rounded object-cover shrink-0" />
) : (
<FileIcon className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium">{getFileName(filePath)}</div>
<div className="truncate text-xs text-muted-foreground">{truncatePath(filePath)}</div>
</div>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</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} />
}

View file

@ -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>
}

View file

@ -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>

View 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>
)
}

View file

@ -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.`;

View file

@ -381,6 +381,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;
// ============================================================================ // ============================================================================