jan/web-app/src/containers/ArtifactsSidebar.tsx
Nicholai 4e92884d51 feat: add Claude-style artifacts for persistent workspace documents
Implement a comprehensive artifacts system that allows users to create, edit,
and manage persistent documents alongside conversations, inspired by Claude's
artifacts feature.

Key Features:
- AI model integration with system prompts teaching artifact usage
- Inline preview cards in chat messages with collapsible previews
- On-demand side panel overlay (replaces split view)
- Preview/Code toggle for rendered markdown vs raw content
- Artifacts sidebar for managing multiple artifacts per thread
- Monaco editor integration for code artifacts
- Autosave with debounced writes and conflict detection
- Diff preview system for proposed updates
- Keyboard shortcuts and quick switcher
- Export/import functionality

Backend (Rust):
- New artifacts module in src-tauri/src/core/artifacts/
- 12 Tauri commands for CRUD, proposals, and export/import
- Atomic file writes with SHA256 hashing
- Sharded history storage with pruning policy
- Path traversal validation and UTF-8 enforcement

Frontend (TypeScript/React):
- ArtifactSidePanel: Claude-style overlay panel from right
- InlineArtifactCard: Preview cards embedded in chat
- ArtifactsSidebar: Floating list for artifact switching
- Enhanced ArtifactActionMessage: Parses AI metadata from content
- useArtifacts: Zustand store with autosave and conflict resolution

Types:
- Extended ThreadMessage.metadata with ArtifactAction
- ProposedContentRef supports inline and temp storage
- MIME-like content_type values for extensibility

Platform:
- Fixed platform detection to check window.__TAURI__
- Web service stubs for browser mode compatibility
- Updated assistant system prompts in both extension and web-app

This implements the complete workflow studied from Claude:
1. AI only creates artifacts when explicitly requested
2. Inline cards appear in chat with preview buttons
3. Side panel opens on demand, not automatic split
4. Users can toggle Preview/Code views and edit content
5. Autosave and version management prevent data loss
2025-11-02 12:19:36 -07:00

123 lines
4.2 KiB
TypeScript

/**
* ArtifactsSidebar - Floating list of all artifacts in current conversation
*
* Appears in bottom-right of the side panel.
* Lists all artifacts with download buttons and click-to-switch functionality.
*/
import { useArtifacts } from '@/hooks/useArtifacts'
import { cn } from '@/lib/utils'
import { IconDownload, IconFileText, IconCode } from '@tabler/icons-react'
type ArtifactsSidebarProps = {
threadId: string
}
export function ArtifactsSidebar({ threadId }: ArtifactsSidebarProps) {
const artifacts = useArtifacts()
const threadArtifacts = artifacts.threadArtifacts[threadId] || []
const activeArtifactId = artifacts.threadIndex[threadId]?.active_artifact_id
const handleDownloadAll = () => {
// Download all artifacts as individual files
threadArtifacts.forEach((artifact: any) => {
const blob = new Blob([artifact.content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = artifact.file_path.split('/').pop() || 'artifact.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
})
}
const handleSelectArtifact = (artifactId: string) => {
artifacts.setActiveArtifact(threadId, artifactId)
}
const handleDownloadSingle = (artifact: any, e: React.MouseEvent) => {
e.stopPropagation() // Prevent selecting the artifact
const blob = new Blob([artifact.content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = artifact.file_path.split('/').pop() || 'artifact.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const getArtifactIcon = (artifact: any) => {
if (artifact.content_type.startsWith('text/x-')) {
return <IconCode size={14} />
}
return <IconFileText size={14} />
}
const getFileType = (artifact: any) => {
const ext = artifact.file_path.split('.').pop()
return ext?.toUpperCase() || 'TXT'
}
if (threadArtifacts.length === 0) {
return null
}
return (
<div className="bg-background border border-main-view-fg/10 rounded-lg shadow-lg overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-main-view-fg/10 bg-main-view-fg/5">
<h3 className="text-xs font-semibold text-main-view-fg">Artifacts</h3>
{threadArtifacts.length > 1 && (
<button
onClick={handleDownloadAll}
className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-main-view-fg/70 hover:text-main-view-fg hover:bg-main-view-fg/10 rounded transition-colors"
>
<IconDownload size={12} />
Download all
</button>
)}
</div>
{/* Artifact List */}
<div className="max-h-64 overflow-y-auto">
{threadArtifacts.map((artifact) => (
<button
key={artifact.id}
onClick={() => handleSelectArtifact(artifact.id)}
className={cn(
"w-full flex items-center gap-2 p-3 text-left transition-colors border-b border-main-view-fg/5 last:border-b-0",
artifact.id === activeArtifactId
? "bg-main-view-fg/10"
: "hover:bg-main-view-fg/5"
)}
>
<div className="flex-shrink-0 text-main-view-fg/60">
{getArtifactIcon(artifact)}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-main-view-fg truncate">
{artifact.name}
</div>
<div className="text-xs text-main-view-fg/50 mt-0.5">
{artifact.content_type === 'text/markdown' ? 'Document' : 'Code'} · {getFileType(artifact)}
</div>
</div>
<button
onClick={(e) => handleDownloadSingle(artifact, e)}
className="flex-shrink-0 p-1.5 hover:bg-main-view-fg/10 rounded transition-colors"
title="Download"
>
<IconDownload size={14} />
</button>
</button>
))}
</div>
</div>
)
}