diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index 3ab06e1e..4c455c63 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -33,7 +33,7 @@ import { } from "@ui/components/dropdown-menu" import { RemoveConnectionDialog } from "@/components/remove-connection-dialog" import { SyncStatusBadge } from "@/components/settings/sync-status-badge" -import { SyncHistorySheet } from "@/components/settings/sync-history-sheet" +import { SyncHistoryPanel } from "@/components/settings/sync-history-panel" import { useTriggerSync } from "@/hooks/use-trigger-sync" import { formatRelativeTime } from "@/components/settings/sync-utils" import type { ImportProvider } from "@/components/settings/sync-utils" @@ -101,7 +101,6 @@ function ConnectionRow({ projects, onTriggerSync, isSyncing, - onViewHistory, }: { connection: Connection onDelete: () => void @@ -109,8 +108,8 @@ function ConnectionRow({ projects: Project[] onTriggerSync: () => void isSyncing: boolean - onViewHistory: () => void }) { + const [historyOpen, setHistoryOpen] = useState(false) const config = CONNECTORS[connection.provider as ConnectorProvider] if (!config) return null @@ -196,13 +195,25 @@ function ConnectionRow({ type="button" onClick={(e) => { e.stopPropagation() - onViewHistory() + setHistoryOpen((v) => !v) }} - className="text-[#737373] hover:text-[#FAFAFA] transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5" aria-label="Sync history" - title="Sync history" + aria-expanded={historyOpen} + title={historyOpen ? "Hide sync history" : "Sync history"} + className={cn( + "transition-colors disabled:opacity-50 disabled:cursor-not-allowed p-1.5 rounded-lg hover:bg-white/5 flex items-center gap-0.5", + historyOpen + ? "text-[#FAFAFA] bg-white/5" + : "text-[#737373] hover:text-[#FAFAFA]", + )} > + + + )} + + {!isLoading && !error && syncRuns && syncRuns.length === 0 && ( +
+ + No syncs yet — runs will appear here. + +
+ )} + + {hasRuns && ( + <> + + + + )} + + ) +} diff --git a/apps/web/components/settings/sync-history-sheet.tsx b/apps/web/components/settings/sync-history-sheet.tsx deleted file mode 100644 index ac706c2e..00000000 --- a/apps/web/components/settings/sync-history-sheet.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"use client" - -import { cn } from "@lib/utils" -import { dmSans125ClassName } from "@/lib/fonts" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@ui/components/sheet" -import { useSyncRuns } from "@/hooks/use-sync-runs" -import type { SyncRun } from "@/hooks/use-sync-runs" -import { - formatRelativeTime, - TRIGGER_TYPE_LABELS, - PROVIDER_DISPLAY_NAMES, -} from "@/components/settings/sync-utils" -import type { ConnectionResponseSchema } from "@repo/validation/api" -import type { z } from "zod" - -type Connection = z.infer - -const STATUS_COLORS: Record = { - completed: { dot: "bg-[#00AC3F]", text: "text-[#00AC3F]" }, - failed: { dot: "bg-[#EF4444]", text: "text-[#EF4444]" }, - running: { dot: "bg-[#4BA0FA] animate-pulse", text: "text-[#4BA0FA]" }, -} - -function SyncRunCard({ run }: { run: SyncRun }) { - const colors = STATUS_COLORS[run.status] ?? { - dot: "bg-[#4BA0FA] animate-pulse", - text: "text-[#4BA0FA]", - } - - return ( -
- {/* Top row: status + trigger + time */} -
-
-
- - {run.status.charAt(0).toUpperCase() + run.status.slice(1)} - - - {TRIGGER_TYPE_LABELS[run.triggerType] ?? run.triggerType} - -
- - {formatRelativeTime(run.startedAt)} - -
- - {/* Middle row: item counts */} - {(run.itemsProcessed > 0 || run.itemsFailed > 0) && ( -
- {run.itemsProcessed} processed - {run.itemsFailed > 0 && ( - · {run.itemsFailed} failed - )} -
- )} - - {/* Bottom row: error message */} - {run.error && ( -

- {run.error} -

- )} -
- ) -} - -interface SyncHistorySheetProps { - open: boolean - onOpenChange: (open: boolean) => void - connection: Connection | null -} - -export function SyncHistorySheet({ - open, - onOpenChange, - connection, -}: SyncHistorySheetProps) { - const { - data: syncRuns, - isLoading, - error, - refetch, - } = useSyncRuns(open && connection ? connection.id : "") - - const providerTitle = connection - ? (PROVIDER_DISPLAY_NAMES[connection.provider] ?? connection.provider) - : "" - const email = connection?.email ?? "" - - return ( - - - - - Sync History - - - {providerTitle} - {email ? ` · ${email}` : ""} - - - -
- {isLoading && ( -
-
-
- )} - - {error && !isLoading && ( -
-

- Failed to load sync history -

- -
- )} - - {!isLoading && !error && syncRuns && syncRuns.length === 0 && ( -
-

- No sync runs yet -

-
- )} - - {!isLoading && - !error && - syncRuns && - syncRuns.length > 0 && - syncRuns.map((run) => )} -
- - - ) -} diff --git a/apps/web/components/settings/sync-status-badge.tsx b/apps/web/components/settings/sync-status-badge.tsx index 37e316b9..c4d64aa5 100644 --- a/apps/web/components/settings/sync-status-badge.tsx +++ b/apps/web/components/settings/sync-status-badge.tsx @@ -29,10 +29,10 @@ export function SyncStatusBadge({ const status = deriveStatus(syncInProgress, lastSyncedAt, isExpired) return ( -
+
Syncing... @@ -54,7 +54,7 @@ export function SyncStatusBadge({ Synced @@ -63,7 +63,7 @@ export function SyncStatusBadge({ {formatRelativeTime(lastSyncedAt)} @@ -74,7 +74,7 @@ export function SyncStatusBadge({ Disconnected @@ -84,7 +84,7 @@ export function SyncStatusBadge({ Waiting for first sync diff --git a/apps/web/hooks/use-trigger-sync.ts b/apps/web/hooks/use-trigger-sync.ts index f7f701e9..5df0b8f0 100644 --- a/apps/web/hooks/use-trigger-sync.ts +++ b/apps/web/hooks/use-trigger-sync.ts @@ -3,7 +3,12 @@ import { $fetch } from "@lib/api" import { useMutation, useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" +import type { ConnectionResponseSchema } from "@repo/validation/api" +import type { z } from "zod" import type { ImportProvider } from "@/components/settings/sync-utils" +import type { SyncRun } from "@/hooks/use-sync-runs" + +type Connection = z.infer export function useTriggerSync() { const queryClient = useQueryClient() @@ -13,9 +18,7 @@ export function useTriggerSync() { provider, containerTags, }: { - // connectionId is not sent to the backend — the import endpoint is keyed - // by provider, so it re-syncs all connections for that provider. - // It's kept here so onSuccess can target cache invalidation. + // connectionId isn't sent to the backend (import is keyed by provider) — kept for cache updates connectionId: string provider: ImportProvider containerTags?: string[] @@ -32,15 +35,60 @@ export function useTriggerSync() { } return response.data }, - onSuccess: (_data, variables) => { + // Optimistically flip to "syncing" so the badge/button update instantly; the 5s poll then converges on real state + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey: ["connections"] }) + const previousConnections = queryClient.getQueryData([ + "connections", + ]) + queryClient.setQueryData(["connections"], (old) => + old?.map((c) => + c.provider === variables.provider + ? { + ...c, + metadata: { + ...((c.metadata as Record | null) ?? {}), + syncInProgress: true, + }, + } + : c, + ), + ) + + const syncRunsKey = ["sync-runs", variables.connectionId] + const previousSyncRuns = queryClient.getQueryData(syncRunsKey) + if (previousSyncRuns) { + const optimisticRun: SyncRun = { + id: `optimistic-${Date.now()}`, + connectionId: variables.connectionId, + status: "running", + triggerType: "manual", + startedAt: new Date().toISOString(), + completedAt: null, + itemsProcessed: 0, + itemsFailed: 0, + error: null, + } + queryClient.setQueryData(syncRunsKey, [ + optimisticRun, + ...previousSyncRuns, + ]) + } + + return { previousConnections, previousSyncRuns, syncRunsKey } + }, + // Don't invalidate connections/sync-runs here — an immediate refetch races the backend and clobbers the optimistic state; the 5s polls handle it + onSuccess: () => { toast.success("Sync started") - queryClient.invalidateQueries({ queryKey: ["connections"] }) - queryClient.invalidateQueries({ - queryKey: ["sync-runs", variables.connectionId], - }) queryClient.invalidateQueries({ queryKey: ["processing-documents"] }) }, - onError: (error) => { + onError: (error, _variables, context) => { + if (context?.previousConnections !== undefined) { + queryClient.setQueryData(["connections"], context.previousConnections) + } + if (context?.previousSyncRuns !== undefined) { + queryClient.setQueryData(context.syncRunsKey, context.previousSyncRuns) + } toast.error("Failed to start sync", { description: error instanceof Error ? error.message : "Unknown error", })