mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(brain): add /brain Slack-first onboarding entry (#1310)
New /brain route creates the org straight from signup and redirects into the Slack install, so onboarding has no domain-confirmation step. Returning from OAuth shows an Open Slack handoff instead of a toast. Also models the terminal research error state: polling stops, the UI retries once, and the docked header no longer gates Continue on research finishing.
This commit is contained in:
parent
2426305a2d
commit
e39ba92cb4
6 changed files with 312 additions and 29 deletions
136
apps/web/app/(app)/brain/page.tsx
Normal file
136
apps/web/app/(app)/brain/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import { authClient } from "@lib/auth"
|
||||||
|
import { useAuth } from "@lib/auth-context"
|
||||||
|
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
||||||
|
import { cn } from "@lib/utils"
|
||||||
|
import { analytics } from "@/lib/analytics"
|
||||||
|
import { dmSansClassName } from "@/lib/fonts"
|
||||||
|
import {
|
||||||
|
detectModeFromEmail,
|
||||||
|
generateOrgSlug,
|
||||||
|
workspaceDomainFromEmail,
|
||||||
|
workspaceNameFromDomain,
|
||||||
|
workspaceNameFromEmail,
|
||||||
|
type BrainMetadata,
|
||||||
|
} from "@/components/onboarding-brain/types"
|
||||||
|
|
||||||
|
const BACKEND =
|
||||||
|
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||||
|
|
||||||
|
// No forms: sign up → org auto-created → Slack install. The bot asks the rest.
|
||||||
|
export default function BrainEntryPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
|
||||||
|
useAuth()
|
||||||
|
const { email = null } = user ?? {}
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [attempt, setAttempt] = useState(0)
|
||||||
|
const startedRef = useRef(false)
|
||||||
|
|
||||||
|
const run = useCallback(async () => {
|
||||||
|
if (organizations && organizations.length > 0) {
|
||||||
|
const active =
|
||||||
|
org ?? organizations.find((o) => o.slug) ?? organizations[0]
|
||||||
|
if (!org && active?.slug) await setActiveOrg(active.slug)
|
||||||
|
const status = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "X-App-Source": "nova" },
|
||||||
|
})
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.catch(() => null)
|
||||||
|
if (status?.connected) {
|
||||||
|
router.replace("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personal email → shell org; the Slack workspace resolves identity later.
|
||||||
|
const domain =
|
||||||
|
detectModeFromEmail(email) === "team"
|
||||||
|
? workspaceDomainFromEmail(email)
|
||||||
|
: null
|
||||||
|
const name =
|
||||||
|
(domain
|
||||||
|
? workspaceNameFromDomain(domain)
|
||||||
|
: workspaceNameFromEmail(email)) || "Company Brain"
|
||||||
|
const metadata: BrainMetadata & { signupSource: string } = {
|
||||||
|
signupSource: "consumer",
|
||||||
|
brainOnboardingVersion: "v1",
|
||||||
|
brainMode: "team",
|
||||||
|
brainWorkspaceName: name,
|
||||||
|
brainWorkspaceDomain: domain,
|
||||||
|
// Always the shared Team Brain; the CB UI never selects a slug space.
|
||||||
|
brainContainerTag: SHARED_TEAM_BRAIN_TAG,
|
||||||
|
}
|
||||||
|
const result = await authClient.organization.create({
|
||||||
|
name,
|
||||||
|
slug: generateOrgSlug(name),
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
|
if (result.error || !result.data?.slug) {
|
||||||
|
throw new Error(result.error?.message || "Could not create workspace.")
|
||||||
|
}
|
||||||
|
await setActiveOrg(result.data.slug)
|
||||||
|
await refetchOrganizations()
|
||||||
|
analytics.onboardingWorkspaceCreated({
|
||||||
|
mode: "team",
|
||||||
|
has_about: false,
|
||||||
|
has_domain: Boolean(domain),
|
||||||
|
})
|
||||||
|
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
||||||
|
}, [email, org, organizations, setActiveOrg, refetchOrganizations, router])
|
||||||
|
|
||||||
|
// Sole caller of run(): the guard is only released on failure, so a dep change
|
||||||
|
// mid-flight can't kick off a second org creation.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: attempt retriggers the retry
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || organizations === null || startedRef.current) return
|
||||||
|
startedRef.current = true
|
||||||
|
run().catch((e) => {
|
||||||
|
startedRef.current = false
|
||||||
|
console.error("Brain entry failed:", e)
|
||||||
|
setError(e instanceof Error ? e.message : "Something went wrong.")
|
||||||
|
})
|
||||||
|
}, [user, organizations, run, attempt])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-dvh flex-col items-center justify-center gap-4 bg-[#05080D] px-6 text-center",
|
||||||
|
dmSansClassName(),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[15px] font-medium text-[#FAFAFA]">
|
||||||
|
Couldn't set up your Company Brain
|
||||||
|
</p>
|
||||||
|
<p className="max-w-sm text-[13px] text-[#8A94A6]">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null)
|
||||||
|
setAttempt((a) => a + 1)
|
||||||
|
}}
|
||||||
|
className="rounded-full bg-white px-5 py-2 text-[13px] font-semibold text-[#1D1C1D] hover:bg-white/95"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-6 animate-spin text-[#4BA0FA]" />
|
||||||
|
<p className="text-[14px] font-medium text-[#8A94A6]">
|
||||||
|
Setting up your Company Brain…
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ import { RaycastDetail } from "@/components/integrations/raycast-detail"
|
||||||
import { PluginsDetail } from "@/components/integrations/plugins-detail"
|
import { PluginsDetail } from "@/components/integrations/plugins-detail"
|
||||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||||
import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti"
|
import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti"
|
||||||
|
import { SlackHandoff } from "@/components/onboarding-brain/slack-handoff"
|
||||||
import { AddDocumentModal } from "@/components/add-document"
|
import { AddDocumentModal } from "@/components/add-document"
|
||||||
import { DocumentModal } from "@/components/document-modal"
|
import { DocumentModal } from "@/components/document-modal"
|
||||||
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
|
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
|
||||||
|
|
@ -138,16 +139,23 @@ export function AppExperience() {
|
||||||
const isCompanyBrain = useHasCompanyBrain()
|
const isCompanyBrain = useHasCompanyBrain()
|
||||||
const backendUrl = getBackendUrl()
|
const backendUrl = getBackendUrl()
|
||||||
|
|
||||||
// Slack OAuth redirects back here with ?slack=connected — toast then clean up.
|
// ?slack=connected: CB orgs get the handoff takeover, everyone else a toast.
|
||||||
|
const [slackHandoff, setSlackHandoff] = useState<{
|
||||||
|
team: string | null
|
||||||
|
} | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sp = new URLSearchParams(window.location.search)
|
const sp = new URLSearchParams(window.location.search)
|
||||||
if (sp.get("slack") !== "connected") return
|
if (sp.get("slack") !== "connected") return
|
||||||
const team = sp.get("team")
|
const team = sp.get("team")
|
||||||
toast.success(
|
if (isCompanyBrain) {
|
||||||
team
|
setSlackHandoff({ team })
|
||||||
? `Supermemory added to ${team} on Slack`
|
} else {
|
||||||
: "Supermemory added to your Slack",
|
toast.success(
|
||||||
)
|
team
|
||||||
|
? `Supermemory added to ${team} on Slack`
|
||||||
|
: "Supermemory added to your Slack",
|
||||||
|
)
|
||||||
|
}
|
||||||
sp.delete("slack")
|
sp.delete("slack")
|
||||||
sp.delete("team")
|
sp.delete("team")
|
||||||
const qs = sp.toString()
|
const qs = sp.toString()
|
||||||
|
|
@ -156,7 +164,7 @@ export function AppExperience() {
|
||||||
"",
|
"",
|
||||||
window.location.pathname + (qs ? `?${qs}` : ""),
|
window.location.pathname + (qs ? `?${qs}` : ""),
|
||||||
)
|
)
|
||||||
}, [])
|
}, [isCompanyBrain])
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
|
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
|
||||||
|
|
||||||
|
|
@ -616,6 +624,12 @@ export function AppExperience() {
|
||||||
return (
|
return (
|
||||||
<HotkeysProvider>
|
<HotkeysProvider>
|
||||||
<OnboardingConfetti />
|
<OnboardingConfetti />
|
||||||
|
{slackHandoff && (
|
||||||
|
<SlackHandoff
|
||||||
|
teamName={slackHandoff.team}
|
||||||
|
onDismiss={() => setSlackHandoff(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,11 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
||||||
(pathname === "/" &&
|
(pathname === "/" &&
|
||||||
["integrations", "mcp"].includes(searchParams.get("view") ?? ""))
|
["integrations", "mcp"].includes(searchParams.get("view") ?? ""))
|
||||||
const isGuestPublicAppPage = isPublicAppPage && !session && !isSessionPending
|
const isGuestPublicAppPage = isPublicAppPage && !session && !isSessionPending
|
||||||
const isOnboarding = pathname.startsWith("/onboarding")
|
// /brain is the Slack-first Company Brain entry: it creates the org itself.
|
||||||
|
const isOnboarding =
|
||||||
|
pathname.startsWith("/onboarding") ||
|
||||||
|
pathname === "/brain" ||
|
||||||
|
pathname.startsWith("/brain/")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isGuestPublicAppPage) return
|
if (isGuestPublicAppPage) return
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,11 @@ export function CompanyBrainOnboarding({
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { status: researchStatus } = useResearchStatus(phase === "research")
|
const { status: researchStatus } = useResearchStatus(phase === "research")
|
||||||
const researchDone = researchStatus === "done"
|
const researchDone = researchStatus === "done"
|
||||||
|
// One-shot retry: re-kick research once on a terminal error.
|
||||||
|
const retryStage = useRef<"idle" | "started" | "rerunning" | "exhausted">(
|
||||||
|
"idle",
|
||||||
|
)
|
||||||
|
const [retryUi, setRetryUi] = useState<null | "retrying" | "exhausted">(null)
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!clean || submitting) return
|
if (!clean || submitting) return
|
||||||
|
|
@ -120,6 +125,48 @@ export function CompanyBrainOnboarding({
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [phase, serverSchedulesResearch, clean, queryClient])
|
}, [phase, serverSchedulesResearch, clean, queryClient])
|
||||||
|
|
||||||
|
// "error" lingers a render after re-kicking, so only arm the second-error
|
||||||
|
// branch once the re-run is observed running.
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "research" || !clean) return
|
||||||
|
const stage = retryStage.current
|
||||||
|
if (researchStatus === "error" && stage === "idle") {
|
||||||
|
retryStage.current = "started"
|
||||||
|
setRetryUi("retrying")
|
||||||
|
void (async () => {
|
||||||
|
// A failed restart never reaches queued/running, and polling is off on
|
||||||
|
// error — without this the UI would say "retrying" forever.
|
||||||
|
const ok = await fetch(`${BACKEND}/brain/research/start`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"X-App-Source": "nova",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ domain: clean }),
|
||||||
|
})
|
||||||
|
.then((res) => res.ok)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!ok) {
|
||||||
|
retryStage.current = "exhausted"
|
||||||
|
setRetryUi("exhausted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
|
||||||
|
})()
|
||||||
|
} else if (
|
||||||
|
(researchStatus === "queued" || researchStatus === "running") &&
|
||||||
|
stage === "started"
|
||||||
|
) {
|
||||||
|
retryStage.current = "rerunning"
|
||||||
|
} else if (researchStatus === "error" && stage === "rerunning") {
|
||||||
|
retryStage.current = "exhausted"
|
||||||
|
setRetryUi("exhausted")
|
||||||
|
} else if (researchStatus === "done") {
|
||||||
|
setRetryUi(null)
|
||||||
|
}
|
||||||
|
}, [phase, clean, researchStatus, queryClient])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -182,6 +229,8 @@ export function CompanyBrainOnboarding({
|
||||||
<DockedHeader
|
<DockedHeader
|
||||||
domain={clean}
|
domain={clean}
|
||||||
done={researchDone}
|
done={researchDone}
|
||||||
|
retrying={retryUi === "retrying"}
|
||||||
|
exhausted={retryUi === "exhausted"}
|
||||||
onContinue={onDone}
|
onContinue={onDone}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -307,13 +356,30 @@ function ConfirmBody({
|
||||||
function DockedHeader({
|
function DockedHeader({
|
||||||
domain,
|
domain,
|
||||||
done,
|
done,
|
||||||
|
retrying,
|
||||||
|
exhausted,
|
||||||
onContinue,
|
onContinue,
|
||||||
}: {
|
}: {
|
||||||
domain: string
|
domain: string
|
||||||
done: boolean
|
done: boolean
|
||||||
|
retrying: boolean
|
||||||
|
exhausted: boolean
|
||||||
onContinue: () => void
|
onContinue: () => void
|
||||||
}) {
|
}) {
|
||||||
const brandName = workspaceNameFromDomain(domain) || domain
|
const brandName = workspaceNameFromDomain(domain) || domain
|
||||||
|
const showSpinner = !done && !exhausted
|
||||||
|
const statusLabel = done
|
||||||
|
? "Company Brain ready"
|
||||||
|
: exhausted
|
||||||
|
? "Couldn't finish — you can continue"
|
||||||
|
: retrying
|
||||||
|
? "Retrying research…"
|
||||||
|
: "Building your Company Brain…"
|
||||||
|
const statusColor = done
|
||||||
|
? "text-[#5CD68A]"
|
||||||
|
: exhausted
|
||||||
|
? "text-[#E5A45A]"
|
||||||
|
: "text-[#737373]"
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div
|
<div
|
||||||
|
|
@ -331,35 +397,35 @@ function DockedHeader({
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 whitespace-nowrap text-[12px] font-medium",
|
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
|
||||||
done ? "text-[#5CD68A]" : "text-[#737373]",
|
statusColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{done ? "Company Brain ready" : "Building your Company Brain…"}
|
{showSpinner && (
|
||||||
|
<Loader2 className="size-3 animate-spin text-[#4BA0FA]" />
|
||||||
|
)}
|
||||||
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{done ? (
|
{/* Never gated on research; the admin can move on while it keeps working. */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onContinue}
|
onClick={onContinue}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto shrink-0 rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
|
"ml-auto shrink-0 rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
|
||||||
dmSans125ClassName(),
|
dmSans125ClassName(),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
<ArrowRight className="size-3.5" />
|
<ArrowRight className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
|
||||||
<Loader2 className="ml-auto size-3.5 shrink-0 animate-spin text-[#4BA0FA]" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResearchTranscript() {
|
function ResearchTranscript() {
|
||||||
const { status, events } = useResearchStatus()
|
const { status, events } = useResearchStatus()
|
||||||
const running = status !== "done"
|
const running = status !== "done" && status !== "error"
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events
|
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events
|
||||||
|
|
|
||||||
61
apps/web/components/onboarding-brain/slack-handoff.tsx
Normal file
61
apps/web/components/onboarding-brain/slack-handoff.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
import { ArrowRight } from "lucide-react"
|
||||||
|
import { cn } from "@lib/utils"
|
||||||
|
import { SlackMark } from "@/components/brain-connector-icons"
|
||||||
|
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||||
|
|
||||||
|
export function SlackHandoff({
|
||||||
|
teamName,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
teamName: string | null
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-[100] flex items-center justify-center bg-[#05080D]/90 backdrop-blur-sm px-4",
|
||||||
|
dmSansClassName(),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 260, damping: 26 }}
|
||||||
|
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] border border-white/[0.06] p-8 text-center"
|
||||||
|
>
|
||||||
|
<div className="mx-auto mb-5 flex size-14 items-center justify-center rounded-[16px] bg-[#14161A] border border-[rgba(82,89,102,0.2)]">
|
||||||
|
<SlackMark className="size-7" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-[20px] font-semibold text-[#FAFAFA]">
|
||||||
|
{teamName
|
||||||
|
? `Company Brain is live in ${teamName}`
|
||||||
|
: "Company Brain is live in your Slack"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-[13px] leading-relaxed text-[#8A94A6]">
|
||||||
|
We sent you a DM to get started. Ask it anything about your company —
|
||||||
|
it answers where your team already works.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="slack://open"
|
||||||
|
className={cn(
|
||||||
|
"mt-6 inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-3 text-[14px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] transition-opacity hover:opacity-90",
|
||||||
|
dmSans125ClassName(),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Open Slack
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="mt-3 w-full text-[12px] font-medium text-[#525D6E] transition-colors hover:text-[#8A94A6]"
|
||||||
|
>
|
||||||
|
Stay in the browser
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ export type ResearchEvent = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResearchState = {
|
export type ResearchState = {
|
||||||
status: "queued" | "running" | "done" | null
|
status: "queued" | "running" | "done" | "error" | null
|
||||||
domain: string | null
|
domain: string | null
|
||||||
findings: number
|
findings: number
|
||||||
events: ResearchEvent[]
|
events: ResearchEvent[]
|
||||||
|
|
@ -54,7 +54,9 @@ export function useResearchStatus(enabled = true) {
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
const status = query.state.data?.status
|
const status = query.state.data?.status
|
||||||
const polls = query.state.dataUpdateCount
|
const polls = query.state.dataUpdateCount
|
||||||
if (status === "done" || polls >= MAX_POLLS) return false
|
// error is terminal too — keep polling only while it can still progress.
|
||||||
|
if (status === "done" || status === "error" || polls >= MAX_POLLS)
|
||||||
|
return false
|
||||||
return POLL_INTERVAL_MS
|
return POLL_INTERVAL_MS
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue