task notificaton panel in frontend

This commit is contained in:
phact 2025-07-30 22:42:44 -04:00
parent a282f2a9f8
commit 0d171706e6
13 changed files with 822 additions and 626 deletions

View file

@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View file

@ -22,6 +22,7 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
},
@ -6409,6 +6410,16 @@
"is-arrayish": "^0.3.1"
}
},
"node_modules/sonner": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
"integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View file

@ -23,6 +23,7 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
},

View file

@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Upload, FolderOpen, Loader2 } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
import { useTask } from "@/contexts/task-context"
function AdminPage() {
const [fileUploadLoading, setFileUploadLoading] = useState(false)
@ -14,6 +15,7 @@ function AdminPage() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [folderPath, setFolderPath] = useState("/app/documents/")
const [uploadStatus, setUploadStatus] = useState<string>("")
const { addTask } = useTask()
const handleFileUpload = async (e: React.FormEvent) => {
e.preventDefault()
@ -68,7 +70,7 @@ function AdminPage() {
const result = await response.json()
if (response.status === 201) {
// New flow: Got task ID, start polling
// New flow: Got task ID, use centralized tracking
const taskId = result.task_id || result.id
const totalFiles = result.total_files || 0
@ -76,10 +78,12 @@ function AdminPage() {
throw new Error("No task ID received from server")
}
setUploadStatus(`🔄 Processing started for ${totalFiles} files... (Task ID: ${taskId})`)
// Add task to centralized tracking
addTask(taskId)
// Start polling the task status
await pollPathTaskStatus(taskId, totalFiles)
setUploadStatus(`🔄 Processing started for ${totalFiles} files. Check the task notification panel for real-time progress. (Task ID: ${taskId})`)
setFolderPath("")
setPathUploadLoading(false)
} else if (response.ok) {
// Original flow: Direct response with results
@ -87,72 +91,18 @@ function AdminPage() {
const total = result.results?.length || 0
setUploadStatus(`Path processed successfully! ${successful}/${total} files indexed.`)
setFolderPath("")
setPathUploadLoading(false)
} else {
setUploadStatus(`Error: ${result.error || "Path upload failed"}`)
setPathUploadLoading(false)
}
} catch (error) {
setUploadStatus(`Error: ${error instanceof Error ? error.message : "Path upload failed"}`)
} finally {
setPathUploadLoading(false)
}
}
const pollPathTaskStatus = async (taskId: string, totalFiles: number) => {
const maxAttempts = 120 // Poll for up to 10 minutes (120 * 5s intervals) for large batches
let attempts = 0
const poll = async (): Promise<void> => {
try {
attempts++
const response = await fetch(`/api/tasks/${taskId}`)
if (!response.ok) {
throw new Error(`Failed to check task status: ${response.status}`)
}
const task = await response.json()
if (task.status === 'completed') {
setUploadStatus(`✅ Path processing completed! ${task.successful_files}/${task.total_files} files processed successfully.`)
setFolderPath("")
setPathUploadLoading(false)
} else if (task.status === 'failed' || task.status === 'error') {
setUploadStatus(`❌ Path processing failed: ${task.error || 'Unknown error occurred'}`)
setPathUploadLoading(false)
} else if (task.status === 'pending' || task.status === 'running') {
// Still in progress, update status and continue polling
const processed = task.processed_files || 0
const successful = task.successful_files || 0
const failed = task.failed_files || 0
setUploadStatus(`⏳ Processing files... ${processed}/${totalFiles} processed (${successful} successful, ${failed} failed)`)
// Continue polling if we haven't exceeded max attempts
if (attempts < maxAttempts) {
setTimeout(poll, 5000) // Poll every 5 seconds
} else {
setUploadStatus(`⚠️ Processing timeout after ${attempts} attempts. The task may still be running in the background.`)
setPathUploadLoading(false)
}
} else {
setUploadStatus(`❓ Unknown task status: ${task.status}`)
setPathUploadLoading(false)
}
} catch (error) {
console.error('Task polling error:', error)
setUploadStatus(`❌ Failed to check processing status: ${error instanceof Error ? error.message : 'Unknown error'}`)
setPathUploadLoading(false)
}
}
// Start polling immediately
poll()
}
// Remove the old pollPathTaskStatus function since we're using centralized system
return (
<div className="space-y-8">

View file

@ -13,6 +13,7 @@ export default function AuthCallbackPage() {
const { refreshAuth } = useAuth()
const [status, setStatus] = useState<"processing" | "success" | "error">("processing")
const [error, setError] = useState<string | null>(null)
const [purpose, setPurpose] = useState<string>("app_auth")
const hasProcessed = useRef(false)
useEffect(() => {
@ -32,10 +33,15 @@ export default function AuthCallbackPage() {
const storedConnectorType = localStorage.getItem('connecting_connector_type')
const authPurpose = localStorage.getItem('auth_purpose')
// Determine purpose - default to app_auth for login, data_source for connectors
const detectedPurpose = authPurpose || (storedConnectorType?.includes('drive') ? 'data_source' : 'app_auth')
setPurpose(detectedPurpose)
// Debug logging
console.log('Auth Callback Debug:', {
console.log('OAuth Callback Debug:', {
urlParams: { code: !!code, state: !!state, error: errorParam },
localStorage: { connectorId, storedConnectorType, authPurpose },
detectedPurpose,
fullUrl: window.location.href
})
@ -47,7 +53,7 @@ export default function AuthCallbackPage() {
}
if (!code || !state || !finalConnectorId) {
console.error('Missing auth callback parameters:', {
console.error('Missing OAuth callback parameters:', {
code: !!code,
state: !!state,
finalConnectorId: !!finalConnectorId
@ -70,30 +76,44 @@ export default function AuthCallbackPage() {
const result = await response.json()
if (response.ok && result.purpose === 'app_auth') {
if (response.ok) {
setStatus("success")
// Refresh auth context to pick up the new user
await refreshAuth()
// Clean up localStorage
localStorage.removeItem('connecting_connector_id')
localStorage.removeItem('connecting_connector_type')
localStorage.removeItem('auth_purpose')
// Get redirect URL from login page
const redirectTo = searchParams.get('redirect') || '/'
// Redirect to the original page or home
setTimeout(() => {
router.push(redirectTo)
}, 2000)
if (result.purpose === 'app_auth' || detectedPurpose === 'app_auth') {
// App authentication - refresh auth context and redirect to home/original page
await refreshAuth()
// Get redirect URL from login page
const redirectTo = searchParams.get('redirect') || '/'
// Clean up localStorage
localStorage.removeItem('connecting_connector_id')
localStorage.removeItem('connecting_connector_type')
localStorage.removeItem('auth_purpose')
// Redirect to the original page or home
setTimeout(() => {
router.push(redirectTo)
}, 2000)
} else {
// Connector authentication - redirect to connectors page
// Clean up localStorage
localStorage.removeItem('connecting_connector_id')
localStorage.removeItem('connecting_connector_type')
localStorage.removeItem('auth_purpose')
// Redirect to connectors page with success indicator
setTimeout(() => {
router.push('/connectors?oauth_success=true')
}, 2000)
}
} else {
throw new Error(result.error || 'Authentication failed')
}
} catch (err) {
console.error('Auth callback error:', err)
console.error('OAuth callback error:', err)
setError(err instanceof Error ? err.message : 'Unknown error occurred')
setStatus("error")
@ -107,6 +127,33 @@ export default function AuthCallbackPage() {
handleCallback()
}, [searchParams, router, refreshAuth])
// Dynamic UI content based on purpose
const isAppAuth = purpose === 'app_auth'
const getTitle = () => {
if (status === "processing") {
return isAppAuth ? "Signing you in..." : "Connecting..."
}
if (status === "success") {
return isAppAuth ? "Welcome to GenDB!" : "Connection Successful!"
}
if (status === "error") {
return isAppAuth ? "Sign In Failed" : "Connection Failed"
}
}
const getDescription = () => {
if (status === "processing") {
return isAppAuth ? "Please wait while we complete your sign in..." : "Please wait while we complete the connection..."
}
if (status === "success") {
return "You will be redirected shortly."
}
if (status === "error") {
return isAppAuth ? "There was an issue signing you in." : "There was an issue with the connection."
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md">
@ -115,26 +162,24 @@ export default function AuthCallbackPage() {
{status === "processing" && (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Signing you in...
{getTitle()}
</>
)}
{status === "success" && (
<>
<CheckCircle className="h-5 w-5 text-green-500" />
Welcome to GenDB!
{getTitle()}
</>
)}
{status === "error" && (
<>
<XCircle className="h-5 w-5 text-red-500" />
Sign In Failed
{getTitle()}
</>
)}
</CardTitle>
<CardDescription>
{status === "processing" && "Please wait while we complete your sign in..."}
{status === "success" && "You will be redirected shortly."}
{status === "error" && "There was an issue signing you in."}
{getDescription()}
</CardDescription>
</CardHeader>
<CardContent>
@ -144,12 +189,12 @@ export default function AuthCallbackPage() {
<p className="text-sm text-red-600">{error}</p>
</div>
<Button
onClick={() => router.push('/login')}
onClick={() => router.push(isAppAuth ? '/login' : '/connectors')}
variant="outline"
className="w-full"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Login
{isAppAuth ? 'Back to Login' : 'Back to Connectors'}
</Button>
</div>
)}
@ -157,7 +202,7 @@ export default function AuthCallbackPage() {
<div className="text-center">
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p className="text-sm text-green-600">
Redirecting you to the app...
{isAppAuth ? 'Redirecting you to the app...' : 'Redirecting to connectors...'}
</p>
</div>
</div>

View file

@ -6,6 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input"
import { MessageCircle, Send, Loader2, User, Bot, Zap, Settings, ChevronDown, ChevronRight, Upload } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
import { useAuth } from "@/contexts/auth-context"
import { useTask } from "@/contexts/task-context"
interface Message {
role: "user" | "assistant"
@ -65,6 +67,8 @@ function ChatPage() {
const dragCounterRef = useRef(0)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const { user } = useAuth()
const { addTask } = useTask()
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
@ -120,7 +124,7 @@ function ChatPage() {
console.log("Upload result:", result)
if (response.status === 201) {
// New flow: Got task ID, start polling
// New flow: Got task ID, start tracking with centralized system
const taskId = result.task_id || result.id
if (!taskId) {
@ -128,17 +132,17 @@ function ChatPage() {
throw new Error("No task ID received from server")
}
// Update message to show polling started
// Add task to centralized tracking
addTask(taskId)
// Update message to show task is being tracked
const pollingMessage: Message = {
role: "assistant",
content: `⏳ Upload initiated for **${file.name}**. Processing... (Task ID: ${taskId})`,
content: `⏳ Upload initiated for **${file.name}**. Processing in background... (Task ID: ${taskId})`,
timestamp: new Date()
}
setMessages(prev => [...prev.slice(0, -1), pollingMessage])
// Start polling the task status
await pollTaskStatus(taskId, file.name)
} else if (response.ok) {
// Original flow: Direct response
@ -175,101 +179,7 @@ function ChatPage() {
}
}
const pollTaskStatus = async (taskId: string, filename: string) => {
const maxAttempts = 60 // Poll for up to 5 minutes (60 * 5s intervals)
let attempts = 0
const poll = async (): Promise<void> => {
try {
attempts++
const response = await fetch(`/api/tasks/${taskId}`)
console.log("Task polling response status:", response.status)
if (!response.ok) {
const errorText = await response.text()
console.error("Task polling failed:", response.status, errorText)
throw new Error(`Failed to check task status: ${response.status} - ${errorText}`)
}
const task = await response.json()
console.log("Task polling result:", task)
// Safety check to ensure task object exists
if (!task) {
throw new Error("No task data received from server")
}
// Update the message based on task status
if (task.status === 'completed') {
const successMessage: Message = {
role: "assistant",
content: `✅ **${filename}** processed successfully!\n\n${task.result?.confirmation || 'Document has been added to the knowledge base.'}`,
timestamp: new Date()
}
setMessages(prev => [...prev.slice(0, -1), successMessage])
// Update response ID if available
if (task.result?.response_id) {
setPreviousResponseIds(prev => ({
...prev,
[endpoint]: task.result.response_id
}))
}
} else if (task.status === 'failed' || task.status === 'error') {
const errorMessage: Message = {
role: "assistant",
content: `❌ Processing failed for **${filename}**: ${task.error || 'Unknown error occurred'}`,
timestamp: new Date()
}
setMessages(prev => [...prev.slice(0, -1), errorMessage])
} else if (task.status === 'pending' || task.status === 'running' || task.status === 'processing') {
// Still in progress, update message and continue polling
const progressMessage: Message = {
role: "assistant",
content: `⏳ Processing **${filename}**... (${task.status}) - Attempt ${attempts}/${maxAttempts}`,
timestamp: new Date()
}
setMessages(prev => [...prev.slice(0, -1), progressMessage])
// Continue polling if we haven't exceeded max attempts
if (attempts < maxAttempts) {
setTimeout(poll, 5000) // Poll every 5 seconds
} else {
const timeoutMessage: Message = {
role: "assistant",
content: `⚠️ Processing timeout for **${filename}**. The task may still be running in the background.`,
timestamp: new Date()
}
setMessages(prev => [...prev.slice(0, -1), timeoutMessage])
}
} else {
// Unknown status
const unknownMessage: Message = {
role: "assistant",
content: `❓ Unknown status for **${filename}**: ${task.status}`,
timestamp: new Date()
}
setMessages(prev => [...prev.slice(0, -1), unknownMessage])
}
} catch (error) {
console.error('Task polling error:', error)
const errorMessage: Message = {
role: "assistant",
content: `❌ Failed to check processing status for **${filename}**: ${error instanceof Error ? error.message : 'Unknown error'}`,
timestamp: new Date()
}
setMessages(prev => [...prev.slice(0, -1), errorMessage])
}
}
// Start polling immediately
poll()
}
// Remove the old pollTaskStatus function since we're using centralized system
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()

View file

@ -1,203 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Loader2, CheckCircle, XCircle, ArrowLeft } from "lucide-react"
import { useAuth } from "@/contexts/auth-context"
export default function ConnectorCallbackPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { refreshAuth } = useAuth()
const [status, setStatus] = useState<"processing" | "success" | "error">("processing")
const [error, setError] = useState<string | null>(null)
const [connectorType, setConnectorType] = useState<string | null>(null)
const [isAppAuth, setIsAppAuth] = useState<boolean>(false)
useEffect(() => {
const handleCallback = async () => {
try {
// Get parameters from URL
const code = searchParams.get('code')
const state = searchParams.get('state')
const errorParam = searchParams.get('error')
// Get stored connector info
const connectorId = localStorage.getItem('connecting_connector_id')
const storedConnectorType = localStorage.getItem('connecting_connector_type')
const authPurpose = localStorage.getItem('auth_purpose')
// Debug logging
console.log('OAuth Callback Debug:', {
urlParams: { code: !!code, state: !!state, error: errorParam },
localStorage: { connectorId, storedConnectorType, authPurpose },
fullUrl: window.location.href
})
// Use state parameter as connection_id if localStorage is missing
const finalConnectorId = connectorId || state
const finalConnectorType = storedConnectorType || 'app_auth'
const finalAuthPurpose = authPurpose || 'app_auth'
setConnectorType(finalConnectorType)
setIsAppAuth(finalAuthPurpose === 'app_auth' || finalConnectorType === 'app_auth')
if (errorParam) {
throw new Error(`OAuth error: ${errorParam}`)
}
if (!code || !state || !finalConnectorId) {
console.error('Missing OAuth callback parameters:', {
code: !!code,
state: !!state,
finalConnectorId: !!finalConnectorId
})
throw new Error('Missing required parameters for OAuth callback')
}
// Send callback data to backend
const response = await fetch('/api/auth/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
connection_id: finalConnectorId,
authorization_code: code,
state: state
}),
})
const result = await response.json()
if (response.ok) {
setStatus("success")
if (result.purpose === 'app_auth' || isAppAuth) {
// App authentication - refresh auth context and redirect to home
await refreshAuth()
// Clean up localStorage
localStorage.removeItem('connecting_connector_id')
localStorage.removeItem('connecting_connector_type')
localStorage.removeItem('auth_purpose')
// Redirect to home page after app login
setTimeout(() => {
router.push('/')
}, 2000)
} else {
// Connector authentication - redirect to connectors page
// Clean up localStorage
localStorage.removeItem('connecting_connector_id')
localStorage.removeItem('connecting_connector_type')
localStorage.removeItem('auth_purpose')
// Redirect to connectors page after a short delay
setTimeout(() => {
router.push('/connectors?oauth_success=true')
}, 2000)
}
} else {
throw new Error(result.error || 'Authentication failed')
}
} catch (err) {
console.error('OAuth callback error:', err)
setError(err instanceof Error ? err.message : 'Unknown error occurred')
setStatus("error")
// Clean up localStorage on error too
localStorage.removeItem('connecting_connector_id')
localStorage.removeItem('connecting_connector_type')
localStorage.removeItem('auth_purpose')
}
}
handleCallback()
}, [searchParams, router, refreshAuth])
const getTitle = () => {
if (status === "processing") {
return isAppAuth ? "Signing In..." : "Connecting..."
}
if (status === "success") {
return isAppAuth ? "Sign In Successful!" : "Connection Successful!"
}
if (status === "error") {
return isAppAuth ? "Sign In Failed" : "Connection Failed"
}
}
const getDescription = () => {
if (status === "processing") {
return isAppAuth ? "Please wait while we sign you in..." : "Please wait while we complete the connection..."
}
if (status === "success") {
return "You will be redirected shortly."
}
if (status === "error") {
return isAppAuth ? "There was an issue signing you in." : "There was an issue with the connection."
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
{status === "processing" && (
<>
<Loader2 className="h-5 w-5 animate-spin" />
{getTitle()}
</>
)}
{status === "success" && (
<>
<CheckCircle className="h-5 w-5 text-green-500" />
{getTitle()}
</>
)}
{status === "error" && (
<>
<XCircle className="h-5 w-5 text-red-500" />
{getTitle()}
</>
)}
</CardTitle>
<CardDescription>
{getDescription()}
</CardDescription>
</CardHeader>
<CardContent>
{status === "error" && (
<div className="space-y-4">
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
<Button
onClick={() => router.push(isAppAuth ? '/' : '/connectors')}
variant="outline"
className="w-full"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{isAppAuth ? 'Back to Home' : 'Back to Connectors'}
</Button>
</div>
)}
{status === "success" && (
<div className="text-center">
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p className="text-sm text-green-600">
{isAppAuth ? 'Redirecting to home...' : 'Redirecting to connectors...'}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Loader2, PlugZap, CheckCircle, XCircle, RefreshCw, FileText, Download, AlertCircle } from "lucide-react"
import { useAuth } from "@/contexts/auth-context"
import { useTask } from "@/contexts/task-context"
import { ProtectedRoute } from "@/components/protected-route"
interface Connector {
@ -19,6 +20,7 @@ interface Connector {
status: "not_connected" | "connecting" | "connected" | "error"
type: string
connectionId?: string // Store the active connection ID for syncing
access_token?: string // For connectors that use OAuth
}
interface ConnectorStatus {
@ -33,88 +35,94 @@ interface ConnectorStatus {
}>
}
interface SyncResult {
processed?: number;
added?: number;
skipped?: number;
errors?: number;
error?: string;
message?: string; // For sync started messages
isStarted?: boolean; // For sync started state
}
function ConnectorsPage() {
const { user, isAuthenticated } = useAuth()
const { addTask, refreshTasks } = useTask()
const searchParams = useSearchParams()
const [connectors, setConnectors] = useState<Connector[]>([
{
id: "google_drive",
name: "Google Drive",
description: "Connect your Google Drive to automatically sync documents",
icon: <div className="w-8 h-8 bg-blue-500 rounded flex items-center justify-center text-white font-bold">G</div>,
status: "not_connected",
type: "google_drive"
},
// Future connectors can be added here
// {
// id: "dropbox",
// name: "Dropbox",
// description: "Connect your Dropbox to automatically sync documents",
// icon: <div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-white font-bold">D</div>,
// status: "not_connected",
// type: "dropbox"
// }
])
const [connectors, setConnectors] = useState<Connector[]>([])
const [isConnecting, setIsConnecting] = useState<string | null>(null)
const [isSyncing, setIsSyncing] = useState<string | null>(null)
const [syncResults, setSyncResults] = useState<{ [key: string]: any }>({})
const [syncProgress, setSyncProgress] = useState<{ [key: string]: any }>({})
const [syncResults, setSyncResults] = useState<{[key: string]: SyncResult | null}>({})
const [syncProgress, setSyncProgress] = useState<{[key: string]: number | null}>({})
const [maxFiles, setMaxFiles] = useState<number>(10)
const [isLoading, setIsLoading] = useState(true)
// Function definitions first
const checkConnectorStatuses = async () => {
for (const connector of connectors) {
try {
const response = await fetch(`/api/connectors/status/${connector.type}`)
// Initialize connectors list
setConnectors([
{
id: "google_drive",
name: "Google Drive",
description: "Connect your Google Drive to automatically sync documents",
icon: <div className="w-8 h-8 bg-blue-500 rounded flex items-center justify-center text-white font-bold">G</div>,
status: "not_connected",
type: "google_drive"
},
])
try {
// Check status for each connector type
const connectorTypes = ["google_drive"]
for (const connectorType of connectorTypes) {
const response = await fetch(`/api/connectors/${connectorType}/status`)
if (response.ok) {
const status: ConnectorStatus = await response.json()
const isConnected = status.authenticated
// Find the first active connection to use for syncing
const activeConnection = status.connections?.find(conn => conn.is_active)
const data = await response.json()
const connections = data.connections || []
const activeConnection = connections.find((conn: any) => conn.is_active)
const isConnected = activeConnection !== undefined
setConnectors(prev => prev.map(c =>
c.id === connector.id
c.type === connectorType
? {
...c,
status: isConnected ? "connected" : "not_connected",
connectionId: activeConnection?.connection_id
connectionId: activeConnection?.connection_id
}
: c
))
}
} catch (error) {
console.error(`Failed to check status for ${connector.name}:`, error)
}
} catch (error) {
console.error('Failed to check connector statuses:', error)
} finally {
setIsLoading(false)
}
}
const refreshConnectorStatus = async (connectorId: string) => {
const connector = connectors.find(c => c.id === connectorId)
if (!connector) return
const refreshConnectorStatus = async (connector: Connector) => {
try {
const response = await fetch(`/api/connectors/status/${connector.type}`)
const response = await fetch(`/api/connectors/${connector.type}/status`)
if (response.ok) {
const status: ConnectorStatus = await response.json()
const isConnected = status.authenticated
// Find the first active connection to use for syncing
const activeConnection = status.connections?.find(conn => conn.is_active)
const data = await response.json()
const connections = data.connections || []
const activeConnection = connections.find((conn: any) => conn.is_active)
const isConnected = activeConnection !== undefined
setConnectors(prev => prev.map(c =>
c.id === connectorId
c.id === connector.id
? {
...c,
status: isConnected ? "connected" : "not_connected",
connectionId: activeConnection?.connection_id
connectionId: activeConnection?.connection_id
}
: c
))
}
} catch (error) {
console.error(`Failed to refresh status for ${connector.name}:`, error)
console.error(`Failed to refresh connector status for ${connector.name}:`, error)
}
}
@ -125,8 +133,8 @@ function ConnectorsPage() {
))
try {
// Frontend determines the correct redirect URI using its own origin
const redirectUri = `${window.location.origin}/connectors/callback`
// Use the shared auth callback URL, not a separate connectors callback
const redirectUri = `${window.location.origin}/auth/callback`
const response = await fetch('/api/auth/init', {
method: 'POST',
@ -175,169 +183,66 @@ function ConnectorsPage() {
}
}
const pollTaskStatus = async (taskId: string, connectorId: string) => {
const maxAttempts = 120 // Poll for up to 10 minutes (120 * 5s intervals)
let attempts = 0
const poll = async (): Promise<void> => {
try {
attempts++
const response = await fetch(`/api/tasks/${taskId}`)
if (!response.ok) {
throw new Error(`Failed to check task status: ${response.status}`)
}
const task = await response.json()
if (task.status === 'completed') {
// Task completed successfully
setSyncResults(prev => ({
...prev,
[connectorId]: {
processed: task.total_files || 0,
added: task.successful_files || 0,
skipped: (task.total_files || 0) - (task.successful_files || 0),
errors: task.failed_files || 0
}
}))
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
setIsSyncing(null)
} else if (task.status === 'failed' || task.status === 'error') {
// Task failed
setSyncResults(prev => ({
...prev,
[connectorId]: {
error: task.error || 'Sync failed'
}
}))
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
setIsSyncing(null)
} else if (task.status === 'pending' || task.status === 'running') {
// Still in progress, update progress and continue polling
const processed = task.processed_files || 0
const total = task.total_files || 0
const successful = task.successful_files || 0
const failed = task.failed_files || 0
setSyncProgress(prev => ({
...prev,
[connectorId]: {
status: task.status,
processed,
total,
successful,
failed
}
}))
// Continue polling if we haven't exceeded max attempts
if (attempts < maxAttempts) {
setTimeout(poll, 5000) // Poll every 5 seconds
} else {
setSyncResults(prev => ({
...prev,
[connectorId]: {
error: `Sync timeout after ${attempts} attempts. The task may still be running in the background.`
}
}))
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
setIsSyncing(null)
}
} else {
// Unknown status
setSyncResults(prev => ({
...prev,
[connectorId]: {
error: `Unknown task status: ${task.status}`
}
}))
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
setIsSyncing(null)
}
} catch (error) {
console.error('Task polling error:', error)
setSyncResults(prev => ({
...prev,
[connectorId]: {
error: error instanceof Error ? error.message : 'Failed to check sync status'
}
}))
setSyncProgress(prev => ({ ...prev, [connectorId]: null }))
setIsSyncing(null)
}
}
// Start polling
await poll()
}
const handleSync = async (connector: Connector) => {
setIsSyncing(connector.id)
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
setSyncProgress(prev => ({ ...prev, [connector.id]: null }))
if (!connector.connectionId) {
console.error('No connection ID available for syncing')
setSyncResults(prev => ({
...prev,
[connector.id]: {
error: 'No active connection found. Please reconnect and try again.'
}
}))
setIsSyncing(null)
console.error('No connection ID available for connector')
return
}
setIsSyncing(connector.id)
setSyncProgress(prev => ({ ...prev, [connector.id]: null })) // Clear any existing progress
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
try {
const response = await fetch('/api/connectors/sync', {
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
connection_id: connector.connectionId,
max_files: maxFiles
}),
})
const result = await response.json()
if (response.status === 201 && result.task_id) {
// Async sync started, begin polling for status
setSyncProgress(prev => ({
// Task-based sync, use centralized tracking
addTask(result.task_id)
console.log(`Sync task ${result.task_id} added to central tracking for connector ${connector.id}`)
// Immediately refresh task notifications to show the new task
await refreshTasks()
// Show sync started message
setSyncResults(prev => ({
...prev,
[connector.id]: {
status: 'pending',
processed: 0,
total: 0,
successful: 0,
failed: 0
message: "Check task notification panel for progress",
isStarted: true
}
}))
// Start polling for task status
await pollTaskStatus(result.task_id, connector.id)
setIsSyncing(null)
} else if (response.ok) {
// Legacy synchronous response (fallback)
setSyncResults(prev => ({ ...prev, [connector.id]: result }))
// Direct sync result - still show "sync started" message
setSyncResults(prev => ({
...prev,
[connector.id]: {
message: "Check task notification panel for progress",
isStarted: true
}
}))
setIsSyncing(null)
} else {
throw new Error(result.error || 'Failed to sync')
throw new Error(result.error || 'Sync failed')
}
} catch (error) {
console.error('Sync failed:', error)
setSyncResults(prev => ({
...prev,
[connector.id]: {
error: error instanceof Error ? error.message : 'Sync failed'
}
error: error instanceof Error ? error.message : 'Sync failed'
}
}))
setIsSyncing(null)
}
@ -522,58 +427,30 @@ function ConnectorsPage() {
)}
</div>
{/* Sync Results and Progress */}
{(syncResults[connector.id] || syncProgress[connector.id]) && (
{/* Sync Results */}
{syncResults[connector.id] && (
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
{syncProgress[connector.id] && (
{syncResults[connector.id]?.isStarted && (
<div className="text-sm">
<div className="font-medium text-blue-600 mb-1">
<RefreshCw className="inline h-3 w-3 mr-1 animate-spin" />
Sync in Progress
<RefreshCw className="inline h-3 w-3 mr-1" />
Task initiated:
</div>
<div className="space-y-1 text-muted-foreground">
<div>Status: {syncProgress[connector.id].status}</div>
{syncProgress[connector.id].total > 0 && (
<>
<div>Progress: {syncProgress[connector.id].processed}/{syncProgress[connector.id].total} files</div>
<div>Successful: {syncProgress[connector.id].successful}</div>
{syncProgress[connector.id].failed > 0 && (
<div className="text-red-500">
Failed: {syncProgress[connector.id].failed}
</div>
)}
</>
)}
<div className="text-blue-600">
{syncResults[connector.id]?.message}
</div>
</div>
)}
{syncResults[connector.id] && !syncProgress[connector.id] && (
<>
{syncResults[connector.id].error ? (
<div className="text-sm text-red-500">
<div className="font-medium">Sync Failed</div>
<div>{syncResults[connector.id].error}</div>
</div>
) : (
<div className="text-sm">
<div className="font-medium text-green-600 mb-1">
<FileText className="inline h-3 w-3 mr-1" />
Sync Completed
</div>
<div className="space-y-1 text-muted-foreground">
<div>Processed: {syncResults[connector.id].processed || 0} files</div>
<div>Added: {syncResults[connector.id].added || 0} documents</div>
<div>Skipped: {syncResults[connector.id].skipped || 0} files</div>
{syncResults[connector.id].errors > 0 && (
<div className="text-red-500">
Errors: {syncResults[connector.id].errors}
</div>
)}
</div>
</div>
)}
</>
{syncResults[connector.id]?.error && (
<div className="text-sm">
<div className="font-medium text-red-600 mb-1">
<XCircle className="h-4 w-4 inline mr-1" />
Sync Failed
</div>
<div className="text-red-600">
{syncResults[connector.id]?.error}
</div>
</div>
)}
</div>
)}

View file

@ -87,3 +87,16 @@
@apply bg-background text-foreground;
}
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}

View file

@ -5,8 +5,10 @@ import { ThemeProvider } from "@/components/theme-provider";
import { Navigation } from "@/components/navigation";
import { ModeToggle } from "@/components/mode-toggle";
import { AuthProvider } from "@/contexts/auth-context";
import { TaskProvider } from "@/contexts/task-context";
import { UserNav } from "@/components/user-nav";
import { LayoutWrapper } from "@/components/layout-wrapper";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -40,11 +42,14 @@ export default function RootLayout({
disableTransitionOnChange
>
<AuthProvider>
<LayoutWrapper>
{children}
</LayoutWrapper>
<TaskProvider>
<LayoutWrapper>
{children}
</LayoutWrapper>
</TaskProvider>
</AuthProvider>
</ThemeProvider>
<Toaster />
</body>
</html>
);

View file

@ -1,17 +1,28 @@
"use client"
import { usePathname } from "next/navigation"
import { Bell, BellRing } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Navigation } from "@/components/navigation"
import { ModeToggle } from "@/components/mode-toggle"
import { UserNav } from "@/components/user-nav"
import { TaskNotificationMenu } from "@/components/task-notification-menu"
import { useTask } from "@/contexts/task-context"
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const { tasks, isMenuOpen, toggleMenu } = useTask()
// List of paths that should not show navigation
const authPaths = ['/login', '/auth/callback']
const isAuthPage = authPaths.includes(pathname)
// Calculate active tasks for the bell icon
const activeTasks = tasks.filter(task =>
task.status === 'pending' || task.status === 'running' || task.status === 'processing'
)
if (isAuthPage) {
// For auth pages, render without navigation
return (
@ -21,7 +32,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
)
}
// For all other pages, render with full navigation
// For all other pages, render with full navigation and task menu
return (
<div className="h-full relative">
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background">
@ -33,6 +44,27 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
</div>
<div className="flex flex-1 items-center justify-end space-x-2">
<nav className="flex items-center space-x-2">
{/* Task Notification Bell */}
<Button
variant="ghost"
size="sm"
onClick={toggleMenu}
className="relative p-2"
>
{activeTasks.length > 0 ? (
<BellRing className="h-4 w-4 text-blue-500" />
) : (
<Bell className="h-4 w-4 text-muted-foreground" />
)}
{activeTasks.length > 0 && (
<Badge
variant="secondary"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs bg-blue-500 text-white border-0"
>
{activeTasks.length}
</Badge>
)}
</Button>
<UserNav />
<ModeToggle />
</nav>
@ -42,15 +74,16 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
<div className="hidden md:flex md:w-72 md:flex-col md:fixed md:top-14 md:bottom-0 md:left-0 z-[80] border-r border-border/40">
<Navigation />
</div>
<main className="md:pl-72">
<main className={`md:pl-72 ${isMenuOpen ? 'md:pr-80' : ''}`}>
<div className="flex flex-col h-[calc(100vh-3.6rem)]">
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto scrollbar-hide">
<div className="container py-6 lg:py-8">
{children}
</div>
</div>
</div>
</main>
<TaskNotificationMenu />
</div>
)
}

View file

@ -0,0 +1,309 @@
"use client"
import { useState } from 'react'
import { Bell, BellRing, CheckCircle, XCircle, Clock, Loader2, ChevronDown, ChevronUp, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useTask, Task } from '@/contexts/task-context'
import { cn } from '@/lib/utils'
export function TaskNotificationMenu() {
const { tasks, isFetching, isMenuOpen, cancelTask } = useTask()
const [isExpanded, setIsExpanded] = useState(false)
// Don't render if menu is closed
if (!isMenuOpen) return null
const activeTasks = tasks.filter(task =>
task.status === 'pending' || task.status === 'running' || task.status === 'processing'
)
const recentTasks = tasks.filter(task =>
task.status === 'completed' || task.status === 'failed' || task.status === 'error'
).slice(0, 5) // Show last 5 completed/failed tasks
const getTaskIcon = (status: Task['status']) => {
switch (status) {
case 'completed':
return <CheckCircle className="h-4 w-4 text-green-500" />
case 'failed':
case 'error':
return <XCircle className="h-4 w-4 text-red-500" />
case 'pending':
return <Clock className="h-4 w-4 text-yellow-500" />
case 'running':
case 'processing':
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
default:
return <Clock className="h-4 w-4 text-gray-500" />
}
}
const getStatusBadge = (status: Task['status']) => {
switch (status) {
case 'completed':
return <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">Completed</Badge>
case 'failed':
case 'error':
return <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">Failed</Badge>
case 'pending':
return <Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">Pending</Badge>
case 'running':
case 'processing':
return <Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">Processing</Badge>
default:
return <Badge variant="outline" className="bg-gray-500/10 text-gray-500 border-gray-500/20">Unknown</Badge>
}
}
const formatTaskProgress = (task: Task) => {
const total = task.total_files || 0
const processed = task.processed_files || 0
const successful = task.successful_files || 0
const failed = task.failed_files || 0
const skipped = Math.max(0, processed - successful - failed) // Calculate skipped
if (total > 0) {
return {
basic: `${processed}/${total} files`,
detailed: {
total,
processed,
successful,
failed,
skipped,
remaining: total - processed
}
}
}
return null
}
const formatRelativeTime = (dateString: string) => {
// Handle different timestamp formats
let date: Date
// If it's a number (Unix timestamp), convert it
if (/^\d+$/.test(dateString)) {
const timestamp = parseInt(dateString)
// If it looks like seconds (less than 10^13), convert to milliseconds
date = new Date(timestamp < 10000000000 ? timestamp * 1000 : timestamp)
}
// If it's a decimal number (Unix timestamp with decimals)
else if (/^\d+\.\d+$/.test(dateString)) {
const timestamp = parseFloat(dateString)
// Convert seconds to milliseconds
date = new Date(timestamp * 1000)
}
// Otherwise, try to parse as ISO string or other date format
else {
date = new Date(dateString)
}
// Check if date is valid
if (isNaN(date.getTime())) {
console.warn('Invalid date format:', dateString)
return 'Unknown time'
}
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMinutes < 1) return 'Just now'
if (diffMinutes < 60) return `${diffMinutes}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return `${diffDays}d ago`
}
return (
<div className="fixed top-14 right-0 z-40 w-80 h-[calc(100vh-3.5rem)] bg-background border-l border-border/40">
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-border/40">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeTasks.length > 0 ? (
<BellRing className="h-5 w-5 text-blue-500" />
) : (
<Bell className="h-5 w-5 text-muted-foreground" />
)}
<h3 className="font-semibold">Tasks</h3>
{isFetching && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</div>
{activeTasks.length > 0 && (
<Badge variant="secondary" className="bg-blue-500/10 text-blue-500">
{activeTasks.length}
</Badge>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Active Tasks */}
{activeTasks.length > 0 && (
<div className="p-4 space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">Active Tasks</h4>
{activeTasks.map((task) => (
<Card key={task.task_id} className="bg-card/50">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
{getTaskIcon(task.status)}
Task {task.task_id.substring(0, 8)}...
</CardTitle>
</div>
<CardDescription className="text-xs">
Started {formatRelativeTime(task.created_at)}
</CardDescription>
</CardHeader>
{formatTaskProgress(task) && (
<CardContent className="pt-0">
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
Progress: {formatTaskProgress(task)?.basic}
</div>
{formatTaskProgress(task)?.detailed && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-green-600">
{formatTaskProgress(task)?.detailed.successful} success
</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
<span className="text-red-600">
{formatTaskProgress(task)?.detailed.failed} failed
</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
<span className="text-yellow-600">
{formatTaskProgress(task)?.detailed.skipped} skipped
</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full"></div>
<span className="text-muted-foreground">
{formatTaskProgress(task)?.detailed.remaining} pending
</span>
</div>
</div>
)}
</div>
{/* Cancel button in bottom right */}
{(task.status === 'pending' || task.status === 'running' || task.status === 'processing') && (
<div className="flex justify-end mt-3">
<Button
variant="destructive"
size="sm"
onClick={() => cancelTask(task.task_id)}
className="h-7 px-3 text-xs"
title="Cancel task"
>
<X className="h-3 w-3 mr-1" />
Cancel
</Button>
</div>
)}
</CardContent>
)}
{/* Cancel button for tasks without progress */}
{!formatTaskProgress(task) && (task.status === 'pending' || task.status === 'running' || task.status === 'processing') && (
<CardContent className="pt-0">
<div className="flex justify-end">
<Button
variant="destructive"
size="sm"
onClick={() => cancelTask(task.task_id)}
className="h-7 px-3 text-xs"
title="Cancel task"
>
<X className="h-3 w-3 mr-1" />
Cancel
</Button>
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
{/* Recent Tasks */}
{recentTasks.length > 0 && (
<div className="p-4 space-y-3 border-t border-border/40">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-muted-foreground">Recent Tasks</h4>
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="h-6 w-6 p-0"
>
{isExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</Button>
</div>
{isExpanded && (
<div className="space-y-2 transition-all duration-200">
{recentTasks.map((task) => (
<div
key={task.task_id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors"
>
{getTaskIcon(task.status)}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
Task {task.task_id.substring(0, 8)}...
</div>
<div className="text-xs text-muted-foreground">
{formatRelativeTime(task.updated_at)}
</div>
{/* Show final results for completed tasks */}
{task.status === 'completed' && formatTaskProgress(task)?.detailed && (
<div className="text-xs text-muted-foreground mt-1">
{formatTaskProgress(task)?.detailed.successful} success, {' '}
{formatTaskProgress(task)?.detailed.failed} failed, {' '}
{formatTaskProgress(task)?.detailed.skipped} skipped
</div>
)}
{task.status === 'failed' && task.error && (
<div className="text-xs text-red-600 mt-1 truncate">
{task.error}
</div>
)}
</div>
{getStatusBadge(task.status)}
</div>
))}
</div>
)}
</div>
)}
{/* Empty State */}
{activeTasks.length === 0 && recentTasks.length === 0 && (
<div className="p-8 text-center">
<Bell className="h-12 w-12 text-muted-foreground/50 mx-auto mb-4" />
<h4 className="text-sm font-medium text-muted-foreground mb-2">No tasks yet</h4>
<p className="text-xs text-muted-foreground">
Task notifications will appear here when you upload files or sync connectors.
</p>
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,214 @@
"use client"
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import { useAuth } from '@/contexts/auth-context'
export interface Task {
task_id: string
status: 'pending' | 'running' | 'processing' | 'completed' | 'failed' | 'error'
total_files?: number
processed_files?: number
successful_files?: number
failed_files?: number
created_at: string
updated_at: string
result?: any
error?: string
files?: { [key: string]: any }
}
interface TaskContextType {
tasks: Task[]
addTask: (taskId: string) => void
removeTask: (taskId: string) => void
refreshTasks: () => Promise<void>
cancelTask: (taskId: string) => Promise<void>
isPolling: boolean
isFetching: boolean
isMenuOpen: boolean
toggleMenu: () => void
}
const TaskContext = createContext<TaskContextType | undefined>(undefined)
export function TaskProvider({ children }: { children: React.ReactNode }) {
const [tasks, setTasks] = useState<Task[]>([])
const [isPolling, setIsPolling] = useState(false)
const [isFetching, setIsFetching] = useState(false)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [trackedTaskIds, setTrackedTaskIds] = useState<Set<string>>(new Set())
const { isAuthenticated } = useAuth()
const fetchTasks = useCallback(async () => {
if (!isAuthenticated) return
setIsFetching(true)
try {
const response = await fetch('/api/tasks')
if (response.ok) {
const data = await response.json()
const newTasks = data.tasks || []
// Update tasks and check for status changes in the same state update
setTasks(prevTasks => {
// Check for newly completed tasks to show toasts
if (prevTasks.length > 0) {
newTasks.forEach((newTask: Task) => {
const oldTask = prevTasks.find(t => t.task_id === newTask.task_id)
if (oldTask && oldTask.status !== 'completed' && newTask.status === 'completed') {
// Task just completed - show success toast
toast.success("Task completed successfully!", {
description: `Task ${newTask.task_id} has finished processing.`,
action: {
label: "View",
onClick: () => console.log("View task", newTask.task_id),
},
})
} else if (oldTask && oldTask.status !== 'failed' && oldTask.status !== 'error' && (newTask.status === 'failed' || newTask.status === 'error')) {
// Task just failed - show error toast
toast.error("Task failed", {
description: `Task ${newTask.task_id} failed: ${newTask.error || 'Unknown error'}`,
})
}
})
}
return newTasks
})
}
} catch (error) {
console.error('Failed to fetch tasks:', error)
} finally {
setIsFetching(false)
}
}, [isAuthenticated]) // Removed 'tasks' from dependencies to prevent infinite loop!
const addTask = useCallback((taskId: string) => {
setTrackedTaskIds(prev => new Set(prev).add(taskId))
// Immediately start aggressive polling for the new task
let pollAttempts = 0
const maxPollAttempts = 30 // Poll for up to 30 seconds
const aggressivePoll = async () => {
try {
const response = await fetch('/api/tasks')
if (response.ok) {
const data = await response.json()
const newTasks = data.tasks || []
const foundTask = newTasks.find((task: Task) => task.task_id === taskId)
if (foundTask) {
// Task found! Update the tasks state
setTasks(prevTasks => {
// Check if task is already in the list
const exists = prevTasks.some(t => t.task_id === taskId)
if (!exists) {
return [...prevTasks, foundTask]
}
// Update existing task
return prevTasks.map(t => t.task_id === taskId ? foundTask : t)
})
return // Stop polling, we found it
}
}
} catch (error) {
console.error('Aggressive polling failed:', error)
}
pollAttempts++
if (pollAttempts < maxPollAttempts) {
// Continue polling every 1 second for new tasks
setTimeout(aggressivePoll, 1000)
}
}
// Start aggressive polling after a short delay to allow backend to process
setTimeout(aggressivePoll, 500)
}, [])
const refreshTasks = useCallback(async () => {
await fetchTasks()
}, [fetchTasks])
const removeTask = useCallback((taskId: string) => {
setTrackedTaskIds(prev => {
const newSet = new Set(prev)
newSet.delete(taskId)
return newSet
})
}, [])
const cancelTask = useCallback(async (taskId: string) => {
try {
const response = await fetch(`/api/tasks/${taskId}/cancel`, {
method: 'POST',
})
if (response.ok) {
// Immediately refresh tasks to show the updated status
await fetchTasks()
toast.success("Task cancelled", {
description: `Task ${taskId.substring(0, 8)}... has been cancelled`
})
} else {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to cancel task')
}
} catch (error) {
console.error('Failed to cancel task:', error)
toast.error("Failed to cancel task", {
description: error instanceof Error ? error.message : 'Unknown error'
})
}
}, [fetchTasks])
const toggleMenu = useCallback(() => {
setIsMenuOpen(prev => !prev)
}, [])
// Periodic polling for task updates
useEffect(() => {
if (!isAuthenticated) return
setIsPolling(true)
// Initial fetch
fetchTasks()
// Set up polling interval - every 3 seconds (more responsive for active tasks)
const interval = setInterval(fetchTasks, 3000)
return () => {
clearInterval(interval)
setIsPolling(false)
}
}, [isAuthenticated, fetchTasks])
const value: TaskContextType = {
tasks,
addTask,
removeTask,
refreshTasks,
cancelTask,
isPolling,
isFetching,
isMenuOpen,
toggleMenu,
}
return (
<TaskContext.Provider value={value}>
{children}
</TaskContext.Provider>
)
}
export function useTask() {
const context = useContext(TaskContext)
if (context === undefined) {
throw new Error('useTask must be used within a TaskProvider')
}
return context
}