diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index eab052be3..ced7c1097 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -73,6 +73,8 @@ import { LiveNoteSidebar } from '@/components/live-note-sidebar' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BrowserPane } from '@/components/browser-pane/BrowserPane' import { VersionHistoryPanel } from '@/components/version-history-panel' +import { ChatFilesPanel } from '@/components/chat-files-panel' +import { collectSessionFiles } from '@/lib/session-files' import { FileCardProvider } from '@/contexts/file-card-context' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { CaffeinateIndicator } from '@/components/caffeinate-indicator' @@ -5288,6 +5290,12 @@ function App() { if (isBrowserOpen) { dismissBrowserOverlay() } + // Re-navigating to the already-open view while the chat pane is + // maximized over it should reveal it (e.g. clicking "Open" on a PDF + // that is already the current file) — mirror applyViewState. + if (isRightPaneMaximized) { + setIsRightPaneMaximized(false) + } return } @@ -5298,7 +5306,7 @@ function App() { } setHistory(nextHistory) await applyViewState(nextView) - }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay]) + }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay, isRightPaneMaximized]) // Move the maximized/full-screen chat into the right side pane: restore the // view we expanded from (or fall back to Home) and dock the chat on the right. @@ -6690,6 +6698,13 @@ function App() { permissionResponses, autoPermissionDecisions, ]) + // Files the agent created/modified in the active chat — drives the header + // Files button and the right-hand panel (full-screen chat only). + const [chatFilesPanelOpen, setChatFilesPanelOpen] = useState(false) + const chatSessionFiles = React.useMemo( + () => collectSessionFiles(activeChatTabState.conversation), + [activeChatTabState.conversation], + ) const emptyChatTabState = React.useMemo(() => createEmptyChatTabViewState(), []) const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { if (tabId === activeChatTabId) return activeChatTabState @@ -6852,6 +6867,9 @@ function App() { sessionUsage={activeChatTabState.sessionUsage} onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} + filesCount={chatSessionFiles.length} + filesPanelOpen={chatFilesPanelOpen} + onToggleFilesPanel={() => setChatFilesPanelOpen(v => !v)} /> ) : ( ) : ( { navigateToFile(path) }}> +
{chatTabs.map((tab) => { @@ -7513,6 +7532,13 @@ function App() {
+ {chatFilesPanelOpen && ( + setChatFilesPanelOpen(false)} + /> + )} +
)} @@ -7612,6 +7638,9 @@ function App() { isToolOpenForTab={isToolOpenForTab} onToolOpenChangeForTab={setToolOpenForTab} onOpenKnowledgeFile={(path) => { navigateToFile(path) }} + sessionFiles={chatSessionFiles} + filesPanelOpen={chatFilesPanelOpen} + onToggleFilesPanel={() => setChatFilesPanelOpen(v => !v)} onActivate={() => setActiveShortcutPane('right')} collapsedLeftPaddingPx={collapsedLeftPaddingPx} isRecording={isRecording} diff --git a/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx index c178c4d0e..4ab3f564d 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx @@ -1,14 +1,21 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { BookOpen, FileIcon, FileText, Image, Music, Pause, Play, Video } from 'lucide-react' +import { FolderOpen, Pause, Play } 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' +import { cn } from '@/lib/utils' 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']) +const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.doc', '.docx', '.txt', '.rtf']) +const SPREADSHEET_EXTENSIONS = new Set(['.csv', '.tsv', '.xls', '.xlsx']) +const ARCHIVE_EXTENSIONS = new Set(['.zip', '.rar', '.7z', '.tar', '.gz']) +const CODE_EXTENSIONS = new Set([ + '.js', '.jsx', '.ts', '.tsx', '.json', '.yaml', '.yml', '.toml', '.xml', + '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.sh', '.sql', '.html', '.css', +]) function getExtension(filePath: string): string { const dot = filePath.lastIndexOf('.') @@ -21,20 +28,55 @@ function getFileNameWithoutExt(filePath: string): string { 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 getCategoryLabel(ext: string): string { + if (AUDIO_EXTENSIONS.has(ext)) return 'Audio' + if (IMAGE_EXTENSIONS.has(ext)) return 'Image' + if (VIDEO_EXTENSIONS.has(ext)) return 'Video' + if (DOCUMENT_EXTENSIONS.has(ext)) return 'Document' + if (SPREADSHEET_EXTENSIONS.has(ext)) return 'Spreadsheet' + if (ARCHIVE_EXTENSIONS.has(ext)) return 'Archive' + if (ext === '.md') return 'Markdown' + if (CODE_EXTENSIONS.has(ext)) return 'Code' + return 'File' } function getExtLabel(ext: string): string { return ext ? ext.slice(1).toUpperCase() : '' } -// Shared card shell used by all variants +/** Accent color for the extension label on the page glyph, by file type. */ +function extAccentClass(ext: string): string { + if (ext === '.pdf') return 'text-red-600 dark:text-red-400' + if (SPREADSHEET_EXTENSIONS.has(ext)) return 'text-emerald-600 dark:text-emerald-400' + if (AUDIO_EXTENSIONS.has(ext)) return 'text-fuchsia-600 dark:text-fuchsia-400' + if (VIDEO_EXTENSIONS.has(ext)) return 'text-violet-600 dark:text-violet-400' + if (ARCHIVE_EXTENSIONS.has(ext)) return 'text-amber-600 dark:text-amber-400' + if (DOCUMENT_EXTENSIONS.has(ext)) return 'text-blue-600 dark:text-blue-400' + return 'text-muted-foreground' +} + +/** + * A small document-page glyph with a folded corner. Shows the file's + * extension (colored by type) — or arbitrary content (audio play button). + */ +function PageGlyph({ ext, children }: { ext?: string; children?: React.ReactNode }) { + return ( +
+
+ {children ?? ( + + {ext ? getExtLabel(ext) : 'FILE'} + + )} +
+ ) +} + +// Shared card shell used by all variants: page glyph, two-line text block, +// always-visible Open plus a hover-revealed reveal-in-Finder action. function CardShell({ icon, title, @@ -54,20 +96,43 @@ function CardShell({ 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" + title={title} + className="group my-2 flex w-full cursor-pointer items-center gap-3.5 rounded-xl border border-border/60 bg-card py-3 pl-3.5 pr-3 text-left transition-all hover:border-border hover:bg-accent/40 hover:shadow-sm" > -
- {icon} -
-
-
{title}
-
{subtitle}
+ {icon} +
+
{title}
+
{subtitle}
{action}
) } +function OpenAction({ filePath, showReveal }: { filePath: string; showReveal?: boolean }) { + return ( +
+ {showReveal && ( + + )} + +
+ ) +} + // --- Knowledge File Card --- function KnowledgeFileCard({ filePath }: { filePath: string }) { @@ -79,15 +144,11 @@ function KnowledgeFileCard({ filePath }: { filePath: string }) { return ( } + icon={} title={label} - subtitle={extLabel ? `Knowledge \u00b7 ${extLabel}` : 'Knowledge'} + subtitle={extLabel ? `Knowledge · ${extLabel}` : 'Knowledge'} onClick={() => { setActiveSection('knowledge'); onOpenKnowledgeFile(filePath) }} - action={ - - } + action={} /> ) } @@ -145,25 +206,21 @@ function AudioFileCard({ filePath }: { filePath: string }) { return ( - {isPlaying - ? - : - } - + + + } title={getFileNameWithoutExt(filePath)} - subtitle={`Audio \u00b7 ${extLabel}`} + subtitle={`Audio · ${extLabel}`} onClick={handleOpen} - action={ - - } + action={} /> ) } @@ -174,8 +231,12 @@ function SystemFileCard({ filePath }: { filePath: string }) { const ext = getExtension(filePath) const isImage = IMAGE_EXTENSIONS.has(ext) const [thumbnail, setThumbnail] = useState(null) - const { label: categoryLabel, icon: CategoryIcon } = getFileCategory(ext) + const categoryLabel = getCategoryLabel(ext) const extLabel = getExtLabel(ext) + // PDFs open in Rowboat's own viewer (a file tab with the chat alongside); + // everything else still hands off to the OS. + const { onOpenKnowledgeFile: openInApp } = useFileCard() + const isPdf = ext === '.pdf' useEffect(() => { if (!isImage) return @@ -191,6 +252,10 @@ function SystemFileCard({ filePath }: { filePath: string }) { }, [filePath, isImage]) const handleOpen = async () => { + if (isPdf) { + openInApp(filePath) + return + } await window.ipc.invoke('shell:openPath', { path: filePath }) } @@ -198,17 +263,13 @@ function SystemFileCard({ filePath }: { filePath: string }) { - : + ? + : } title={getFileNameWithoutExt(filePath)} - subtitle={extLabel ? `${categoryLabel} \u00b7 ${extLabel}` : categoryLabel} + subtitle={extLabel ? `${categoryLabel} · ${extLabel}` : categoryLabel} onClick={handleOpen} - action={ - - } + action={} /> ) } diff --git a/apps/x/apps/renderer/src/components/chat-files-panel.tsx b/apps/x/apps/renderer/src/components/chat-files-panel.tsx new file mode 100644 index 000000000..59948ebe9 --- /dev/null +++ b/apps/x/apps/renderer/src/components/chat-files-panel.tsx @@ -0,0 +1,52 @@ +import { X } from 'lucide-react' +import { FilePathCard } from '@/components/ai-elements/file-path-card' +import type { SessionFileEntry } from '@/lib/session-files' +import { cn } from '@/lib/utils' + +/** + * Right-hand panel listing every file the agent created or modified in the + * current chat (derived via `collectSessionFiles`). Each row is a + * `FilePathCard`, so knowledge files open in the editor and system files open + * with the OS — same behavior as the inline cards in the conversation. + * Must render inside `FileCardProvider`. + */ +export function ChatFilesPanel({ + files, + onClose, + className, +}: { + files: SessionFileEntry[] + onClose: () => void + /** Override the default fixed-width right-panel layout (e.g. w-full overlay in the side-pane chat). */ + className?: string +}) { + return ( +
+
+ + Files + {files.length > 0 && ( + {files.length} + )} + + +
+
+ {files.length === 0 ? ( +

+ No files in this chat yet. Documents and files the agent creates will show up here. +

+ ) : ( + files.map(file => ) + )} +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-header.tsx b/apps/x/apps/renderer/src/components/chat-header.tsx index d100d3392..1b32d3e89 100644 --- a/apps/x/apps/renderer/src/components/chat-header.tsx +++ b/apps/x/apps/renderer/src/components/chat-header.tsx @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import { ArrowUpRight, Bug, ChevronDown, MessageSquare, MoreHorizontal, Plus } from 'lucide-react' +import { ArrowUpRight, Bug, ChevronDown, Files, MessageSquare, MoreHorizontal, Plus } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -32,6 +32,13 @@ export interface ChatHeaderProps { sessionUsage?: TokenUsage onSelectRun?: (runId: string) => void onOpenChatHistory?: () => void + /** + * Files panel wiring. The button renders when a toggle handler is provided + * and the chat has produced at least one file. + */ + filesCount?: number + filesPanelOpen?: boolean + onToggleFilesPanel?: () => void } /** @@ -49,6 +56,9 @@ export function ChatHeader({ sessionUsage, onSelectRun, onOpenChatHistory, + filesCount = 0, + filesPanelOpen = false, + onToggleFilesPanel, }: ChatHeaderProps) { const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory) const showUsage = hasTokenUsage(sessionUsage) @@ -136,6 +146,30 @@ export function ChatHeader({ align="end" /> )} + {onToggleFilesPanel && filesCount > 0 && ( + + + + + Files created in this chat + + )}