Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion apps/x/apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}

Expand All @@ -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.
Expand Down Expand Up @@ -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<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
if (tabId === activeChatTabId) return activeChatTabState
Expand Down Expand Up @@ -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)}
/>
) : (
<TabBar
Expand Down Expand Up @@ -7418,6 +7436,7 @@ function App() {
</div>
) : (
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
{chatTabs.map((tab) => {
Expand Down Expand Up @@ -7513,6 +7532,13 @@ function App() {
</div>
</div>
</div>
{chatFilesPanelOpen && (
<ChatFilesPanel
files={chatSessionFiles}
onClose={() => setChatFilesPanelOpen(false)}
/>
)}
</div>
</FileCardProvider>
)}
</SidebarInset>
Expand Down Expand Up @@ -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}
Expand Down
159 changes: 110 additions & 49 deletions apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx
Original file line number Diff line number Diff line change
@@ -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('.')
Expand All @@ -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 (
<div className="relative flex h-11 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-background shadow-xs dark:bg-muted/60">
<div
className="absolute -right-px -top-px size-3 rounded-bl-md border-b border-l border-border bg-muted"
aria-hidden
/>
{children ?? (
<span className={cn('text-[8.5px] font-bold tracking-wider', ext ? extAccentClass(ext) : 'text-muted-foreground')}>
{ext ? getExtLabel(ext) : 'FILE'}
</span>
)}
</div>
)
}

// 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,
Expand All @@ -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"
>
<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>
{icon}
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium leading-tight text-foreground">{title}</div>
<div className="truncate pt-0.5 text-[11.5px] leading-tight text-muted-foreground">{subtitle}</div>
</div>
{action}
</div>
)
}

function OpenAction({ filePath, showReveal }: { filePath: string; showReveal?: boolean }) {
return (
<div className="flex shrink-0 items-center gap-1">
{showReveal && (
<button
type="button"
title="Reveal in Finder"
aria-label="Reveal in Finder"
onClick={(e) => {
e.stopPropagation()
void window.ipc.invoke('shell:showItemInFolder', { path: filePath })
}}
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
>
<FolderOpen className="size-4" />
</button>
)}
<Button variant="outline" size="sm" className="pointer-events-none h-8 shrink-0 rounded-lg text-xs">
Open
</Button>
</div>
)
}

// --- Knowledge File Card ---

function KnowledgeFileCard({ filePath }: { filePath: string }) {
Expand All @@ -79,15 +144,11 @@ function KnowledgeFileCard({ filePath }: { filePath: string }) {

return (
<CardShell
icon={<BookOpen className="h-5 w-5 text-muted-foreground" />}
icon={<PageGlyph ext={ext} />}
title={label}
subtitle={extLabel ? `Knowledge \u00b7 ${extLabel}` : 'Knowledge'}
subtitle={extLabel ? `Knowledge · ${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>
}
action={<OpenAction filePath={filePath} />}
/>
)
}
Expand Down Expand Up @@ -145,25 +206,21 @@ function AudioFileCard({ filePath }: { filePath: string }) {
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>
<PageGlyph ext={ext}>
<button
onClick={handlePlayPause}
disabled={isLoading}
aria-label={isPlaying ? 'Pause' : 'Play'}
className="flex h-full w-full items-center justify-center text-foreground"
>
{isPlaying ? <Pause className="size-4" /> : <Play className="size-4" />}
</button>
</PageGlyph>
}
title={getFileNameWithoutExt(filePath)}
subtitle={`Audio \u00b7 ${extLabel}`}
subtitle={`Audio · ${extLabel}`}
onClick={handleOpen}
action={
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
Open
</Button>
}
action={<OpenAction filePath={filePath} showReveal />}
/>
)
}
Expand All @@ -174,8 +231,12 @@ 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 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
Expand All @@ -191,24 +252,24 @@ function SystemFileCard({ filePath }: { filePath: string }) {
}, [filePath, isImage])

const handleOpen = async () => {
if (isPdf) {
openInApp(filePath)
return
}
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" />
? <img src={thumbnail} alt="" className="h-11 w-11 shrink-0 rounded-md border border-border object-cover" />
: <PageGlyph ext={ext} />
}
title={getFileNameWithoutExt(filePath)}
subtitle={extLabel ? `${categoryLabel} \u00b7 ${extLabel}` : categoryLabel}
subtitle={extLabel ? `${categoryLabel} · ${extLabel}` : categoryLabel}
onClick={handleOpen}
action={
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
Open
</Button>
}
action={<OpenAction filePath={filePath} showReveal />}
/>
)
}
Expand Down
52 changes: 52 additions & 0 deletions apps/x/apps/renderer/src/components/chat-files-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn('flex w-[320px] shrink-0 flex-col border-l border-border bg-background', className)}>
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
<span className="text-sm font-medium text-foreground">
Files
{files.length > 0 && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">{files.length}</span>
)}
</span>
<button
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Close files panel"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-1">
{files.length === 0 ? (
<p className="px-2 py-10 text-center text-xs leading-relaxed text-muted-foreground">
No files in this chat yet. Documents and files the agent creates will show up here.
</p>
) : (
files.map(file => <FilePathCard key={file.path} filePath={file.path} />)
)}
</div>
</div>
)
}
Loading
Loading