Skip to content
Merged
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
60 changes: 55 additions & 5 deletions frontend/src/app/workspace/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
import { DocumentViewer } from '@/components/DocumentViewer';
import { DeleteDocumentDialog } from '@/components/DeleteDocumentDialog';
import { ProviderSelectionProvider } from '@/contexts/ProviderSelectionContext';
import type { Upload } from '@/components/UploadProgress';
import type { Upload, ProcessingPhase } from '@/components/UploadProgress';
import { getWorkspace } from '@/lib/api';
import { uploadDocuments, getDocumentStatus, deleteDocument, type Document } from '@/lib/documents';
import { uploadDocuments, getDocumentStatus, deleteDocument, uploadUrl, detectUrlType, type Document } from '@/lib/documents';

Check warning on line 23 in frontend/src/app/workspace/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Lint, Build & Unit Test

'detectUrlType' is defined but never used
import { toast } from 'sonner';
import type { Workspace } from '@/lib/types';
import type { CitationData } from '@/types/citation';
Expand Down Expand Up @@ -81,12 +81,14 @@
upload.fileName === fileName
? {
...upload,
status: (status.status === 'processing' || status.status === 'embedding') ? 'uploading' :
status: (status.status === 'processing' || status.status === 'embedding' || status.status === 'fetching') ? 'uploading' :
status.status === 'ready' ? 'success' :
status.status === 'error' ? 'error' : upload.status,
progress: (status.status === 'processing' || status.status === 'embedding') ? 95 :
progress: (status.status === 'processing' || status.status === 'embedding' || status.status === 'fetching') ? 95 :
status.status === 'ready' ? 100 : upload.progress,
phase: status.phase ?? undefined, // Pass phase for detailed progress indicator
phase: status.status === 'fetching'
? 'fetching' as ProcessingPhase
: status.phase ?? undefined,
}
: upload
)
Expand Down Expand Up @@ -203,6 +205,53 @@
}
};

// Handle URL submission for ingestion
const handleUrlSubmit = useCallback(async (url: string, sourceType: 'url' | 'youtube' | 'pdf') => {
if (!workspace) return;

const displayName = url.length > 50 ? url.substring(0, 47) + '...' : url;

// Add to uploads state for progress display
const newUpload: Upload = {
fileName: displayName,
progress: 10,
status: 'uploading',
};
setUploads(prev => [...prev, newUpload]);

try {
const response = await uploadUrl(workspace.id, url, sourceType);

// Update upload progress
setUploads(prev =>
prev.map(u => u.fileName === displayName
? { ...u, progress: 50, status: 'uploading' as const }
: u
)
);

toast.success(`URL submitted - ${response.message}`);
setDocumentListRefresh(prev => prev + 1);

// Start polling (reuse existing pollDocumentStatus)
const interval = setInterval(() => {
pollDocumentStatus(response.id, displayName);
}, 2000);
pollingIntervalsRef.current.set(response.id, interval);
pollDocumentStatus(response.id, displayName);

} catch (err) {
const error = err instanceof Error ? err : new Error('URL upload failed');
setUploads(prev =>
prev.map(u => u.fileName === displayName
? { ...u, status: 'error' as const, error: error.message }
: u
)
);
toast.error(error.message);
}
}, [workspace, pollDocumentStatus]);

// Handle document selection for viewing
const handleSelectDocument = (doc: Document) => {
setSelectedDocumentId(doc.id);
Expand Down Expand Up @@ -346,6 +395,7 @@
onUploadError={handleUploadError}
onCancelUpload={handleCancel}
onRetryUpload={handleRetry}
onUrlSubmit={handleUrlSubmit}
/>
</div>

Expand Down
32 changes: 29 additions & 3 deletions frontend/src/components/DocumentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
import { getDocuments, type Document } from '@/lib/documents';
import { FileText, Trash2, Loader2 } from 'lucide-react';
import { FileText, Trash2, Loader2, Globe, Play, FileType } from 'lucide-react';
import { cn } from '@/lib/utils';

interface DocumentListProps {
Expand Down Expand Up @@ -55,6 +55,32 @@ function getStatusStyles(status: string) {
}
}

/**
* Get the appropriate icon component for a document based on its source type.
* URL documents have null fileType, so we detect type from fileName pattern.
*/
function getDocumentIcon(doc: Document, className: string) {
const name = doc.fileName.toLowerCase();

// YouTube URLs
if (name.includes('youtube.com/watch') || name.includes('youtu.be/')) {
return <Play className={className} data-testid="doc-icon-youtube" />;
}

// PDF URLs (null fileType + .pdf in URL)
if (doc.fileType === null && (name.endsWith('.pdf') || name.includes('.pdf?'))) {
return <FileType className={className} data-testid="doc-icon-pdf" />;
}

// Web URLs (null fileType, not YouTube/PDF)
if (doc.fileType === null) {
return <Globe className={className} data-testid="doc-icon-globe" />;
}

// Default: regular file upload
return <FileText className={className} data-testid="doc-icon-file" />;
}

export function DocumentList({
workspaceId,
onSelectDocument,
Expand Down Expand Up @@ -135,7 +161,7 @@ export function DocumentList({
onClick={() => onSelectDocument(doc)}
data-testid={`collapsed-doc-${doc.id}`}
>
<FileText className={cn("h-4 w-4", styles.icon)} />
{getDocumentIcon(doc, cn("h-4 w-4", styles.icon))}
</button>
</TooltipTrigger>
<TooltipContent side="right">
Expand Down Expand Up @@ -167,7 +193,7 @@ export function DocumentList({
onClick={() => onSelectDocument(doc)}
>
{/* Icon - color indicates status */}
<FileText className={cn("h-4 w-4 flex-shrink-0", styles.icon)} />
{getDocumentIcon(doc, cn("h-4 w-4 flex-shrink-0", styles.icon))}

{/* File name - takes full available space */}
<p className="text-xs font-medium truncate flex-1 min-w-0" title={doc.fileName}>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/DocumentSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface DocumentSidebarProps {
onCancelUpload: (fileName: string) => void;
/** Callback to retry a failed upload (kept for interface compatibility) */
onRetryUpload: (fileName: string) => void;
/** Callback when a URL is submitted for ingestion */
onUrlSubmit?: (url: string, sourceType: 'url' | 'youtube' | 'pdf') => void;
}

/**
Expand All @@ -60,6 +62,7 @@ export function DocumentSidebar({
onUploadError,
onCancelUpload: _onCancelUpload = () => { }, // eslint-disable-line @typescript-eslint/no-unused-vars -- Kept for interface compatibility
onRetryUpload: _onRetryUpload = () => { }, // eslint-disable-line @typescript-eslint/no-unused-vars -- Kept for interface compatibility
onUrlSubmit,
}: DocumentSidebarProps) {
const [isCollapsed, setIsCollapsed] = useLocalStorage('sidebar-collapsed', false);
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
Expand Down Expand Up @@ -139,6 +142,7 @@ export function DocumentSidebar({
onFilesSelected={onFilesSelected}
onUploadComplete={onUploadComplete}
onUploadError={onUploadError}
onUrlSubmit={onUrlSubmit}
/>
</>
);
Expand Down
82 changes: 79 additions & 3 deletions frontend/src/components/UploadModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,26 @@
* UploadModal component
*
* Modal dialog wrapping the IngestionDropzone for document uploads.
* Also includes URL input for web page, YouTube, and PDF ingestion.
* Triggered from the sidebar "Add Source" button per UX spec.
*/

'use client';

import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { IngestionDropzone } from '@/components/IngestionDropzone';
import { Globe, Play, FileType } from 'lucide-react';
import { detectUrlType } from '@/lib/documents';
import type { Document } from '@/lib/documents';

export interface UploadModalProps {
Expand All @@ -30,11 +37,27 @@ export interface UploadModalProps {
onUploadComplete: (documents: Document[]) => void;
/** Callback when upload fails */
onUploadError: (error: Error) => void;
/** Callback when URL is submitted for ingestion */
onUrlSubmit?: (url: string, sourceType: 'url' | 'youtube' | 'pdf') => void;
}

/**
* Get the URL type icon component based on detected type
*/
function UrlTypeIcon({ urlType }: { urlType: 'youtube' | 'pdf' | 'url' }) {
switch (urlType) {
case 'youtube':
return <Play className="h-4 w-4 text-red-500" data-testid="url-type-icon-youtube" />;
case 'pdf':
return <FileType className="h-4 w-4 text-orange-500" data-testid="url-type-icon-pdf" />;
default:
return <Globe className="h-4 w-4 text-blue-500" data-testid="url-type-icon-web" />;
}
}

/**
* Modal dialog for document upload.
* Wraps IngestionDropzone in a Dialog component.
* Wraps IngestionDropzone in a Dialog component with URL input above.
*/
export function UploadModal({
open,
Expand All @@ -43,13 +66,26 @@ export function UploadModal({
onFilesSelected,
onUploadComplete,
onUploadError,
onUrlSubmit,
}: UploadModalProps) {
const [urlValue, setUrlValue] = useState('');

const handleFilesSelected = (files: File[]) => {
onFilesSelected(files);
// Close modal after files are selected (upload will continue in background)
onOpenChange(false);
};

const handleUrlSubmit = () => {
const trimmed = urlValue.trim();
if (!trimmed || !onUrlSubmit) return;
const sourceType = detectUrlType(trimmed);
onUrlSubmit(trimmed, sourceType);
setUrlValue('');
};

const detectedType = urlValue.trim() ? detectUrlType(urlValue) : null;

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
Expand All @@ -59,11 +95,51 @@ export function UploadModal({
<DialogHeader>
<DialogTitle>Add Documents</DialogTitle>
<DialogDescription>
Upload documents to your workspace for RAG-powered conversations.
Upload documents or add URLs to your workspace for RAG-powered conversations.
</DialogDescription>
</DialogHeader>

<div className="mt-4">
<div className="mt-4 space-y-4">
{/* URL Input Section */}
<div className="space-y-2">
<Label htmlFor="url-input">Enter URL (web page, YouTube, PDF)</Label>
<div className="flex items-center gap-2">
{detectedType && <UrlTypeIcon urlType={detectedType} />}
<Input
id="url-input"
data-testid="url-input"
type="text"
placeholder="https://example.com/article"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleUrlSubmit();
}
}}
/>
</div>
<Button
data-testid="add-url-button"
variant="default"
size="sm"
className="w-full"
onClick={handleUrlSubmit}
disabled={!urlValue.trim()}
>
Add URL
</Button>
</div>

{/* Visual separator */}
<div className="flex items-center gap-4" data-testid="url-file-separator">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-muted-foreground">or</span>
<div className="flex-1 h-px bg-border" />
</div>

{/* File Dropzone */}
<IngestionDropzone
workspaceId={workspaceId}
onFilesSelected={handleFilesSelected}
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/UploadProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type UploadStatus = 'uploading' | 'success' | 'error';
/**
* Processing phase for detailed progress indication
*/
export type ProcessingPhase = 'parsing' | 'embedding' | null;
export type ProcessingPhase = 'fetching' | 'parsing' | 'embedding' | null;

export interface Upload {
fileName: string;
Expand Down Expand Up @@ -107,9 +107,11 @@ export function UploadProgress({ uploads, onCancel, onRetry }: UploadProgressPro
)}
{upload.status === 'uploading' && upload.progress >= 95 && (
<p className="text-xs text-muted-foreground mt-1" data-testid="processing-message">
{upload.phase === 'embedding'
? 'Generating Embeddings...'
: 'Parsing document...'}
{upload.phase === 'fetching'
? 'Fetching content...'
: upload.phase === 'embedding'
? 'Generating Embeddings...'
: 'Parsing document...'}
</p>
)}
{upload.error && (
Expand Down
Loading
Loading