mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
merge main into mcp revamp
This commit is contained in:
commit
36db68a499
38 changed files with 2840 additions and 218 deletions
|
|
@ -40,19 +40,19 @@ export const HeroCard = ({ imageUrl, title, description, href }) => {
|
|||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="/concepts/how-it-works"
|
||||
href="/docs/concepts/how-it-works"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-gray-900 dark:bg-zinc-100 px-4 py-2.5 text-sm font-medium text-white dark:text-zinc-900 hover:bg-gray-800 dark:hover:bg-white transition-colors"
|
||||
>
|
||||
Architecture
|
||||
</a>
|
||||
<a
|
||||
href="/quickstart"
|
||||
href="/docs/quickstart"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-4 py-2.5 text-sm font-medium text-gray-900 dark:text-zinc-100 hover:bg-gray-50 dark:hover:bg-zinc-900 transition-colors"
|
||||
>
|
||||
Quickstart
|
||||
</a>
|
||||
<a
|
||||
href="/company-brain/setup"
|
||||
href="/docs/company-brain/setup"
|
||||
className="inline-flex items-center justify-center rounded-lg px-3 py-2.5 text-sm font-medium text-gray-600 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-zinc-100 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors"
|
||||
>
|
||||
Set up your company brain
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ interface SDKResult {
|
|||
export class SupermemoryClient {
|
||||
private client: Supermemory
|
||||
private containerTag: string
|
||||
private hasExplicitContainerTag: boolean
|
||||
private bearerToken: string
|
||||
private apiUrl: string
|
||||
|
||||
|
|
@ -134,6 +135,7 @@ export class SupermemoryClient {
|
|||
baseURL: apiUrl,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
})
|
||||
this.hasExplicitContainerTag = Boolean(containerTag)
|
||||
this.containerTag = containerTag || DEFAULT_PROJECT_ID
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +154,7 @@ export class SupermemoryClient {
|
|||
containerTag: this.containerTag,
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
this.handleOperationError("Create memory request", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -179,7 +181,12 @@ export class SupermemoryClient {
|
|||
}
|
||||
|
||||
const SIMILARITY_THRESHOLD = 0.85
|
||||
const searchResult = await this.search(content, 5, SIMILARITY_THRESHOLD)
|
||||
const searchResult = await this.search(
|
||||
content,
|
||||
5,
|
||||
SIMILARITY_THRESHOLD,
|
||||
this.containerTag,
|
||||
)
|
||||
|
||||
if (searchResult.results.length === 0) {
|
||||
return {
|
||||
|
|
@ -211,7 +218,7 @@ export class SupermemoryClient {
|
|||
containerTag: this.containerTag,
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
this.handleOperationError("Forget memory request", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,12 +226,16 @@ export class SupermemoryClient {
|
|||
query: string,
|
||||
limit = 10,
|
||||
threshold?: number,
|
||||
containerTagOverride?: string,
|
||||
): Promise<SearchResult> {
|
||||
try {
|
||||
const containerTag =
|
||||
containerTagOverride ??
|
||||
(this.hasExplicitContainerTag ? this.containerTag : undefined)
|
||||
const result = await this.client.search.memories({
|
||||
q: query,
|
||||
limit,
|
||||
containerTag: this.containerTag,
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
searchMode: "hybrid",
|
||||
threshold,
|
||||
})
|
||||
|
|
@ -247,11 +258,20 @@ export class SupermemoryClient {
|
|||
|
||||
return { results, total: result.total, timing: result.timing }
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
this.handleOperationError("Search request", error)
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile(query?: string): Promise<ProfileResponse> {
|
||||
if (!this.hasExplicitContainerTag) {
|
||||
return {
|
||||
profile: {
|
||||
static: [],
|
||||
dynamic: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.client.profile({
|
||||
containerTag: this.containerTag,
|
||||
|
|
@ -287,7 +307,7 @@ export class SupermemoryClient {
|
|||
|
||||
return response
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
this.handleOperationError("Profile request", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +509,10 @@ export class SupermemoryClient {
|
|||
case 402:
|
||||
throw new Error("Memory limit reached. Upgrade at supermemory.ai")
|
||||
case 403:
|
||||
throw new Error("Access forbidden.")
|
||||
throw new Error(
|
||||
message ||
|
||||
"Access forbidden. Your account may be restricted or blocked.",
|
||||
)
|
||||
case 404:
|
||||
throw new Error("Not found.")
|
||||
case 429:
|
||||
|
|
@ -504,4 +527,16 @@ export class SupermemoryClient {
|
|||
if (error instanceof Error) throw error
|
||||
throw new Error(`Unexpected error: ${String(error)}`)
|
||||
}
|
||||
|
||||
private handleOperationError(operation: string, error: unknown): never {
|
||||
try {
|
||||
this.handleError(error)
|
||||
} catch (handledError) {
|
||||
const message =
|
||||
handledError instanceof Error
|
||||
? handledError.message
|
||||
: String(handledError)
|
||||
throw new Error(`${operation} failed: ${message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,19 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { LogoFull } from "@ui/assets/Logo"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { AlertTriangle, ChevronRight, Loader2, RotateCw } 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 {
|
||||
type BrainEntryOrganization,
|
||||
resolveCompanyBrainEntry,
|
||||
} from "@/lib/company-brain-entry"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
detectModeFromEmail,
|
||||
generateOrgSlug,
|
||||
|
|
@ -21,22 +27,39 @@ import {
|
|||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
const modalCardStyle = {
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}
|
||||
|
||||
const inputBevelStyle = {
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}
|
||||
|
||||
// No forms: sign up → org auto-created → Slack install.
|
||||
// After OAuth, mono attaches api_scale (14d trial) + company_brain (200 credits).
|
||||
export default function BrainEntryPage() {
|
||||
const router = useRouter()
|
||||
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
|
||||
useAuth()
|
||||
const {
|
||||
user,
|
||||
org,
|
||||
organizations,
|
||||
isRestoring,
|
||||
setActiveOrg,
|
||||
refetchOrganizations,
|
||||
} = useAuth()
|
||||
const { email = null } = user ?? {}
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [choices, setChoices] = useState<BrainEntryOrganization[] | 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 continueWithOrganization = useCallback(
|
||||
async (organization: BrainEntryOrganization) => {
|
||||
if (org?.id !== organization.id) {
|
||||
await setActiveOrg(organization.slug)
|
||||
}
|
||||
const status = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
|
|
@ -48,9 +71,11 @@ export default function BrainEntryPage() {
|
|||
return
|
||||
}
|
||||
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
||||
return
|
||||
}
|
||||
},
|
||||
[org?.id, router, setActiveOrg],
|
||||
)
|
||||
|
||||
const createCompanyBrain = useCallback(async () => {
|
||||
// Personal email → shell org; the Slack workspace resolves identity later.
|
||||
const domain =
|
||||
detectModeFromEmail(email) === "team"
|
||||
|
|
@ -85,53 +110,206 @@ export default function BrainEntryPage() {
|
|||
has_domain: Boolean(domain),
|
||||
})
|
||||
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
||||
}, [email, org, organizations, setActiveOrg, refetchOrganizations, router])
|
||||
}, [email, refetchOrganizations, setActiveOrg])
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const organizationsWithActiveMetadata = (organizations ?? []).map(
|
||||
(organization) =>
|
||||
organization.id === org?.id
|
||||
? { ...organization, metadata: org.metadata }
|
||||
: organization,
|
||||
)
|
||||
const decision = resolveCompanyBrainEntry(
|
||||
org?.id,
|
||||
organizationsWithActiveMetadata,
|
||||
)
|
||||
|
||||
if (decision.action === "use" || decision.action === "switch") {
|
||||
await continueWithOrganization(decision.organization)
|
||||
return
|
||||
}
|
||||
if (decision.action === "choose") {
|
||||
setChoices(decision.organizations)
|
||||
return
|
||||
}
|
||||
await createCompanyBrain()
|
||||
}, [continueWithOrganization, createCompanyBrain, org, organizations])
|
||||
|
||||
const handleChoice = useCallback(
|
||||
(organization: BrainEntryOrganization) => {
|
||||
setChoices(null)
|
||||
setError(null)
|
||||
continueWithOrganization(organization).catch((e) => {
|
||||
startedRef.current = false
|
||||
console.error("Company Brain organization selection failed:", e)
|
||||
setError(e instanceof Error ? e.message : "Something went wrong.")
|
||||
})
|
||||
},
|
||||
[continueWithOrganization],
|
||||
)
|
||||
|
||||
// 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
|
||||
if (!user || organizations === null || isRestoring || 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])
|
||||
}, [user, organizations, isRestoring, 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]">
|
||||
<EntryShell>
|
||||
{choices ? (
|
||||
<section
|
||||
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-6 text-left md:p-8"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[20px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Choose your Company Brain
|
||||
</p>
|
||||
<p className="mt-1.5 text-[14px] font-medium leading-[1.5] text-[#737373]">
|
||||
You're a member of more than one workspace. Pick the one to open.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
{choices.map((organization) => (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
onClick={() => handleChoice(organization)}
|
||||
style={inputBevelStyle}
|
||||
className="group flex w-full items-center gap-3 rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-3 py-3 text-left transition-colors hover:bg-[#1E2228] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.15)] bg-[#0D121A] text-[13px] font-semibold uppercase text-[#A1A1AA]">
|
||||
{(organization.name.trim()[0] ?? "?").toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[14px] font-medium text-[#fafafa]">
|
||||
{organization.name}
|
||||
</span>
|
||||
{generateOrgSlug(organization.name) !== organization.slug && (
|
||||
<span className="mt-0.5 block truncate text-[12px] font-medium text-[#525D6E]">
|
||||
{organization.slug}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-[#525D6E] transition-[color,transform] group-hover:translate-x-0.5 group-hover:text-[#A1A1AA]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{email && (
|
||||
<p className="mt-5 border-t border-white/[0.06] pt-4 text-[12px] font-medium text-[#525D6E]">
|
||||
Signed in as {email}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : error ? (
|
||||
<section
|
||||
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-8 text-center"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex size-12 items-center justify-center rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<AlertTriangle className="size-5 text-[#E5A94B]" />
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-5 text-[20px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Couldn't set up your Company Brain
|
||||
</p>
|
||||
<p className="max-w-sm text-[13px] text-[#8A94A6]">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
<p className="mt-2 text-[14px] font-medium leading-[1.5] text-[#737373]">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
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"
|
||||
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
<RotateCw className="size-3.5" />
|
||||
Try again
|
||||
</button>
|
||||
</>
|
||||
</Button>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="size-6 animate-spin text-[#4BA0FA]" />
|
||||
<p className="text-[14px] font-medium text-[#8A94A6]">
|
||||
Setting up your Company Brain…
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="relative flex size-14 items-center justify-center">
|
||||
<span className="absolute inset-0 animate-ping rounded-[18px] bg-[#4BA0FA]/10" />
|
||||
<span
|
||||
className="absolute inset-0 rounded-[18px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
<Loader2 className="relative size-5 animate-spin text-[#4BA0FA]" />
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-6 text-[20px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Setting up your Company Brain
|
||||
</p>
|
||||
</>
|
||||
<p className="mt-2 max-w-sm text-[14px] font-medium leading-[1.5] text-[#737373]">
|
||||
Preparing your workspace, then we'll connect it to Slack.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</EntryShell>
|
||||
)
|
||||
}
|
||||
|
||||
function EntryShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-dvh overflow-hidden bg-[#05080D] text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 select-none"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 80% 60% at 50% 40%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 select-none"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at center, rgba(105,167,240,0.22) 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
|
||||
WebkitMaskImage:
|
||||
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
|
||||
}}
|
||||
/>
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-20 px-4 py-4 md:px-10">
|
||||
<LogoFull className="h-5 text-[#fafafa] md:h-6" />
|
||||
</header>
|
||||
<main className="relative z-10 flex min-h-dvh flex-col items-center justify-center px-4 py-20 md:px-10">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useAuth } from "@lib/auth-context"
|
|||
import { authClient } from "@lib/auth"
|
||||
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry"
|
||||
import { BrainShell } from "@/components/onboarding-brain/shell"
|
||||
import {
|
||||
StepAbout,
|
||||
|
|
@ -55,6 +56,20 @@ const getErrorMessage = (error: unknown, fallback: string) => {
|
|||
return fallback
|
||||
}
|
||||
|
||||
const getWorkspaceCreationErrorCopy = (message: string) => {
|
||||
const limit = message.match(/maximum number of workspaces \((\d+)\)/i)?.[1]
|
||||
if (limit) {
|
||||
return {
|
||||
title: "Workspace limit reached",
|
||||
description: `You can own up to ${limit} workspaces. Delete one in Settings or contact support@supermemory.com for a higher limit.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: "Couldn't create workspace",
|
||||
description: message,
|
||||
}
|
||||
}
|
||||
|
||||
export default function BrainOnboardingPage() {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
|
|
@ -64,6 +79,9 @@ export default function BrainOnboardingPage() {
|
|||
|
||||
// `?new=1` forces creating an additional org even when the user already has one.
|
||||
const forceCreate = params?.get("new") === "1"
|
||||
// ensureOrg strips `new` once the org exists, so latch it for `finish`'s reload.
|
||||
const forcedCreateRef = useRef(forceCreate)
|
||||
if (forceCreate) forcedCreateRef.current = true
|
||||
const nameParam = params?.get("name")?.trim() || ""
|
||||
|
||||
const stepFromUrl = (params?.get("step") as BrainStep | null) ?? "about"
|
||||
|
|
@ -73,9 +91,12 @@ export default function BrainOnboardingPage() {
|
|||
|
||||
const [step, setStep] = useState<BrainStep>(initialStep)
|
||||
|
||||
// `?mode=team` wins over email detection so a personal-domain user arriving
|
||||
// from a "set up a Company Brain" CTA doesn't land in personal onboarding.
|
||||
const modeParam = params?.get("mode") === "team" ? "team" : null
|
||||
const detectedMode = useMemo(
|
||||
() => detectModeFromEmail(user?.email),
|
||||
[user?.email],
|
||||
() => modeParam ?? detectModeFromEmail(user?.email),
|
||||
[modeParam, user?.email],
|
||||
)
|
||||
const suggestedWorkspaceName = useMemo(
|
||||
() => workspaceNameFromEmail(user?.email),
|
||||
|
|
@ -113,12 +134,12 @@ export default function BrainOnboardingPage() {
|
|||
sources?: SourcesValues
|
||||
team?: TeamValues
|
||||
}
|
||||
if (cached.mode) setMode(cached.mode)
|
||||
if (cached.mode && !modeParam) setMode(cached.mode)
|
||||
if (cached.about) setAbout((a) => ({ ...a, ...cached.about }))
|
||||
if (cached.sources) setSources((s) => ({ ...s, ...cached.sources }))
|
||||
if (cached.team) setTeam((t) => ({ ...t, ...cached.team }))
|
||||
} catch {}
|
||||
}, [forceCreate])
|
||||
}, [forceCreate, modeParam])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
@ -209,12 +230,12 @@ export default function BrainOnboardingPage() {
|
|||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {}
|
||||
// Extra org from settings: hard-reload so org-scoped caches don't show the previous org's data.
|
||||
if (forceCreate) {
|
||||
if (forcedCreateRef.current) {
|
||||
window.location.href = "/?onboarded=1"
|
||||
return
|
||||
}
|
||||
router.push("/?onboarded=1")
|
||||
}, [router, mode, sources, team, forceCreate])
|
||||
}, [router, mode, sources, team])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
const idx = steps.indexOf(step)
|
||||
|
|
@ -239,8 +260,16 @@ export default function BrainOnboardingPage() {
|
|||
const creatingOrgRef = useRef(false)
|
||||
|
||||
const ensureOrg = useCallback(
|
||||
async (domainOverride?: string): Promise<boolean> => {
|
||||
if (!forceCreate && organizations && organizations.length > 0)
|
||||
async (
|
||||
domainOverride?: string,
|
||||
createEvenIfExisting = false,
|
||||
): Promise<boolean> => {
|
||||
if (
|
||||
!createEvenIfExisting &&
|
||||
!forceCreate &&
|
||||
organizations &&
|
||||
organizations.length > 0
|
||||
)
|
||||
return false
|
||||
const name = (
|
||||
domainOverride
|
||||
|
|
@ -322,8 +351,14 @@ export default function BrainOnboardingPage() {
|
|||
analytics.onboardingWorkspaceCreateFailed({
|
||||
error: message,
|
||||
})
|
||||
toast.error("Organization was not created", {
|
||||
description: "Please try again from Settings.",
|
||||
const errorCopy = getWorkspaceCreationErrorCopy(message)
|
||||
toast.error(errorCopy.title, {
|
||||
description: errorCopy.description,
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: "Open Settings",
|
||||
onClick: () => router.push("/settings"),
|
||||
},
|
||||
})
|
||||
if (forceCreate && (organizations?.length ?? 0) > 0) {
|
||||
router.replace("/")
|
||||
|
|
@ -336,7 +371,10 @@ export default function BrainOnboardingPage() {
|
|||
const isCompanyBrain = mode === "team"
|
||||
|
||||
const handleBrainConfirm = useCallback(
|
||||
async (confirmedDomain: string): Promise<CompanyBrainConfirmResult> => {
|
||||
async (
|
||||
confirmedDomain: string,
|
||||
organizationId?: string,
|
||||
): Promise<CompanyBrainConfirmResult> => {
|
||||
if (creatingOrgRef.current) return { ok: false }
|
||||
creatingOrgRef.current = true
|
||||
setCreatingOrg(true)
|
||||
|
|
@ -347,7 +385,42 @@ export default function BrainOnboardingPage() {
|
|||
workspaceDomain: confirmedDomain,
|
||||
workspaceName: workspaceName || a.workspaceName,
|
||||
}))
|
||||
const orgCreated = await ensureOrg(confirmedDomain)
|
||||
let orgCreated = false
|
||||
if (forceCreate) {
|
||||
orgCreated = await ensureOrg(confirmedDomain, true)
|
||||
} else if (organizationId) {
|
||||
const selected = organizations?.find(
|
||||
(organization) => organization.id === organizationId,
|
||||
)
|
||||
if (!selected) return { ok: false }
|
||||
if (selected.id !== org?.id) await setActiveOrg(selected.slug)
|
||||
} else {
|
||||
const organizationsWithActiveMetadata = (organizations ?? []).map(
|
||||
(organization) =>
|
||||
organization.id === org?.id
|
||||
? { ...organization, metadata: org.metadata }
|
||||
: organization,
|
||||
)
|
||||
const decision = resolveCompanyBrainEntry(
|
||||
org?.id,
|
||||
organizationsWithActiveMetadata,
|
||||
confirmedDomain,
|
||||
)
|
||||
if (decision.action === "choose") {
|
||||
return {
|
||||
ok: false,
|
||||
choices: decision.organizations.map((organization) => ({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (decision.action === "switch") {
|
||||
await setActiveOrg(decision.organization.slug)
|
||||
} else if (decision.action === "create") {
|
||||
orgCreated = await ensureOrg(confirmedDomain, true)
|
||||
}
|
||||
}
|
||||
// Re-entering onboarding on an existing org ("Try onboarding") must
|
||||
// kick research from the client. New orgs rely on the signup hook after
|
||||
// provisioning — a duplicate /start races and can strand the DO task.
|
||||
|
|
@ -387,8 +460,14 @@ export default function BrainOnboardingPage() {
|
|||
const message = getErrorMessage(e, "Organization was not created.")
|
||||
console.error("Failed to create organization:", e)
|
||||
analytics.onboardingWorkspaceCreateFailed({ error: message })
|
||||
toast.error("Organization was not created", {
|
||||
description: "Please try again.",
|
||||
const errorCopy = getWorkspaceCreationErrorCopy(message)
|
||||
toast.error(errorCopy.title, {
|
||||
description: errorCopy.description,
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: "Open Settings",
|
||||
onClick: () => router.push("/settings"),
|
||||
},
|
||||
})
|
||||
return { ok: false }
|
||||
} finally {
|
||||
|
|
@ -396,7 +475,15 @@ export default function BrainOnboardingPage() {
|
|||
setCreatingOrg(false)
|
||||
}
|
||||
},
|
||||
[ensureOrg, queryClient],
|
||||
[
|
||||
ensureOrg,
|
||||
forceCreate,
|
||||
org,
|
||||
organizations,
|
||||
queryClient,
|
||||
setActiveOrg,
|
||||
router,
|
||||
],
|
||||
)
|
||||
|
||||
const [sendingInvites, setSendingInvites] = useState(false)
|
||||
|
|
|
|||
536
apps/web/app/slack/link/page.tsx
Normal file
536
apps/web/app/slack/link/page.tsx
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { ArrowRight, Check, LoaderIcon } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { getBackendUrl } from "@/lib/url-helpers"
|
||||
|
||||
const GRADIENT_BG =
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)"
|
||||
const GRADIENT_SHADOW =
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)"
|
||||
|
||||
type LinkPreview = {
|
||||
status: "ready"
|
||||
orgName: string
|
||||
teamId: string
|
||||
teamName: string | null
|
||||
slackDisplayName: string | null
|
||||
slackEmail: string | null
|
||||
signedInEmail: string
|
||||
isOrgMember: boolean
|
||||
requiresRelink: boolean
|
||||
}
|
||||
|
||||
type PageState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; preview: LinkPreview }
|
||||
| { kind: "linking"; preview: LinkPreview }
|
||||
| { kind: "linked"; orgName: string; teamId: string }
|
||||
| {
|
||||
kind: "error"
|
||||
reason: "expired" | "used" | "invalid" | "not_in_org" | "unknown"
|
||||
}
|
||||
|
||||
const ERROR_COPY: Record<
|
||||
Extract<PageState, { kind: "error" }>["reason"],
|
||||
{ title: string; body: string }
|
||||
> = {
|
||||
expired: {
|
||||
title: "This link has expired",
|
||||
body: "Return to Slack and ask Company Brain again to generate a fresh account link.",
|
||||
},
|
||||
used: {
|
||||
title: "This link was already used",
|
||||
body: "Your account may already be connected. Return to Slack and try your request again.",
|
||||
},
|
||||
invalid: {
|
||||
title: "We couldn't verify this link",
|
||||
body: "Return to Slack and use the latest link sent by Company Brain.",
|
||||
},
|
||||
not_in_org: {
|
||||
title: "This account isn't in the workspace",
|
||||
body: "Sign in with a Supermemory account that already belongs to this organization, or ask an admin to add you.",
|
||||
},
|
||||
unknown: {
|
||||
title: "We couldn't finish the connection",
|
||||
body: "Nothing was changed. Please try again, or return to Slack for a fresh link.",
|
||||
},
|
||||
}
|
||||
|
||||
function loginRedirectUrl(): string {
|
||||
const redirect = window.location.href
|
||||
return `/login?redirect=${encodeURIComponent(redirect)}`
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<Record<string, unknown>> {
|
||||
return (await response.json().catch(() => ({}))) as Record<string, unknown>
|
||||
}
|
||||
|
||||
export default function SlackAccountLinkPage() {
|
||||
const params = useSearchParams()
|
||||
const token = params.get("token")
|
||||
const { session, user, isSessionPending } = useAuth()
|
||||
const [state, setState] = useState<PageState>({ kind: "loading" })
|
||||
|
||||
const loadPreview = useCallback(async () => {
|
||||
if (!token) {
|
||||
setState({ kind: "error", reason: "invalid" })
|
||||
return
|
||||
}
|
||||
const response = await fetch(
|
||||
`${getBackendUrl()}/brain/slack/account-link/${encodeURIComponent(token)}`,
|
||||
{
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
},
|
||||
)
|
||||
const body = await readJson(response)
|
||||
if (!response.ok) {
|
||||
const reason = body.status
|
||||
setState({
|
||||
kind: "error",
|
||||
reason:
|
||||
reason === "expired" || reason === "used" || reason === "invalid"
|
||||
? reason
|
||||
: "unknown",
|
||||
})
|
||||
return
|
||||
}
|
||||
setState({ kind: "ready", preview: body as LinkPreview })
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSessionPending) return
|
||||
if (!session) {
|
||||
window.location.replace(loginRedirectUrl())
|
||||
return
|
||||
}
|
||||
void loadPreview().catch(() => {
|
||||
setState({ kind: "error", reason: "unknown" })
|
||||
})
|
||||
}, [isSessionPending, session, loadPreview])
|
||||
|
||||
const confirmLink = async (preview: LinkPreview) => {
|
||||
if (!token) return
|
||||
setState({ kind: "linking", preview })
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${getBackendUrl()}/brain/slack/account-link/${encodeURIComponent(token)}`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
},
|
||||
)
|
||||
const body = await readJson(response)
|
||||
if (!response.ok) {
|
||||
const reason = body.status
|
||||
setState({
|
||||
kind: "error",
|
||||
reason:
|
||||
reason === "not_in_org" ||
|
||||
reason === "expired" ||
|
||||
reason === "used" ||
|
||||
reason === "invalid"
|
||||
? reason
|
||||
: "unknown",
|
||||
})
|
||||
return
|
||||
}
|
||||
setState({
|
||||
kind: "linked",
|
||||
orgName:
|
||||
typeof body.orgName === "string" ? body.orgName : preview.orgName,
|
||||
teamId: preview.teamId,
|
||||
})
|
||||
} catch {
|
||||
setState({ kind: "error", reason: "unknown" })
|
||||
}
|
||||
}
|
||||
|
||||
const switchAccount = async () => {
|
||||
await authClient.signOut()
|
||||
window.location.assign(loginRedirectUrl())
|
||||
}
|
||||
|
||||
const recheck = () => {
|
||||
setState({ kind: "loading" })
|
||||
void loadPreview().catch(() => {
|
||||
setState({ kind: "error", reason: "unknown" })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<CardShell>
|
||||
<AnimatePresence mode="wait">
|
||||
{state.kind === "loading" ? (
|
||||
<Fade key="loading">
|
||||
<Card>
|
||||
<div className="flex min-h-[260px] flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<LoaderIcon className="size-5 animate-spin text-[#9AA0A6]" />
|
||||
<p className="text-[13px] text-[#737373]">
|
||||
Verifying your secure link…
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Fade>
|
||||
) : null}
|
||||
|
||||
{state.kind === "ready" || state.kind === "linking" ? (
|
||||
<Fade key="ready">
|
||||
<Card>
|
||||
<div className="px-6 pt-7 pb-5 text-center">
|
||||
<ConnectingHeader />
|
||||
<h1 className="mt-5 text-[19px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
|
||||
{state.preview.isOrgMember
|
||||
? `Link Slack to ${state.preview.orgName}`
|
||||
: `This account isn't in ${state.preview.orgName}`}
|
||||
</h1>
|
||||
<p className="mx-auto mt-1 max-w-[340px] text-[13px] text-[#737373]">
|
||||
{state.preview.isOrgMember
|
||||
? "Company Brain will recognize you by your Slack identity, even when your emails differ."
|
||||
: `Switch to a Supermemory account that belongs to ${state.preview.orgName}, or ask an admin to add ${state.preview.signedInEmail}.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-6 h-px bg-white/[0.06]" />
|
||||
<InfoRow
|
||||
label="Slack"
|
||||
name={
|
||||
state.preview.slackDisplayName ||
|
||||
state.preview.teamName ||
|
||||
"Slack account"
|
||||
}
|
||||
detail={
|
||||
state.preview.slackEmail ||
|
||||
state.preview.teamName ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
<div className="mx-6 h-px bg-white/[0.06]" />
|
||||
<InfoRow
|
||||
label="Supermemory"
|
||||
name={user?.name || "Signed-in account"}
|
||||
detail={state.preview.signedInEmail}
|
||||
warn={!state.preview.isOrgMember}
|
||||
/>
|
||||
<div className="mx-6 h-px bg-white/[0.06]" />
|
||||
|
||||
{state.preview.isOrgMember && state.preview.requiresRelink ? (
|
||||
<p className="px-6 pt-4 text-[12px] leading-5 text-[#5C6470]">
|
||||
This Slack identity is linked to another Supermemory account.
|
||||
Confirming will replace that link for {state.preview.orgName}.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-4">
|
||||
{state.preview.isOrgMember ? (
|
||||
<>
|
||||
<TextButton
|
||||
disabled={state.kind === "linking"}
|
||||
onClick={() => void switchAccount()}
|
||||
>
|
||||
Switch account
|
||||
</TextButton>
|
||||
<GradientButton
|
||||
disabled={state.kind === "linking"}
|
||||
onClick={() => void confirmLink(state.preview)}
|
||||
>
|
||||
{state.kind === "linking" ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="size-4" />
|
||||
{state.preview.requiresRelink
|
||||
? "Replace and link"
|
||||
: "Confirm link"}
|
||||
</>
|
||||
)}
|
||||
</GradientButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextButton onClick={recheck}>
|
||||
I've been added — check again
|
||||
</TextButton>
|
||||
<NeutralButton onClick={() => void switchAccount()}>
|
||||
Switch account
|
||||
</NeutralButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="px-6 pb-4 text-center text-[11px] text-[#5C5C5C]">
|
||||
Signed in as {state.preview.signedInEmail}
|
||||
</p>
|
||||
</Card>
|
||||
</Fade>
|
||||
) : null}
|
||||
|
||||
{state.kind === "linked" ? (
|
||||
<Fade key="linked">
|
||||
<Card>
|
||||
<div className="flex flex-col items-center px-6 pt-8 pb-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-emerald-300 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
|
||||
<Check className="size-6" />
|
||||
</div>
|
||||
<h1 className="mt-5 text-[19px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
|
||||
Slack now knows who you are
|
||||
</h1>
|
||||
<p className="mt-1 max-w-[320px] text-[13px] text-[#737373]">
|
||||
Your account is linked to {state.orgName}. Return to Slack and
|
||||
retry your Company Brain request.
|
||||
</p>
|
||||
<a
|
||||
className={cn(
|
||||
"relative mt-6 flex h-11 min-w-[180px] items-center justify-center gap-2 rounded-[10px] px-6",
|
||||
"text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
|
||||
"transition-opacity hover:opacity-90",
|
||||
)}
|
||||
href={`slack://open?team=${encodeURIComponent(state.teamId)}`}
|
||||
style={{
|
||||
background: GRADIENT_BG,
|
||||
boxShadow: GRADIENT_SHADOW,
|
||||
}}
|
||||
>
|
||||
Return to Slack
|
||||
<ArrowRight className="size-4" />
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</Fade>
|
||||
) : null}
|
||||
|
||||
{state.kind === "error" ? (
|
||||
<Fade key="error">
|
||||
<Card>
|
||||
<ErrorState
|
||||
reason={state.reason}
|
||||
onSwitchAccount={() => void switchAccount()}
|
||||
/>
|
||||
</Card>
|
||||
</Fade>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
|
||||
function CardShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="relative flex min-h-dvh items-center justify-center bg-[#08090C] p-4">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(60% 50% at 50% 0%, rgba(75,160,250,0.05), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn("relative w-full max-w-[440px]", dmSans125ClassName())}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[14px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Fade({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectingHeader() {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-[#FAFAFA] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
|
||||
<Logo className="h-6 w-auto text-white" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{["a", "b", "c"].map((k, i) => (
|
||||
<span
|
||||
className="size-1.5 animate-pulse rounded-full bg-[#525660]"
|
||||
key={k}
|
||||
style={{
|
||||
animationDelay: `${i * 220}ms`,
|
||||
animationDuration: "1100ms",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
|
||||
<SlackMark className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
name,
|
||||
detail,
|
||||
warn,
|
||||
}: {
|
||||
label: string
|
||||
name: string
|
||||
detail?: string
|
||||
warn?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-3.5">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]">
|
||||
{label}
|
||||
</span>
|
||||
<p className="truncate text-[14px] font-medium text-[#FAFAFA]">
|
||||
{name}
|
||||
</p>
|
||||
{detail ? (
|
||||
<p className="truncate text-[12px] text-[#737373]">{detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{warn ? (
|
||||
<span className="shrink-0 rounded-[7px] bg-amber-400/10 px-2 py-1 text-[11px] font-medium text-amber-300">
|
||||
Not a member
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="rounded-[10px] px-3 py-2.5 text-left text-[13px] font-medium text-[#9AA0A6] transition-colors hover:text-[#FAFAFA] disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NeutralButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-10 shrink-0 items-center justify-center gap-2 rounded-[10px] bg-[#FAFAFA] px-5",
|
||||
"text-[13px] font-medium text-[#0B0D11]",
|
||||
"cursor-pointer transition-colors hover:bg-white/85 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function GradientButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"relative flex h-11 min-w-[150px] shrink-0 items-center justify-center gap-2 rounded-[10px] px-6",
|
||||
"text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
style={{ background: GRADIENT_BG, boxShadow: GRADIENT_SHADOW }}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorState({
|
||||
reason,
|
||||
onSwitchAccount,
|
||||
}: {
|
||||
reason: Extract<PageState, { kind: "error" }>["reason"]
|
||||
onSwitchAccount: () => void
|
||||
}) {
|
||||
const copy = ERROR_COPY[reason]
|
||||
return (
|
||||
<div className="flex flex-col items-center px-6 pt-8 pb-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-amber-300 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
|
||||
<SlackMark className="size-6" />
|
||||
</div>
|
||||
<h1 className="mt-5 text-[19px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-[320px] text-[13px] text-[#737373]">
|
||||
{copy.body}
|
||||
</p>
|
||||
{reason === "not_in_org" ? (
|
||||
<div className="mt-6">
|
||||
<NeutralButton onClick={onSwitchAccount}>
|
||||
Switch account
|
||||
</NeutralButton>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
className="mt-6 flex h-10 items-center justify-center gap-2 rounded-[10px] border border-white/[0.08] bg-[#0B0D11] px-5 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#1B1E25]"
|
||||
href="slack://open"
|
||||
>
|
||||
Return to Slack
|
||||
<ArrowRight className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
|||
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { BrainHomeView } from "@/components/brain-home/brain-home-view"
|
||||
import { CompanyBrainPromo } from "@/components/company-brain-promo"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
|
|
@ -802,7 +803,7 @@ export function AppExperience() {
|
|||
) : (
|
||||
<DashboardView
|
||||
spaceLabel={dashboardSpaceLabel}
|
||||
headerNotice={undefined}
|
||||
headerNotice={<CompanyBrainPromo />}
|
||||
highlights={highlightsData?.highlights ?? []}
|
||||
isLoadingHighlights={isLoadingHighlights}
|
||||
onAddMemory={handleAddMemory}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
|||
|
||||
export const DEFAULT_CHAT_PROMPTS = [
|
||||
"What do you know about me?",
|
||||
"What have I been working on lately?",
|
||||
"What themes keep showing up in my memories?",
|
||||
"Set up Cursor",
|
||||
"Show my active plugins",
|
||||
] as const
|
||||
|
||||
const SUGGESTION_PILL_CLASS = cn(
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import { useQuery } from "@tanstack/react-query"
|
|||
import { Streamdown } from "streamdown"
|
||||
import {
|
||||
BookOpenIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
ListIcon,
|
||||
Loader2,
|
||||
|
|
@ -17,6 +20,7 @@ import {
|
|||
TerminalIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
|
||||
|
|
@ -84,6 +88,107 @@ function faviconUrl(host: string): string {
|
|||
return `https://www.google.com/s2/favicons?sz=64&domain=${host}`
|
||||
}
|
||||
|
||||
type NovaConnectorStatus =
|
||||
| "active"
|
||||
| "setup_pending"
|
||||
| "not_connected"
|
||||
| "upgrade_required"
|
||||
| "setup_available"
|
||||
|
||||
type NovaConnectorStep = {
|
||||
title?: string
|
||||
description?: string
|
||||
code?: string
|
||||
link?: { url: string; label: string }
|
||||
createPluginKey?: boolean
|
||||
}
|
||||
|
||||
type NovaConnectorCardData = {
|
||||
kind?: "plugin" | "mcp"
|
||||
id?: string
|
||||
name?: string
|
||||
icon?: string
|
||||
description?: string
|
||||
features?: string[]
|
||||
docsUrl?: string
|
||||
repoUrl?: string
|
||||
installSteps?: NovaConnectorStep[]
|
||||
status?: NovaConnectorStatus
|
||||
requiresPro?: boolean
|
||||
canGenerateKey?: boolean
|
||||
keyPluginId?: string
|
||||
}
|
||||
|
||||
type NovaConnectorToolOutput = {
|
||||
success?: boolean
|
||||
error?: string
|
||||
kind?: string
|
||||
connectors?: NovaConnectorCardData[]
|
||||
connector?: NovaConnectorCardData
|
||||
keyReveal?: { pluginId: string; label?: string } | null
|
||||
available?: Array<{ kind: "plugin" | "mcp"; id: string; name: string }>
|
||||
}
|
||||
|
||||
const NOVA_CONNECTOR_TOOLS = new Set([
|
||||
"listNovaConnectors",
|
||||
"getNovaConnectorSetup",
|
||||
"prepareNovaPluginSetup",
|
||||
])
|
||||
|
||||
const CONNECTOR_ICON_FALLBACKS: Record<string, string> = {
|
||||
codex: "/images/plugins/codex.png",
|
||||
cursor: "/images/plugins/cursor.png",
|
||||
mcp_cursor: "/mcp-supported-tools/cursor.png",
|
||||
}
|
||||
|
||||
const STATUS_COPY: Record<
|
||||
NovaConnectorStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
active: {
|
||||
label: "Active",
|
||||
className: "border-emerald-400/20 bg-emerald-400/10 text-emerald-300",
|
||||
},
|
||||
setup_pending: {
|
||||
label: "Finish setup",
|
||||
className: "border-amber-400/20 bg-amber-400/10 text-amber-300",
|
||||
},
|
||||
not_connected: {
|
||||
label: "Not connected",
|
||||
className: "border-white/10 bg-white/[0.05] text-white/55",
|
||||
},
|
||||
upgrade_required: {
|
||||
label: "Pro required",
|
||||
className: "border-[#4BA0FA]/25 bg-[#4BA0FA]/10 text-[#4BA0FA]",
|
||||
},
|
||||
setup_available: {
|
||||
label: "Setup available",
|
||||
className: "border-white/10 bg-white/[0.05] text-white/65",
|
||||
},
|
||||
}
|
||||
|
||||
function connectorToolName(part: ToolCallDisplayPart): string {
|
||||
return part.type.startsWith("tool-")
|
||||
? part.type.slice("tool-".length)
|
||||
: part.type
|
||||
}
|
||||
|
||||
function connectorToolNameFromPart(part: unknown): string | null {
|
||||
if (!part || typeof part !== "object") return null
|
||||
const record = part as { type?: string; toolName?: string }
|
||||
if (record.type === "dynamic-tool") return record.toolName ?? null
|
||||
if (record.type?.startsWith("tool-")) return record.type.slice("tool-".length)
|
||||
return null
|
||||
}
|
||||
|
||||
function parseConnectorOutput(value: string): NovaConnectorToolOutput | null {
|
||||
try {
|
||||
return JSON.parse(value) as NovaConnectorToolOutput
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeExternalUrl(url: string | null | undefined): string | null {
|
||||
if (!url) return null
|
||||
if (url.startsWith("/") && !url.startsWith("//")) return url
|
||||
|
|
@ -98,6 +203,491 @@ function safeExternalUrl(url: string | null | undefined): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function unwrapToolOutput(output: unknown): NovaConnectorToolOutput | null {
|
||||
if (typeof output === "string") {
|
||||
return parseConnectorOutput(output)
|
||||
}
|
||||
if (!output || typeof output !== "object") return null
|
||||
const record = output as Record<string, unknown>
|
||||
for (const key of ["value", "result", "data", "output"]) {
|
||||
const nested = record[key]
|
||||
if (nested && nested !== output) {
|
||||
const parsed = unwrapToolOutput(nested)
|
||||
if (parsed) return parsed
|
||||
}
|
||||
}
|
||||
if (
|
||||
record.type === "json" &&
|
||||
record.value &&
|
||||
typeof record.value === "object"
|
||||
) {
|
||||
return record.value as NovaConnectorToolOutput
|
||||
}
|
||||
if (record.type === "text" && typeof record.value === "string") {
|
||||
return parseConnectorOutput(record.value)
|
||||
}
|
||||
if (typeof record.text === "string") {
|
||||
return parseConnectorOutput(record.text)
|
||||
}
|
||||
return record as NovaConnectorToolOutput
|
||||
}
|
||||
|
||||
function connectorIconSrc(
|
||||
connector: NovaConnectorCardData,
|
||||
): string | undefined {
|
||||
if (connector.id && CONNECTOR_ICON_FALLBACKS[connector.id]) {
|
||||
return CONNECTOR_ICON_FALLBACKS[connector.id]
|
||||
}
|
||||
if (connector.icon?.endsWith("/codex.svg"))
|
||||
return CONNECTOR_ICON_FALLBACKS.codex
|
||||
if (connector.icon?.endsWith("/cursor.svg"))
|
||||
return CONNECTOR_ICON_FALLBACKS.cursor
|
||||
return connector.icon
|
||||
}
|
||||
|
||||
function connectorCardKey(connector: NovaConnectorCardData): string {
|
||||
return `${connector.kind ?? "connector"}-${connector.id ?? connector.name ?? "unknown"}`
|
||||
}
|
||||
|
||||
function connectorIdentity(
|
||||
output: NovaConnectorToolOutput | null,
|
||||
): string | null {
|
||||
if (!output) return null
|
||||
if (output.connectors && output.connectors.length !== 1) return null
|
||||
const connector = output.connector ?? output.connectors?.[0]
|
||||
if (!connector) return null
|
||||
return `${connector.kind ?? "connector"}:${connector.id ?? connector.name ?? ""}`
|
||||
}
|
||||
|
||||
function connectorOutputFromPart(
|
||||
part: unknown,
|
||||
): NovaConnectorToolOutput | null {
|
||||
if (!part || typeof part !== "object") return null
|
||||
const record = part as {
|
||||
type?: string
|
||||
toolName?: string
|
||||
output?: unknown
|
||||
}
|
||||
const toolName = connectorToolNameFromPart(record)
|
||||
if (!toolName || !NOVA_CONNECTOR_TOOLS.has(toolName)) return null
|
||||
return unwrapToolOutput(record.output)
|
||||
}
|
||||
|
||||
function connectorToolPriority(toolName: string | null): number {
|
||||
if (toolName === "prepareNovaPluginSetup") return 2
|
||||
if (toolName === "getNovaConnectorSetup") return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function shouldSkipNovaConnectorPart(parts: unknown[], index: number): boolean {
|
||||
const part = parts[index]
|
||||
const toolName = connectorToolNameFromPart(part)
|
||||
if (!toolName || !NOVA_CONNECTOR_TOOLS.has(toolName)) return false
|
||||
const identity = connectorIdentity(connectorOutputFromPart(part))
|
||||
if (!identity) return false
|
||||
const priority = connectorToolPriority(toolName)
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (i === index) continue
|
||||
const otherTool = connectorToolNameFromPart(parts[i])
|
||||
if (!otherTool || !NOVA_CONNECTOR_TOOLS.has(otherTool)) continue
|
||||
const otherIdentity = connectorIdentity(connectorOutputFromPart(parts[i]))
|
||||
if (otherIdentity !== identity) continue
|
||||
const otherPriority = connectorToolPriority(otherTool)
|
||||
if (i < index && otherPriority >= priority) return true
|
||||
if (i > index && otherPriority > priority) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status?: NovaConnectorStatus }) {
|
||||
const copy =
|
||||
STATUS_COPY[status ?? "not_connected"] ?? STATUS_COPY.not_connected
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 shrink-0 items-center rounded-full border px-2 text-[11px] font-medium",
|
||||
copy.className,
|
||||
)}
|
||||
>
|
||||
{copy.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniCopyButton({ text, label }: { text: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Copy ${label ?? "value"}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
} catch {
|
||||
setCopied(false)
|
||||
}
|
||||
}}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-white/45 transition-colors hover:text-white/80"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3.5 text-emerald-300" />
|
||||
) : (
|
||||
<CopyIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectorCodeBlock({
|
||||
code,
|
||||
apiKey,
|
||||
}: {
|
||||
code: string
|
||||
apiKey?: string
|
||||
}) {
|
||||
const rendered = apiKey ? code.replaceAll("sm_...", apiKey) : code
|
||||
return (
|
||||
<div className="group flex min-w-0 items-center gap-2 rounded-[10px] border border-white/[0.07] bg-[#080B10] px-3 py-2.5">
|
||||
<pre
|
||||
className={cn(
|
||||
"scrollbar-none min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[12px] leading-[1.6] text-[#E4E4E7]",
|
||||
code.includes("sm_...") &&
|
||||
!apiKey &&
|
||||
"select-none blur-[4px] transition-[filter] group-hover:blur-none",
|
||||
)}
|
||||
>
|
||||
{rendered}
|
||||
</pre>
|
||||
<MiniCopyButton text={rendered} label="setup step" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RevealPluginKeyButton({
|
||||
pluginId,
|
||||
onReveal,
|
||||
}: {
|
||||
pluginId: string
|
||||
onReveal: (key: string) => void
|
||||
}) {
|
||||
const [state, setState] = useState<"idle" | "loading" | "copied" | "error">(
|
||||
"idle",
|
||||
)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={state === "loading"}
|
||||
onClick={async () => {
|
||||
setState("loading")
|
||||
try {
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const params = new URLSearchParams({ client: pluginId })
|
||||
const res = await fetch(`${API_URL}/v3/auth/key?${params}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to create plugin key")
|
||||
const data = (await res.json()) as { key?: string }
|
||||
if (!data.key) throw new Error("Plugin key missing")
|
||||
onReveal(data.key)
|
||||
await navigator.clipboard.writeText(data.key).catch(() => undefined)
|
||||
setState("copied")
|
||||
setTimeout(() => setState("idle"), 2200)
|
||||
} catch {
|
||||
setState("error")
|
||||
setTimeout(() => setState("idle"), 2200)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{state === "loading" ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : state === "copied" ? (
|
||||
<CheckIcon className="size-3.5 text-emerald-300" />
|
||||
) : state === "error" ? (
|
||||
<XCircleIcon className="size-3.5 text-red-300" />
|
||||
) : null}
|
||||
{state === "copied"
|
||||
? "Key copied"
|
||||
: state === "error"
|
||||
? "Try again"
|
||||
: state === "loading"
|
||||
? "Generating"
|
||||
: "Generate key"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NovaConnectorCard({
|
||||
connector,
|
||||
}: {
|
||||
connector: NovaConnectorCardData
|
||||
}) {
|
||||
const [revealedKey, setRevealedKey] = useState<string | undefined>()
|
||||
const needsKey = Boolean(connector.canGenerateKey && connector.keyPluginId)
|
||||
const isUpgrade = connector.status === "upgrade_required"
|
||||
const iconSrc = connectorIconSrc(connector)
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.08] bg-[#0D121A] p-3 text-sm text-white/90 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F]">
|
||||
{iconSrc ? (
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
className="size-6 rounded object-contain"
|
||||
onError={(event) => {
|
||||
const img = event.currentTarget
|
||||
const fallback = connector.id
|
||||
? CONNECTOR_ICON_FALLBACKS[connector.id]
|
||||
: undefined
|
||||
if (fallback && img.dataset.fallbackApplied !== "true") {
|
||||
img.dataset.fallbackApplied = "true"
|
||||
img.src = fallback
|
||||
} else {
|
||||
img.style.display = "none"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<WrenchIcon className="size-5 text-white/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-[14px] font-semibold text-[#FAFAFA]">
|
||||
{connector.name ?? connector.id ?? "Connector"}
|
||||
</p>
|
||||
<StatusPill status={connector.status} />
|
||||
</div>
|
||||
{connector.description ? (
|
||||
<p className="mt-1 text-[12px] leading-snug text-[#A1A1AA]">
|
||||
{connector.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connector.installSteps?.length ? (
|
||||
<ol className="mt-3 space-y-3">
|
||||
{connector.installSteps.map((step, index) => (
|
||||
<li key={`${step.title}-${index}`} className="flex gap-2.5">
|
||||
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[#080B10] text-[10px] font-semibold text-[#4BA0FA]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<p className="text-[12px] font-medium text-[#FAFAFA]">
|
||||
{step.title}
|
||||
</p>
|
||||
{step.description ? (
|
||||
<p className="text-[12px] leading-snug text-[#A1A1AA]">
|
||||
{step.description}
|
||||
</p>
|
||||
) : null}
|
||||
{step.code ? (
|
||||
<ConnectorCodeBlock code={step.code} apiKey={revealedKey} />
|
||||
) : null}
|
||||
{step.link ? (
|
||||
<a
|
||||
href={step.link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[12px] text-[#4BA0FA] hover:text-[#86C5FF]"
|
||||
>
|
||||
{step.link.label}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{needsKey && connector.keyPluginId && !isUpgrade ? (
|
||||
<RevealPluginKeyButton
|
||||
pluginId={connector.keyPluginId}
|
||||
onReveal={setRevealedKey}
|
||||
/>
|
||||
) : null}
|
||||
{isUpgrade ? (
|
||||
<span className="inline-flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#4BA0FA]">
|
||||
<ZapIcon className="size-3.5" />
|
||||
Upgrade to connect
|
||||
</span>
|
||||
) : null}
|
||||
{connector.docsUrl ? (
|
||||
<a
|
||||
href={connector.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-[12px] text-[#A1A1AA] transition-colors hover:text-white"
|
||||
>
|
||||
<BookOpenIcon className="size-3.5" />
|
||||
Docs
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NovaConnectorCompactCard({
|
||||
connector,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
connector: NovaConnectorCardData
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const iconSrc = connectorIconSrc(connector)
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"flex min-h-14 w-full cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 text-left transition-colors focus:outline-none focus-visible:border-[#4BA0FA]/50",
|
||||
expanded
|
||||
? "border-[#4BA0FA]/30 bg-[#111820]"
|
||||
: "border-white/[0.08] bg-[#0D121A] hover:border-white/[0.14] hover:bg-[#111820]",
|
||||
)}
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-[8px] bg-[#080B0F]">
|
||||
{iconSrc ? (
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
className="size-5 rounded object-contain"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = "none"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<WrenchIcon className="size-4 text-white/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[12px] font-semibold text-[#FAFAFA]">
|
||||
{connector.name ?? connector.id ?? "Connector"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-[#737373]">
|
||||
{connector.kind === "mcp" ? "MCP" : "Plugin"}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill status={connector.status} />
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 text-[#737373]" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3.5 shrink-0 text-[#737373]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) {
|
||||
const [expandedConnectorKey, setExpandedConnectorKey] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const toolName = connectorToolName(part)
|
||||
const output = unwrapToolOutput(part.output)
|
||||
const isLoading =
|
||||
part.state === "input-streaming" || part.state === "input-available"
|
||||
const isError = part.state === "error" || part.state === "output-error"
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="my-2 flex items-center gap-2 rounded-xl border border-white/[0.08] bg-[#0D121A] px-3 py-2 text-xs text-white/55">
|
||||
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA]" />
|
||||
<span>Checking Supermemory setup…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="my-2 rounded-xl border border-red-400/15 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||
Couldn't load connector setup.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!output) return null
|
||||
if (output.success === false) {
|
||||
return (
|
||||
<div className="my-2 rounded-xl border border-white/[0.08] bg-[#0D121A] p-3 text-sm text-white/80">
|
||||
<p className="font-medium text-[#FAFAFA]">
|
||||
{output.error ?? "Connector not found"}
|
||||
</p>
|
||||
{output.available?.length ? (
|
||||
<p className="mt-1 text-xs text-[#A1A1AA]">
|
||||
Try one of: {output.available.map((item) => item.name).join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const connectors = output.connector
|
||||
? [output.connector]
|
||||
: (output.connectors ?? [])
|
||||
const isConnectorList =
|
||||
toolName === "listNovaConnectors" && connectors.length > 1
|
||||
const expandedConnector =
|
||||
isConnectorList && expandedConnectorKey
|
||||
? connectors.find(
|
||||
(connector) => connectorCardKey(connector) === expandedConnectorKey,
|
||||
)
|
||||
: null
|
||||
return (
|
||||
<div className="my-2 space-y-2">
|
||||
{isConnectorList ? (
|
||||
<p className="px-1 text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]">
|
||||
Supermemory setup options
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
isConnectorList && "grid grid-cols-1 gap-2 sm:grid-cols-2",
|
||||
!isConnectorList && "space-y-2",
|
||||
)}
|
||||
>
|
||||
{connectors.map((connector) =>
|
||||
isConnectorList ? (
|
||||
<NovaConnectorCompactCard
|
||||
key={connectorCardKey(connector)}
|
||||
connector={connector}
|
||||
expanded={connectorCardKey(connector) === expandedConnectorKey}
|
||||
onToggle={() => {
|
||||
const nextKey = connectorCardKey(connector)
|
||||
setExpandedConnectorKey((current) =>
|
||||
current === nextKey ? null : nextKey,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<NovaConnectorCard
|
||||
key={connectorCardKey(connector)}
|
||||
connector={connector}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{expandedConnector ? (
|
||||
<div className="pt-1">
|
||||
<NovaConnectorCard connector={expandedConnector} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isWebSearchPart(part: { type: string; toolName?: string }): boolean {
|
||||
if (part.type === "dynamic-tool") {
|
||||
return isWebSearchToolName(part.toolName ?? "")
|
||||
|
|
@ -548,7 +1138,10 @@ function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) {
|
|||
|
||||
function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const toolName = part.type.replace("tool-", "")
|
||||
const toolName = connectorToolName(part)
|
||||
if (NOVA_CONNECTOR_TOOLS.has(toolName)) {
|
||||
return <NovaConnectorToolDisplay part={part} />
|
||||
}
|
||||
if (toolName === "bash") {
|
||||
return <BashToolDisplay part={part} />
|
||||
}
|
||||
|
|
@ -829,6 +1422,9 @@ export function AgentMessage({
|
|||
)
|
||||
}
|
||||
if (part.type === "dynamic-tool") {
|
||||
if (shouldSkipNovaConnectorPart(message.parts, partIndex)) {
|
||||
return null
|
||||
}
|
||||
const dt = part as {
|
||||
type: "dynamic-tool"
|
||||
toolName: string
|
||||
|
|
@ -856,6 +1452,9 @@ export function AgentMessage({
|
|||
)
|
||||
}
|
||||
if (part.type.startsWith("tool-")) {
|
||||
if (shouldSkipNovaConnectorPart(message.parts, partIndex)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ToolCallDisplay
|
||||
key={`${message.id}-${partIndex}`}
|
||||
|
|
|
|||
89
apps/web/components/company-brain-promo.tsx
Normal file
89
apps/web/components/company-brain-promo.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowRight, XIcon } from "lucide-react"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
|
||||
const DISMISS_KEY = "supermemory-company-brain-promo-dismissed-v1"
|
||||
|
||||
export function CompanyBrainPromo() {
|
||||
const router = useRouter()
|
||||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
const [dismissed, setDismissed] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCompanyBrain) return
|
||||
try {
|
||||
setDismissed(localStorage.getItem(DISMISS_KEY) === "1")
|
||||
} catch {
|
||||
setDismissed(false)
|
||||
}
|
||||
}, [hasCompanyBrain])
|
||||
|
||||
const visible = !hasCompanyBrain && !dismissed
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) analytics.companyBrainPromoSeen()
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const dismiss = () => {
|
||||
setDismissed(true)
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, "1")
|
||||
} catch {}
|
||||
analytics.companyBrainPromoDismissed()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md",
|
||||
"shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef]">
|
||||
<Logo className="h-4 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[15px] font-semibold text-[#fafafa]">
|
||||
Give your team a Company Brain
|
||||
</p>
|
||||
<p className="text-[13px] text-[#a1a1a1]">
|
||||
Lives in your Slack. Answers from your team's tools, and brings things
|
||||
up before you ask.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0 gap-1.5 px-3 font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => {
|
||||
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
|
||||
router.push("/onboarding?new=1&mode=team")
|
||||
}}
|
||||
variant="headers"
|
||||
>
|
||||
Set it up
|
||||
<ArrowRight className="size-4 shrink-0" />
|
||||
</Button>
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +1,30 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Blocks, CalendarClock, Cpu } from "lucide-react"
|
||||
import { Blocks, CalendarClock, Cpu, ScrollText } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import CompanyBrainModels from "@/components/settings/company-brain-models"
|
||||
import CompanyBrainProactivity from "@/components/settings/company-brain-proactivity"
|
||||
import Proactiveness from "@/components/settings/proactiveness"
|
||||
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
|
||||
import { WorkspacePrompt } from "@/components/settings/workspace-prompt"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
type ConfigureSection = "company-brain" | "models" | "automations"
|
||||
type ConfigureSection =
|
||||
| "company-brain"
|
||||
| "models"
|
||||
| "workspace-prompt"
|
||||
| "proactivity"
|
||||
| "automations"
|
||||
|
||||
const SECTIONS: {
|
||||
id: ConfigureSection
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Blocks
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}[] = [
|
||||
{
|
||||
id: "company-brain",
|
||||
|
|
@ -31,6 +40,20 @@ const SECTIONS: {
|
|||
"Pick how fast or thorough your brain should be. Fine-tune each task under Advanced.",
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
id: "workspace-prompt",
|
||||
label: "Workspace Prompt",
|
||||
description:
|
||||
"Persistent guidance for how your brain works across the workspace. Fixed safety, access, and approval constraints still apply.",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
id: "proactivity",
|
||||
label: "Proactivity",
|
||||
description:
|
||||
"When Company Brain speaks up in Slack without being asked. Quiet channels are still read and remembered.",
|
||||
icon: ProactivenessIcon,
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
label: "Automations",
|
||||
|
|
@ -41,6 +64,7 @@ const SECTIONS: {
|
|||
]
|
||||
|
||||
export function ConfigureView() {
|
||||
const { org } = useAuth()
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<ConfigureSection>("company-brain")
|
||||
const active = SECTIONS.find((section) => section.id === activeSection)
|
||||
|
|
@ -48,16 +72,13 @@ export function ConfigureView() {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mx-auto flex min-h-full w-full max-w-[88rem] flex-col",
|
||||
)}
|
||||
className={cn(dmSans125ClassName(), "flex min-h-full w-full flex-col")}
|
||||
>
|
||||
<section
|
||||
aria-label="Configure Company Brain"
|
||||
className="flex flex-1 flex-col rounded-[14px] bg-[#191D24] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-6"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-5 md:flex-row md:gap-8">
|
||||
<div className="mx-auto flex w-full max-w-[88rem] flex-1 flex-col gap-5 md:flex-row md:gap-8">
|
||||
<nav
|
||||
aria-label="Configure sections"
|
||||
className="scrollbar-none flex shrink-0 gap-1 overflow-x-auto md:w-52 md:flex-col md:overflow-x-visible"
|
||||
|
|
@ -118,6 +139,10 @@ export function ConfigureView() {
|
|||
<CompanyBrainConnections />
|
||||
) : activeSection === "models" ? (
|
||||
<CompanyBrainModels showHeading={false} />
|
||||
) : activeSection === "workspace-prompt" ? (
|
||||
<WorkspacePrompt key={org?.id} showHeading={false} />
|
||||
) : activeSection === "proactivity" ? (
|
||||
<CompanyBrainProactivity />
|
||||
) : (
|
||||
<Proactiveness />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -650,15 +650,29 @@ export function ConnectAIModal({
|
|||
if (manual.kind === "chatgpt") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs leading-relaxed text-amber-600 dark:text-amber-500/90">
|
||||
Write-capable custom MCP apps are only supported on
|
||||
Business & Enterprise plans.
|
||||
</p>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm text-muted-foreground">
|
||||
<li>Open ChatGPT in your browser.</li>
|
||||
<li>
|
||||
Settings → Apps → Advanced settings → enable
|
||||
Developer mode.
|
||||
Go to Settings → Security and Login → scroll to
|
||||
the bottom to enable Developer mode.
|
||||
</li>
|
||||
<li>
|
||||
Create an app and paste the MCP URL when asked.
|
||||
Go to{" "}
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href="https://chatgpt.com/plugins"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
chatgpt.com/plugins
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Paste the URL below.</li>
|
||||
<li>Complete OAuth in ChatGPT.</li>
|
||||
</ol>
|
||||
<div className="relative max-w-full sm:max-w-xl">
|
||||
|
|
|
|||
|
|
@ -502,19 +502,30 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
if (manual.kind === "chatgpt") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[12px] leading-relaxed text-amber-500/90">
|
||||
Write-capable custom MCP apps are only supported on
|
||||
Business & Enterprise plans.
|
||||
</p>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-[13px] leading-relaxed text-[#A1A1AA]">
|
||||
<li>Open ChatGPT in your browser.</li>
|
||||
<li>
|
||||
Go to Settings → Apps → Advanced settings → enable
|
||||
Developer mode.
|
||||
Go to Settings → Security and Login → scroll to the
|
||||
bottom to enable Developer mode.
|
||||
</li>
|
||||
<li>
|
||||
Create an app and choose your MCP server URL when
|
||||
asked.
|
||||
</li>
|
||||
<li>
|
||||
Paste the URL below and complete OAuth in ChatGPT.
|
||||
Go to{" "}
|
||||
<a
|
||||
className="text-[#4BA0FA] underline hover:text-[#8BC6FF]"
|
||||
href="https://chatgpt.com/plugins"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
chatgpt.com/plugins
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Paste the URL below.</li>
|
||||
<li>Complete OAuth in ChatGPT.</li>
|
||||
</ol>
|
||||
<McpCodeBlock
|
||||
code={CHATGPT_REMOTE_MCP_URL}
|
||||
|
|
|
|||
|
|
@ -388,6 +388,13 @@ export function MemoriesGrid({
|
|||
enabled: !!user && showAgentFilters,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgentSource || !agentSourceCounts) return
|
||||
if ((agentSourceCounts[selectedAgentSource] ?? 0) === 0) {
|
||||
void setSelectedAgentSource(null)
|
||||
}
|
||||
}, [agentSourceCounts, selectedAgentSource, setSelectedAgentSource])
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
|
|
@ -700,22 +707,25 @@ export function MemoriesGrid({
|
|||
aria-label="Filter memories by agent"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{AGENT_SOURCE_FILTERS.map((filter) => (
|
||||
<ToggleGroupItem
|
||||
key={filter.value}
|
||||
value={filter.value}
|
||||
aria-label={`Show ${filter.label} memories`}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"h-auto min-w-0 flex-none shrink-0 rounded-full! border border-[#161F2C]! bg-[#0D121A] px-2.5 py-1 text-xs hover:border-[#2261CA33]! hover:bg-[#00173C] data-[state=on]:border-[#2261CA33]! data-[state=on]:bg-[#00173C]",
|
||||
)}
|
||||
>
|
||||
{filter.label}
|
||||
<span className="ml-1 text-[#737373]">
|
||||
({agentSourceCounts?.[filter.value] ?? 0})
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
{AGENT_SOURCE_FILTERS.map((filter) => {
|
||||
const count = agentSourceCounts?.[filter.value]
|
||||
if (!count) return null
|
||||
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
key={filter.value}
|
||||
value={filter.value}
|
||||
aria-label={`Show ${filter.label} memories`}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"h-auto min-w-0 flex-none shrink-0 rounded-full! border border-[#161F2C]! bg-[#0D121A] px-2.5 py-1 text-xs hover:border-[#2261CA33]! hover:bg-[#00173C] data-[state=on]:border-[#2261CA33]! data-[state=on]:bg-[#00173C]",
|
||||
)}
|
||||
>
|
||||
{filter.label}
|
||||
<span className="ml-1 text-[#737373]">({count})</span>
|
||||
</ToggleGroupItem>
|
||||
)
|
||||
})}
|
||||
</ToggleGroup>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@ import { LogoFull } from "@ui/assets/Logo"
|
|||
import { Button } from "@ui/components/button"
|
||||
import { Input } from "@ui/components/input"
|
||||
import { cn } from "@lib/utils"
|
||||
import { ArrowRight, Check, Globe, Loader2 } from "lucide-react"
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
|
|
@ -25,6 +32,7 @@ import {
|
|||
import { ResearchActionRail } from "./research-action-rail"
|
||||
import {
|
||||
type CompanyBrainConfirmResult,
|
||||
type CompanyBrainOrganizationChoice,
|
||||
workspaceNameFromDomain,
|
||||
} from "./types"
|
||||
|
||||
|
|
@ -33,7 +41,10 @@ interface CompanyBrainOnboardingProps {
|
|||
avatarUrl: string | null
|
||||
domain: string
|
||||
submitting: boolean
|
||||
onConfirm: (domain: string) => Promise<CompanyBrainConfirmResult>
|
||||
onConfirm: (
|
||||
domain: string,
|
||||
organizationId?: string,
|
||||
) => Promise<CompanyBrainConfirmResult>
|
||||
onDone: () => void
|
||||
onUsePersonal: () => void
|
||||
}
|
||||
|
|
@ -72,6 +83,9 @@ export function CompanyBrainOnboarding({
|
|||
}: CompanyBrainOnboardingProps) {
|
||||
const [phase, setPhase] = useState<Phase>("confirm")
|
||||
const [domain, setDomain] = useState(initialDomain)
|
||||
const [organizationChoices, setOrganizationChoices] = useState<
|
||||
CompanyBrainOrganizationChoice[] | null
|
||||
>(null)
|
||||
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
|
||||
const firstName = name.trim().split(/\s+/)[0] ?? ""
|
||||
const clean = normalizeDomain(domain)
|
||||
|
|
@ -84,10 +98,14 @@ export function CompanyBrainOnboarding({
|
|||
)
|
||||
const [retryUi, setRetryUi] = useState<null | "retrying" | "exhausted">(null)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const handleConfirm = async (organizationId?: string) => {
|
||||
if (!clean || submitting) return
|
||||
const result = await onConfirm(clean)
|
||||
if (!result.ok) return
|
||||
const result = await onConfirm(clean, organizationId)
|
||||
if (!result.ok) {
|
||||
if (result.choices?.length) setOrganizationChoices(result.choices)
|
||||
return
|
||||
}
|
||||
setOrganizationChoices(null)
|
||||
setServerSchedulesResearch(result.serverSchedulesResearch)
|
||||
setPhase("research")
|
||||
}
|
||||
|
|
@ -211,15 +229,24 @@ export function CompanyBrainOnboarding({
|
|||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<ConfirmBody
|
||||
firstName={firstName}
|
||||
name={name}
|
||||
avatarUrl={avatarUrl}
|
||||
domain={domain}
|
||||
onDomainChange={setDomain}
|
||||
onConfirm={handleConfirm}
|
||||
submitting={submitting}
|
||||
/>
|
||||
{organizationChoices ? (
|
||||
<OrganizationChoiceBody
|
||||
organizations={organizationChoices}
|
||||
submitting={submitting}
|
||||
onSelect={(organizationId) => handleConfirm(organizationId)}
|
||||
onBack={() => setOrganizationChoices(null)}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmBody
|
||||
firstName={firstName}
|
||||
name={name}
|
||||
avatarUrl={avatarUrl}
|
||||
domain={domain}
|
||||
onDomainChange={setDomain}
|
||||
onConfirm={() => handleConfirm()}
|
||||
submitting={submitting}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
|
|
@ -240,7 +267,7 @@ export function CompanyBrainOnboarding({
|
|||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{phase === "confirm" && (
|
||||
{phase === "confirm" && !organizationChoices && (
|
||||
<div className="w-full max-w-xl mx-auto mt-5 flex items-center justify-between gap-4 px-1">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -252,7 +279,7 @@ export function CompanyBrainOnboarding({
|
|||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleConfirm}
|
||||
onClick={() => handleConfirm()}
|
||||
disabled={!clean || submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
|
|
@ -300,6 +327,67 @@ export function CompanyBrainOnboarding({
|
|||
)
|
||||
}
|
||||
|
||||
function OrganizationChoiceBody({
|
||||
organizations,
|
||||
submitting,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
organizations: CompanyBrainOrganizationChoice[]
|
||||
submitting: boolean
|
||||
onSelect: (organizationId: string) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full border border-[rgba(82,89,102,0.2)] bg-[#14161A] text-[#4BA0FA]">
|
||||
<Building2 className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[20px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
Choose your Company Brain
|
||||
</p>
|
||||
<p className="mt-1 text-[14px] font-medium leading-[1.4] text-[#737373]">
|
||||
You already belong to more than one Company Brain workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
{organizations.map((organization) => (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => onSelect(organization.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-3 text-left transition-colors hover:bg-white/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] disabled:opacity-50"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[#4BA0FA]/10 text-[#4BA0FA]">
|
||||
<Building2 className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[14px] font-medium text-[#FAFAFA]">
|
||||
{organization.name}
|
||||
</span>
|
||||
{submitting ? (
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-[#8A94A6]" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 shrink-0 text-[#8A94A6]" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={onBack}
|
||||
className="mt-4 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA] disabled:opacity-50"
|
||||
>
|
||||
Use a different domain
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmBody({
|
||||
firstName,
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
export type CompanyBrainOrganizationChoice = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type CompanyBrainConfirmResult =
|
||||
| { ok: true; serverSchedulesResearch: boolean }
|
||||
| { ok: false }
|
||||
| { ok: false; choices?: CompanyBrainOrganizationChoice[] }
|
||||
|
||||
export type BrainMode = "personal" | "team"
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,12 @@ type Category = {
|
|||
count: number
|
||||
}
|
||||
|
||||
const AGENT_CATALOG_IDS = ["claude_code", "codex"] as const
|
||||
const AGENT_CATALOG_IDS = [
|
||||
"claude_code",
|
||||
"codex",
|
||||
"opencode",
|
||||
"cursor",
|
||||
] as const
|
||||
|
||||
export function SelectSpacesModal({
|
||||
isOpen,
|
||||
|
|
@ -1668,7 +1673,7 @@ function AgentsDiscoverPanel({
|
|||
if (catalogIds.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-[#737373]">
|
||||
Claude Code and Codex are connected.
|
||||
Claude Code, Codex, and OpenCode are connected.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -1682,7 +1687,7 @@ function AgentsDiscoverPanel({
|
|||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-[#FAFAFA]">Agents</p>
|
||||
<p className="text-[11px] text-[#737373]">
|
||||
Claude Code and Codex share project memory
|
||||
Claude Code, Codex, and OpenCode share project memory
|
||||
</p>
|
||||
</div>
|
||||
{catalogIds.map((catalogId) => {
|
||||
|
|
|
|||
|
|
@ -132,7 +132,11 @@ function isPendingInvitation(invitation: {
|
|||
return new Date(invitation.expiresAt).getTime() > Date.now()
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
export default function Account({
|
||||
dialogPortalContainer,
|
||||
}: {
|
||||
dialogPortalContainer?: HTMLElement | null
|
||||
}) {
|
||||
const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan, searchesUsed } = useTokenUsage(autumn)
|
||||
|
|
@ -152,6 +156,7 @@ export default function Account() {
|
|||
const [isEditingOrgName, setIsEditingOrgName] = useState(false)
|
||||
const [orgNameDraft, setOrgNameDraft] = useState("")
|
||||
const tagInputRef = useRef<HTMLInputElement>(null)
|
||||
const inviteEmailInputRef = useRef<HTMLInputElement>(null)
|
||||
const tagAnchorRef = useRef<HTMLDivElement>(null)
|
||||
const { allProjects: allContainerTags } = useContainerTags()
|
||||
|
||||
|
|
@ -877,6 +882,11 @@ export default function Account() {
|
|||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
portalContainer={dialogPortalContainer}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
inviteEmailInputRef.current?.focus()
|
||||
}}
|
||||
className="sm:max-w-[480px] border-none bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-6 pt-6 pb-4">
|
||||
|
|
@ -925,6 +935,7 @@ export default function Account() {
|
|||
<div className="relative min-w-0">
|
||||
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#525D6E]" />
|
||||
<input
|
||||
ref={inviteEmailInputRef}
|
||||
id="team-invite-email"
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
import { PillButton } from "../integrations/install-steps"
|
||||
|
||||
|
|
@ -310,7 +309,6 @@ function RowSkeleton() {
|
|||
|
||||
export default function CompanyBrainConnections() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { user } = useAuth()
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [catalogLoaded, setCatalogLoaded] = useState(false)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
|
|
@ -368,9 +366,6 @@ export default function CompanyBrainConnections() {
|
|||
(shared ? r.userId === null : r.userId !== null),
|
||||
)
|
||||
|
||||
const isStaff =
|
||||
user?.email?.toLowerCase().endsWith("@supermemory.com") ?? false
|
||||
|
||||
const connect = async (entry: CatalogEntry, shared: boolean) => {
|
||||
const key = `${entry.slug}:${shared ? "org" : "user"}`
|
||||
setBusy(key)
|
||||
|
|
@ -462,10 +457,6 @@ export default function CompanyBrainConnections() {
|
|||
redirectUrl: window.location.href,
|
||||
}),
|
||||
})
|
||||
if (res.status === 403) {
|
||||
toast.error("Custom MCP URLs are staff-only.")
|
||||
return
|
||||
}
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
authUrl?: string
|
||||
ok?: boolean
|
||||
|
|
@ -609,20 +600,18 @@ export default function CompanyBrainConnections() {
|
|||
}
|
||||
/>
|
||||
))}
|
||||
{isStaff ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomOpen(true)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
|
||||
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add custom MCP
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomOpen(true)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
|
||||
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add custom MCP
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
292
apps/web/components/settings/company-brain-proactivity.tsx
Normal file
292
apps/web/components/settings/company-brain-proactivity.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Check, Loader2, Lock, X } from "lucide-react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select"
|
||||
import {
|
||||
type BrainChannelProactivity,
|
||||
type BrainProactivityDefault,
|
||||
useBrainSettings,
|
||||
useUpdateBrainSettings,
|
||||
} from "@/hooks/use-brain-settings"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type Channel = { id: string; name: string; isPrivate: boolean }
|
||||
|
||||
const HOME_CHANNEL_NAME = "company-brain"
|
||||
const ADD_PLACEHOLDER = "__add__"
|
||||
|
||||
const MODES: {
|
||||
id: BrainProactivityDefault
|
||||
label: string
|
||||
description: string
|
||||
}[] = [
|
||||
{
|
||||
id: "all_channels",
|
||||
label: "All channels",
|
||||
description: "Joins any conversation it's been added to when it can help.",
|
||||
},
|
||||
{
|
||||
id: "own_channel_only",
|
||||
label: "Only its own channel",
|
||||
description: "Speaks only in #company-brain unless @mentioned or DMed.",
|
||||
},
|
||||
]
|
||||
|
||||
const fieldLabel = cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] font-medium uppercase tracking-[0.06em] text-[#5B6675]",
|
||||
)
|
||||
const controlClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
|
||||
)
|
||||
const selectContentClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]",
|
||||
)
|
||||
const selectItemClass =
|
||||
"cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white"
|
||||
|
||||
export default function CompanyBrainProactivity() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
const { org } = useAuth()
|
||||
|
||||
const settingsQuery = useBrainSettings(isCompanyBrain)
|
||||
const update = useUpdateBrainSettings()
|
||||
|
||||
const slackStatusQuery = useQuery({
|
||||
queryKey: ["brain", "slack-status", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to load Slack status")
|
||||
return (await res.json()) as { connected: boolean }
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// Same key + endpoint as the automations picker so react-query dedupes.
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "channels", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BACKEND}/brain/automations/channels`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to load channels")
|
||||
return ((await res.json()) as { channels: Channel[] }).channels ?? []
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (!isCompanyBrain) {
|
||||
return (
|
||||
<div className="px-1 pt-2">
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-[#6B6B6B]")}>
|
||||
Company Brain isn't enabled for this organization.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const proactivity = settingsQuery.data?.proactivity
|
||||
const activeMode = proactivity?.default ?? "all_channels"
|
||||
const overrides = proactivity?.channels ?? {}
|
||||
const channels = channelsQuery.data ?? []
|
||||
const channelName = (id: string) =>
|
||||
channels.find((ch) => ch.id === id)?.name ?? id
|
||||
const addable = channels.filter(
|
||||
(ch) => !overrides[ch.id] && ch.name !== HOME_CHANNEL_NAME,
|
||||
)
|
||||
const disabled = !isAdmin || settingsQuery.isLoading || update.isPending
|
||||
|
||||
const setMode = (mode: BrainProactivityDefault) => {
|
||||
if (mode === activeMode) return
|
||||
update.mutate({ proactivity: { default: mode } })
|
||||
}
|
||||
const setOverride = (
|
||||
channelId: string,
|
||||
value: BrainChannelProactivity | null,
|
||||
) => {
|
||||
update.mutate({ proactivity: { channels: { [channelId]: value } } })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 px-1">
|
||||
{settingsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-[13px] text-[#9A9A9A]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading proactivity…
|
||||
</div>
|
||||
) : settingsQuery.isError ? (
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-red-400")}>
|
||||
Couldn't load proactivity settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{MODES.map((mode) => {
|
||||
const isActive = mode.id === activeMode
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => setMode(mode.id)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-w-0 cursor-pointer flex-col gap-1.5 rounded-xl p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
isActive
|
||||
? "bg-[#10161f] ring-1 ring-[#2261CA]/45"
|
||||
: "hover:bg-[#171A1F]",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]">
|
||||
{mode.label}
|
||||
{mode.id === "all_channels" ? (
|
||||
<span className="ml-1.5 text-[11px] font-medium text-[#737B87]">
|
||||
Default
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check className="size-4 shrink-0 text-[#6BB0FF]" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
{mode.description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={fieldLabel}>Channel exceptions</span>
|
||||
{Object.entries(overrides).map(([channelId, value]) => (
|
||||
<div
|
||||
key={channelId}
|
||||
className="flex items-center justify-between gap-3 rounded-xl bg-[#14161A] px-4 py-2.5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
#{channelName(channelId)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
|
||||
{(["proactive", "quiet"] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={value === option}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (value !== option) setOverride(channelId, option)
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-7 cursor-pointer rounded-full px-3 text-[11.5px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
value === option
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#8B929E] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{option === "proactive" ? "Proactive" : "Quiet"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOverride(channelId, null)}
|
||||
className="cursor-pointer text-[#6B6B6B] transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove exception for #${channelName(channelId)}`}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{addable.length > 0 ? (
|
||||
<Select
|
||||
value={ADD_PLACEHOLDER}
|
||||
disabled={disabled}
|
||||
onValueChange={(channelId) => {
|
||||
if (channelId === ADD_PLACEHOLDER) return
|
||||
setOverride(
|
||||
channelId,
|
||||
activeMode === "all_channels" ? "quiet" : "proactive",
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={cn(controlClass, "sm:w-72")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={selectContentClass}>
|
||||
<SelectItem
|
||||
value={ADD_PLACEHOLDER}
|
||||
className={selectItemClass}
|
||||
>
|
||||
Add a channel exception…
|
||||
</SelectItem>
|
||||
{addable.map((ch) => (
|
||||
<SelectItem
|
||||
key={ch.id}
|
||||
value={ch.id}
|
||||
className={selectItemClass}
|
||||
>
|
||||
{ch.isPrivate ? "🔒 " : "# "}
|
||||
{ch.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : channelsQuery.isLoading || slackStatusQuery.isLoading ? null : (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{slackStatusQuery.data?.connected === false
|
||||
? "Connect Slack to set per-channel exceptions."
|
||||
: "Invite Company Brain to a Slack channel to list it here."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAdmin ? (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[#737373]">
|
||||
<Lock className="size-3.5" />
|
||||
Only organization admins can change these.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,24 +6,16 @@ export function ProactivenessIcon({ className }: { className?: string }) {
|
|||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 2V4M16 2V4M12 2V4M8 20V22M12 20V22M16 20V22M22 16H20M4 8H2M4 16H2M4 12H2M22 8H20M22 12H20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.4802 7.86193C11.6587 7.37936 12.3413 7.37936 12.5198 7.86193L13.3202 10.0248C13.4325 10.3283 13.6717 10.5675 13.9752 10.6798L16.1381 11.4802C16.6206 11.6587 16.6206 12.3413 16.1381 12.5198L13.9752 13.3202C13.6717 13.4325 13.4325 13.6717 13.3202 13.9752L12.5198 16.1381C12.3413 16.6206 11.6587 16.6206 11.4802 16.1381L10.6798 13.9752C10.5675 13.6717 10.3283 13.4325 10.0248 13.3202L7.86193 12.5198C7.37936 12.3413 7.37936 11.6587 7.86193 11.4802L10.0248 10.6798C10.3283 10.5675 10.5675 10.3283 10.6798 10.0248L11.4802 7.86193Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M13.5 4H11C6.75736 4 4.63604 4 3.31802 5.31802C2 6.63604 2 8.75736 2 13C2 17.2426 2 19.364 3.31802 20.682C4.63604 22 6.75736 22 11 22C15.2426 22 17.364 22 18.682 20.682C20 19.364 20 17.2426 20 13V10.5" />
|
||||
<path d="M6 8L16 18" />
|
||||
<path d="M6 14L10 18" />
|
||||
<path d="M12 8L16 12" />
|
||||
<path d="M19.5 2.9375V4.5M19.5 4.5V6.0625M19.5 4.5H18.25M19.5 4.5H20.75M22 4.5L20.9156 4.13852C20.4179 3.97263 20.0274 3.58211 19.8615 3.08443L19.5 2L19.1385 3.08443C18.9726 3.58211 18.5821 3.97263 18.0844 4.13852L17 4.5L18.0844 4.86148C18.5821 5.02737 18.9726 5.41789 19.1385 5.91557L19.5 7L19.8615 5.91557C20.0274 5.41789 20.4179 5.02737 20.9156 4.86148L22 4.5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -477,7 +477,9 @@ export function SettingsContent({
|
|||
</p>
|
||||
}
|
||||
>
|
||||
{activeTab === "account" && <Account />}
|
||||
{activeTab === "account" && (
|
||||
<Account dialogPortalContainer={dialogPortalContainer} />
|
||||
)}
|
||||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
|
|
|
|||
186
apps/web/components/settings/workspace-prompt.tsx
Normal file
186
apps/web/components/settings/workspace-prompt.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client"
|
||||
|
||||
import { LoaderIcon } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
const DESCRIPTION_ID = "workspace-prompt-description"
|
||||
const COUNTER_ID = "workspace-prompt-counter"
|
||||
const HEADING_ID = "workspace-prompt-heading"
|
||||
|
||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h2
|
||||
id={HEADING_ID}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptHeader() {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<SectionHeading>Workspace Prompt</SectionHeading>
|
||||
<p
|
||||
id={DESCRIPTION_ID}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Set persistent guidance for how Company Brain works across your
|
||||
workspace.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspacePrompt({
|
||||
showHeading = true,
|
||||
}: {
|
||||
showHeading?: boolean
|
||||
}) {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
const settingsQuery = useOrgSettings()
|
||||
const updateSettings = useUpdateOrgSettings()
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
|
||||
const savedPrompt = settingsQuery.data?.workspacePrompt ?? ""
|
||||
const prompt = draft ?? savedPrompt
|
||||
const dirty = draft !== null && draft.trim() !== savedPrompt.trim()
|
||||
const canClear = !dirty && savedPrompt.length > 0 && isAdmin
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings.mutate(
|
||||
{
|
||||
workspacePrompt: prompt.trim() ? prompt.trim() : null,
|
||||
},
|
||||
{ onSuccess: () => setDraft(null) },
|
||||
)
|
||||
}
|
||||
|
||||
if (!isCompanyBrain) return null
|
||||
|
||||
return (
|
||||
<section
|
||||
id="workspace-prompt"
|
||||
aria-label={showHeading ? undefined : "Workspace prompt"}
|
||||
aria-labelledby={showHeading ? HEADING_ID : undefined}
|
||||
aria-busy={settingsQuery.isLoading || updateSettings.isPending}
|
||||
className="flex w-full max-w-3xl flex-col gap-3 px-1"
|
||||
>
|
||||
{showHeading ? <PromptHeader /> : null}
|
||||
|
||||
{settingsQuery.isLoading ? (
|
||||
<output
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex min-h-[96px] items-center justify-center gap-2 rounded-[12px] border border-white/[0.08] bg-[#0D121A] text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
<LoaderIcon aria-hidden="true" className="size-3 animate-spin" />
|
||||
Loading workspace prompt…
|
||||
</output>
|
||||
) : settingsQuery.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(dmSansClassName(), "flex flex-col items-start gap-2")}
|
||||
>
|
||||
<p className="text-[13px] text-[#A3A3A3]">
|
||||
Workspace prompt could not be loaded.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void settingsQuery.refetch()}
|
||||
disabled={settingsQuery.isFetching}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{settingsQuery.isFetching && (
|
||||
<LoaderIcon aria-hidden="true" className="size-3 animate-spin" />
|
||||
)}
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(dmSansClassName(), "flex flex-col gap-4")}>
|
||||
<textarea
|
||||
aria-label={showHeading ? undefined : "Workspace prompt"}
|
||||
aria-labelledby={showHeading ? HEADING_ID : undefined}
|
||||
aria-describedby={
|
||||
showHeading ? `${DESCRIPTION_ID} ${COUNTER_ID}` : COUNTER_ID
|
||||
}
|
||||
disabled={!isAdmin}
|
||||
value={prompt}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="Describe operating preferences, priorities, source and tool choices, workflows, terminology, formatting, and communication style. Fixed safety, access, and approval constraints still apply."
|
||||
maxLength={1500}
|
||||
className="min-h-[160px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus-visible:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
id={COUNTER_ID}
|
||||
className="text-[11px] text-[#737373] tabular-nums"
|
||||
>
|
||||
{prompt.length}/1500
|
||||
</span>
|
||||
{canClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft("")}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#E5484D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer",
|
||||
)}
|
||||
>
|
||||
Clear prompt
|
||||
</button>
|
||||
)}
|
||||
{dirty && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon
|
||||
aria-hidden="true"
|
||||
className="size-3 animate-spin"
|
||||
/>
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { XIcon, Download, Copy, Check } from "lucide-react"
|
|||
import { GradientLogo } from "@ui/assets/Logo"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { toast } from "sonner"
|
||||
import * as htmlToImage from "html-to-image"
|
||||
|
||||
|
|
@ -276,7 +277,8 @@ export function ShareModal({
|
|||
onClose,
|
||||
graphCanvasRef,
|
||||
}: ShareModalProps) {
|
||||
const { user } = useAuth()
|
||||
const { user, org } = useAuth()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const [selectedTheme, setSelectedTheme] =
|
||||
useState<BackgroundTheme>("gradient")
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
|
|
@ -291,6 +293,15 @@ export function ShareModal({
|
|||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "Your"
|
||||
const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
|
||||
const ownerLabel = isCompanyBrain
|
||||
? orgLabel
|
||||
? /['’]s$/i.test(orgLabel)
|
||||
? orgLabel
|
||||
: `${orgLabel}'s`
|
||||
: "Your company's"
|
||||
: userName
|
||||
const productName = isCompanyBrain ? "Company Brain" : "supermemory"
|
||||
|
||||
const capturePreview = useCallback(async (): Promise<Blob | null> => {
|
||||
if (!previewRef.current) return null
|
||||
|
|
@ -439,10 +450,10 @@ export function ShareModal({
|
|||
<GradientLogo className="w-7 h-6" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-white/70 leading-tight">
|
||||
{userName}
|
||||
{ownerLabel}
|
||||
</span>
|
||||
<span className="text-sm text-white font-semibold leading-tight">
|
||||
supermemory
|
||||
{productName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { authClient } from "@lib/auth"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
Brain,
|
||||
LogOut,
|
||||
Settings,
|
||||
Settings2,
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
Sun,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
|
|
@ -164,6 +166,18 @@ export function UserProfileMenu({
|
|||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
{isCompanyBrain ? null : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
analytics.companyBrainPromoClicked({ source: "profile_menu" })
|
||||
router.push("/onboarding?new=1&mode=team")
|
||||
}}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<Brain className="size-4 text-[#737373]" />
|
||||
Set up Company Brain
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isCompanyBrain ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("configure")}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,47 @@
|
|||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-markdown-content pre {
|
||||
margin: 0.75rem 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
background: #080b10 !important;
|
||||
padding: 0.875rem 1rem;
|
||||
color: #f4f4f5 !important;
|
||||
}
|
||||
|
||||
.chat-markdown-content pre code {
|
||||
display: block;
|
||||
background: transparent !important;
|
||||
padding: 0;
|
||||
color: #f4f4f5 !important;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.65;
|
||||
text-shadow: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.chat-markdown-content pre code *,
|
||||
.chat-markdown-content pre [style] {
|
||||
background: transparent !important;
|
||||
color: #f4f4f5 !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
.chat-markdown-content pre code .line {
|
||||
min-height: 1.45em;
|
||||
}
|
||||
|
||||
.chat-markdown-content :not(pre) > code {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 0.125rem 0.3125rem;
|
||||
color: #fafafa;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Model spams `---` between every section; headings already separate them */
|
||||
.chat-markdown-content hr {
|
||||
display: none;
|
||||
|
|
|
|||
76
apps/web/hooks/use-brain-settings.ts
Normal file
76
apps/web/hooks/use-brain-settings.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const BASE = `${BACKEND}/brain/settings`
|
||||
|
||||
export type BrainProactivityDefault = "all_channels" | "own_channel_only"
|
||||
export type BrainChannelProactivity = "proactive" | "quiet"
|
||||
|
||||
export type BrainSettingsResponse = {
|
||||
proactivity: {
|
||||
default: BrainProactivityDefault
|
||||
channels: Record<string, BrainChannelProactivity>
|
||||
}
|
||||
choices: {
|
||||
proactivityDefault: BrainProactivityDefault[]
|
||||
channelProactivity: BrainChannelProactivity[]
|
||||
}
|
||||
}
|
||||
|
||||
// null clears: default -> reset, channels[id] -> remove that override.
|
||||
export type BrainSettingsPatch = {
|
||||
proactivity?: {
|
||||
default?: BrainProactivityDefault | null
|
||||
channels?: Record<string, BrainChannelProactivity | null> | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export function useBrainSettings(enabled: boolean) {
|
||||
const { org } = useAuth()
|
||||
return useQuery({
|
||||
queryKey: ["brain", "settings", org?.id],
|
||||
queryFn: async (): Promise<BrainSettingsResponse> => {
|
||||
const res = await fetch(`${BASE}/`, { credentials: "include" })
|
||||
if (!res.ok) throw new Error("Failed to load settings")
|
||||
return res.json()
|
||||
},
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateBrainSettings() {
|
||||
const { org } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (patch: BrainSettingsPatch) => {
|
||||
const res = await fetch(`${BASE}/`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-App-Source": "nova" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
if (res.status === 403)
|
||||
throw new Error("Only admins can change these settings.")
|
||||
if (!res.ok) {
|
||||
const b = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
throw new Error(b.message ?? b.error ?? "Failed to save settings")
|
||||
}
|
||||
return res.json() as Promise<BrainSettingsResponse>
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["brain", "settings", org?.id], data)
|
||||
toast.success("Proactivity saved")
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to save settings",
|
||||
),
|
||||
})
|
||||
}
|
||||
|
|
@ -3,19 +3,14 @@ import { toast } from "sonner"
|
|||
import { $fetch } from "@lib/api"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const API_BASE = `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/v3`
|
||||
|
||||
export type OrgSettings = {
|
||||
shouldLLMFilter: boolean
|
||||
filterPrompt: string | null
|
||||
workspacePrompt: string | null
|
||||
includeItems?: string[] | null
|
||||
excludeItems?: string[] | null
|
||||
}
|
||||
|
||||
type OrgSettingsResponse = {
|
||||
settings?: Partial<OrgSettings>
|
||||
} & Partial<OrgSettings>
|
||||
|
||||
export function useOrgSettings() {
|
||||
const { org } = useAuth()
|
||||
const orgId = org?.id ?? ""
|
||||
|
|
@ -23,17 +18,15 @@ export function useOrgSettings() {
|
|||
return useQuery({
|
||||
queryKey: ["settings", "org", orgId],
|
||||
queryFn: async (): Promise<OrgSettings> => {
|
||||
const response = await $fetch("@get/settings", {
|
||||
disableValidation: true,
|
||||
})
|
||||
const response = await $fetch("@get/settings")
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || "Failed to load settings")
|
||||
}
|
||||
const data = response.data as OrgSettingsResponse | null
|
||||
const settings = data?.settings ?? data ?? {}
|
||||
const settings = response.data ?? {}
|
||||
return {
|
||||
shouldLLMFilter: settings.shouldLLMFilter ?? false,
|
||||
filterPrompt: settings.filterPrompt ?? null,
|
||||
workspacePrompt: settings.workspacePrompt ?? null,
|
||||
includeItems: settings.includeItems ?? null,
|
||||
excludeItems: settings.excludeItems ?? null,
|
||||
}
|
||||
|
|
@ -50,25 +43,26 @@ export function useUpdateOrgSettings() {
|
|||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Partial<OrgSettings>) => {
|
||||
const res = await fetch(`${API_BASE}/settings`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-App-Source": "nova",
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
const response = await $fetch("@patch/settings", {
|
||||
body: settings,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
throw new Error(body?.message || "Failed to save settings")
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || "Failed to save settings", {
|
||||
cause: response.error,
|
||||
})
|
||||
}
|
||||
return res.json()
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "org", orgId] })
|
||||
onMutate: () => ({ orgId }),
|
||||
onSuccess: async (data, _settings, mutationContext) => {
|
||||
const queryKey = ["settings", "org", mutationContext.orgId] as const
|
||||
const canonicalSettings = data?.updated
|
||||
if (canonicalSettings) {
|
||||
queryClient.setQueryData<OrgSettings>(queryKey, (current) =>
|
||||
current ? { ...current, ...canonicalSettings } : current,
|
||||
)
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey, exact: true })
|
||||
toast.success("Settings saved")
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from "./agent-space"
|
||||
|
||||
describe("Agents spaces", () => {
|
||||
it("recognizes only Claude and Codex shared and legacy tags", () => {
|
||||
it("recognizes shared and legacy tags from every unified agent", () => {
|
||||
expect(isAgentContainerTag("repo_supermemory__0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("user_project_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("repo_supermemory")).toBe(true)
|
||||
|
|
@ -16,7 +16,10 @@ describe("Agents spaces", () => {
|
|||
)
|
||||
expect(isAgentContainerTag("codex_project_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("codex_user_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("opencode_project_0123456789abcdef")).toBe(false)
|
||||
expect(isAgentContainerTag("opencode_project_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("opencode_user_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("cursor_project_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("cursor_user_0123456789abcdef")).toBe(true)
|
||||
})
|
||||
|
||||
it("shows agent filters only for an Agents selection", () => {
|
||||
|
|
@ -38,6 +41,8 @@ describe("Agents spaces", () => {
|
|||
"claude-code-plugin",
|
||||
])
|
||||
expect(agentSourceValues("codex")).toEqual(["codex"])
|
||||
expect(agentSourceValues("opencode")).toEqual(["opencode"])
|
||||
expect(agentSourceValues("cursor")).toEqual(["cursor"])
|
||||
expect(agentSourceValues(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
|
|
@ -46,6 +51,7 @@ describe("Agents spaces", () => {
|
|||
{ containerTag: "repo_supermemory__fedcba9876543210" },
|
||||
{ containerTag: "repo_supermemory" },
|
||||
{ containerTag: "codex_project_0123456789abcdef" },
|
||||
{ containerTag: "opencode_project_0123456789abcdef" },
|
||||
{ containerTag: "claudecode_project_0123456789abcdef" },
|
||||
{ containerTag: "user_project_0123456789abcdef" },
|
||||
]
|
||||
|
|
@ -69,6 +75,7 @@ describe("Agents spaces", () => {
|
|||
"claudecode_project_0123456789abcdef",
|
||||
"repo_supermemory",
|
||||
"codex_project_0123456789abcdef",
|
||||
"opencode_project_0123456789abcdef",
|
||||
])
|
||||
})
|
||||
|
||||
|
|
@ -110,10 +117,31 @@ describe("Agents spaces", () => {
|
|||
expect(groups[1]?.kind).toBe("legacy-personal")
|
||||
})
|
||||
|
||||
it("keeps the old global OpenCode personal container separate", () => {
|
||||
const projects = [
|
||||
{ containerTag: "user_project_0123456789abcdef" },
|
||||
{ containerTag: "opencode_user_fedcba9876543210" },
|
||||
]
|
||||
const metadata = new Map(
|
||||
projects.map((project) => [
|
||||
project.containerTag,
|
||||
{ projectName: "supermemory" },
|
||||
]),
|
||||
)
|
||||
|
||||
const groups = groupAgentSpaces(projects, metadata)
|
||||
|
||||
expect(groups).toHaveLength(2)
|
||||
expect(groups[0]?.label).toBe("supermemory")
|
||||
expect(groups[1]?.label).toBe("Legacy OpenCode personal")
|
||||
expect(groups[1]?.kind).toBe("legacy-personal")
|
||||
})
|
||||
|
||||
it("groups path-scoped legacy tags even before metadata loads", () => {
|
||||
const projects = [
|
||||
{ containerTag: "claudecode_project_0123456789abcdef" },
|
||||
{ containerTag: "codex_project_0123456789abcdef" },
|
||||
{ containerTag: "opencode_project_0123456789abcdef" },
|
||||
]
|
||||
|
||||
const groups = groupAgentSpaces(projects, new Map())
|
||||
|
|
@ -123,4 +151,25 @@ describe("Agents spaces", () => {
|
|||
"claudecode_project_0123456789abcdef",
|
||||
)
|
||||
})
|
||||
|
||||
it("shows an unambiguous Cursor project label before metadata loads", () => {
|
||||
const groups = groupAgentSpaces(
|
||||
[{ containerTag: "cursor_project_0123456789abcdef" }],
|
||||
new Map(),
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]?.label).toBe("Cursor project · 012345")
|
||||
})
|
||||
|
||||
it("shows old Cursor personal memory without a Legacy prefix", () => {
|
||||
const groups = groupAgentSpaces(
|
||||
[{ containerTag: "cursor_user_fedcba9876543210" }],
|
||||
new Map(),
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]?.label).toBe("Cursor personal · fedcba")
|
||||
expect(groups[0]?.kind).toBe("legacy-personal")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export type AgentContainerKind =
|
|||
| "legacy-personal"
|
||||
| "legacy-project"
|
||||
|
||||
export type AgentSourceFilter = "claude-code" | "codex"
|
||||
export type AgentSourceFilter = "claude-code" | "codex" | "opencode" | "cursor"
|
||||
|
||||
export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
|
||||
value: AgentSourceFilter
|
||||
|
|
@ -18,6 +18,8 @@ export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
|
|||
sources: ["claude-code", "claude-code-plugin"],
|
||||
},
|
||||
{ value: "codex", label: "Codex", sources: ["codex"] },
|
||||
{ value: "opencode", label: "OpenCode", sources: ["opencode"] },
|
||||
{ value: "cursor", label: "Cursor", sources: ["cursor"] },
|
||||
]
|
||||
|
||||
export type AgentSpaceMetadata = {
|
||||
|
|
@ -54,6 +56,22 @@ const TAG_PATTERNS: Array<{
|
|||
kind: "legacy-project",
|
||||
pattern: /^codex_project_([0-9a-f]{6,64})$/i,
|
||||
},
|
||||
{
|
||||
kind: "legacy-personal",
|
||||
pattern: /^opencode_user_([0-9a-f]{6,64})$/i,
|
||||
},
|
||||
{
|
||||
kind: "legacy-project",
|
||||
pattern: /^opencode_project_([0-9a-f]{6,64})$/i,
|
||||
},
|
||||
{
|
||||
kind: "legacy-personal",
|
||||
pattern: /^cursor_user_([0-9a-f]{6,64})$/i,
|
||||
},
|
||||
{
|
||||
kind: "legacy-project",
|
||||
pattern: /^cursor_project_([0-9a-f]{6,64})$/i,
|
||||
},
|
||||
]
|
||||
|
||||
function matchAgentTag(containerTag: string): {
|
||||
|
|
@ -118,7 +136,7 @@ function tagPriority(containerTag: string): number {
|
|||
case "personal":
|
||||
return 1
|
||||
case "legacy-personal":
|
||||
return containerTag.startsWith("claudecode_project_") ? 2 : 5
|
||||
return containerTag.startsWith("claudecode_project_") ? 2 : 6
|
||||
case "project":
|
||||
return 3
|
||||
case "legacy-project":
|
||||
|
|
@ -137,13 +155,25 @@ function legacyGroupIdentity(
|
|||
return { key: `tag:${containerTag}`, label: containerTag, kind: "project" }
|
||||
}
|
||||
|
||||
// Old Codex personal memory was intentionally global. Even if its newest
|
||||
// document contains a project name, assigning the whole container to that
|
||||
// project would leak memories from its other historical projects.
|
||||
if (containerTag.startsWith("codex_user_")) {
|
||||
// Old Codex, OpenCode, and Cursor personal containers were global.
|
||||
// Even if the newest document has a project name, assigning the whole
|
||||
// container to that project would mix memories from historical projects.
|
||||
if (
|
||||
containerTag.startsWith("codex_user_") ||
|
||||
containerTag.startsWith("opencode_user_") ||
|
||||
containerTag.startsWith("cursor_user_")
|
||||
) {
|
||||
const agent = containerTag.startsWith("codex_user_")
|
||||
? "Codex"
|
||||
: containerTag.startsWith("opencode_user_")
|
||||
? "OpenCode"
|
||||
: "Cursor"
|
||||
return {
|
||||
key: `legacy-personal:${containerTag}`,
|
||||
label: "Legacy Codex personal",
|
||||
label:
|
||||
agent === "Cursor"
|
||||
? `Cursor personal · ${match.id.slice(0, 6)}`
|
||||
: `Legacy ${agent} personal`,
|
||||
kind: "legacy-personal",
|
||||
}
|
||||
}
|
||||
|
|
@ -156,10 +186,19 @@ function legacyGroupIdentity(
|
|||
}
|
||||
}
|
||||
|
||||
if (containerTag.startsWith("cursor_project_")) {
|
||||
return {
|
||||
key: `cursor-project:${match.id.toLocaleLowerCase()}`,
|
||||
label: `Cursor project · ${match.id.slice(0, 6)}`,
|
||||
kind: "project",
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
containerTag.startsWith("user_project_") ||
|
||||
containerTag.startsWith("claudecode_project_") ||
|
||||
containerTag.startsWith("codex_project_")
|
||||
containerTag.startsWith("codex_project_") ||
|
||||
containerTag.startsWith("opencode_project_")
|
||||
) {
|
||||
return {
|
||||
key: `path:${match.id.toLocaleLowerCase()}`,
|
||||
|
|
@ -211,9 +250,9 @@ function addProjectToGroup<T extends { containerTag: string }>(
|
|||
}
|
||||
|
||||
/**
|
||||
* Collapse the physical Claude/Codex containers into one selectable Agents row
|
||||
* per project. Every returned container tag remains real; the UI never writes
|
||||
* to a synthetic "agents" tag.
|
||||
* Collapse the physical Claude/Codex/OpenCode/Cursor containers into one selectable
|
||||
* Agents row per project. Every returned container tag remains real; the UI
|
||||
* never writes to a synthetic "agents" tag.
|
||||
*/
|
||||
export function groupAgentSpaces<T extends { containerTag: string }>(
|
||||
projects: T[],
|
||||
|
|
@ -256,14 +295,19 @@ export function groupAgentSpaces<T extends { containerTag: string }>(
|
|||
const canonicalMatches = projectName
|
||||
? (canonicalKeysByName.get(projectName.toLocaleLowerCase()) ?? [])
|
||||
: []
|
||||
const firstCanonical = canonicalMatches[0]
|
||||
const key =
|
||||
identity.kind === "project" && canonicalMatches.length === 1
|
||||
? canonicalMatches[0]!
|
||||
identity.kind === "project" &&
|
||||
canonicalMatches.length === 1 &&
|
||||
firstCanonical !== undefined
|
||||
? firstCanonical
|
||||
: identity.key
|
||||
addProjectToGroup(
|
||||
grouped,
|
||||
key,
|
||||
projectName ?? identity.label,
|
||||
identity.kind === "legacy-personal"
|
||||
? identity.label
|
||||
: (projectName ?? identity.label),
|
||||
identity.kind,
|
||||
project,
|
||||
projectName,
|
||||
|
|
|
|||
|
|
@ -257,4 +257,12 @@ export const analytics = {
|
|||
rating: "up" | "down" | null
|
||||
message: string
|
||||
}) => safeCapture("digest_feedback_detail", props),
|
||||
|
||||
// company brain promo
|
||||
companyBrainPromoSeen: () => safeCapture("company_brain_promo_seen"),
|
||||
companyBrainPromoClicked: (props: {
|
||||
source: "dashboard_card" | "profile_menu"
|
||||
}) => safeCapture("company_brain_promo_clicked", props),
|
||||
companyBrainPromoDismissed: () =>
|
||||
safeCapture("company_brain_promo_dismissed"),
|
||||
}
|
||||
|
|
|
|||
81
apps/web/lib/company-brain-entry.ts
Normal file
81
apps/web/lib/company-brain-entry.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
getBrainMode,
|
||||
getBrainWorkspaceDomain,
|
||||
getCompanyBrainOverride,
|
||||
hasCompanyBrain,
|
||||
} from "./billing-utils"
|
||||
|
||||
export type BrainEntryOrganization = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
metadata?: Record<string, unknown> | string | null
|
||||
}
|
||||
|
||||
export type CompanyBrainEntryDecision =
|
||||
| { action: "use"; organization: BrainEntryOrganization }
|
||||
| { action: "switch"; organization: BrainEntryOrganization }
|
||||
| { action: "choose"; organizations: BrainEntryOrganization[] }
|
||||
| { action: "create" }
|
||||
|
||||
export function isCompanyBrainOrganization(
|
||||
organization: BrainEntryOrganization,
|
||||
): boolean {
|
||||
const override = getCompanyBrainOverride(organization.metadata)
|
||||
if (override !== undefined) return override
|
||||
return (
|
||||
hasCompanyBrain(organization.metadata) ||
|
||||
getBrainMode(organization.metadata) === "team"
|
||||
)
|
||||
}
|
||||
|
||||
export function getCompanyBrainOrganizations(
|
||||
organizations: BrainEntryOrganization[],
|
||||
): BrainEntryOrganization[] {
|
||||
return organizations.filter(isCompanyBrainOrganization)
|
||||
}
|
||||
|
||||
function normalizeDomain(domain: string): string {
|
||||
return domain
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^www\./, "")
|
||||
.replace(/\/.*$/, "")
|
||||
}
|
||||
|
||||
export function resolveCompanyBrainEntry(
|
||||
activeOrganizationId: string | null | undefined,
|
||||
organizations: BrainEntryOrganization[],
|
||||
requestedDomain?: string,
|
||||
): CompanyBrainEntryDecision {
|
||||
const normalizedRequestedDomain = requestedDomain
|
||||
? normalizeDomain(requestedDomain)
|
||||
: null
|
||||
const companyBrains = getCompanyBrainOrganizations(organizations).filter(
|
||||
(organization) => {
|
||||
if (!normalizedRequestedDomain) return true
|
||||
const organizationDomain = getBrainWorkspaceDomain(organization.metadata)
|
||||
return (
|
||||
organizationDomain !== null &&
|
||||
normalizeDomain(organizationDomain) === normalizedRequestedDomain
|
||||
)
|
||||
},
|
||||
)
|
||||
const active = organizations.find(
|
||||
(organization) => organization.id === activeOrganizationId,
|
||||
)
|
||||
if (
|
||||
active &&
|
||||
companyBrains.some((organization) => organization.id === active.id)
|
||||
) {
|
||||
return { action: "use", organization: active }
|
||||
}
|
||||
if (companyBrains.length === 1 && companyBrains[0]) {
|
||||
return { action: "switch", organization: companyBrains[0] }
|
||||
}
|
||||
if (companyBrains.length > 1) {
|
||||
return { action: "choose", organizations: companyBrains }
|
||||
}
|
||||
return { action: "create" }
|
||||
}
|
||||
|
|
@ -46,6 +46,54 @@ describe("parsePluginDocument — session transcripts", () => {
|
|||
expect(parsed?.pluginIconSrc).toBe("/images/plugins/claude-code.svg")
|
||||
})
|
||||
|
||||
it("renders a new Cursor capture as structured conversation cards", () => {
|
||||
const parsed = parsePluginDocument({
|
||||
id: "doc_cursor",
|
||||
title: "Cursor conversation",
|
||||
content: [
|
||||
"[Conversation cursor-session-1]",
|
||||
"1. [user] Keep the API boundary stable",
|
||||
"2. [assistant] I will preserve it.",
|
||||
].join("\n"),
|
||||
source: "cursor",
|
||||
metadata: { sm_source: "cursor", type: "conversation" },
|
||||
containerTags: ["repo_supermemory__0123456789abcdef"],
|
||||
memoryEntries: [],
|
||||
} as unknown as PluginDocumentInput)
|
||||
|
||||
expect(parsed?.pluginLabel).toBe("Cursor")
|
||||
expect(parsed?.pluginIconSrc).toBe("/images/plugins/cursor.png")
|
||||
expect(parsed?.formatLabel).toBe("Conversation")
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.role).toBe("user")
|
||||
expect(parsed?.messages[1]?.role).toBe("assistant")
|
||||
})
|
||||
|
||||
it("renders old Cursor tags and transcripts without source metadata", () => {
|
||||
const parsed = parsePluginDocument({
|
||||
id: "doc_cursor_legacy",
|
||||
title: "Cursor session",
|
||||
content: [
|
||||
"Cursor IDE session transcript:",
|
||||
"User: Fix the renderer",
|
||||
"with the existing card design.",
|
||||
"Assistant: Implemented the parser.",
|
||||
].join("\n"),
|
||||
source: "api",
|
||||
metadata: {},
|
||||
containerTags: ["cursor_project_0123456789abcdef"],
|
||||
memoryEntries: [],
|
||||
} as unknown as PluginDocumentInput)
|
||||
|
||||
expect(parsed?.pluginLabel).toBe("Cursor")
|
||||
expect(parsed?.pluginIconSrc).toBe("/images/plugins/cursor.png")
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.text).toBe(
|
||||
"Fix the renderer\nwith the existing card design.",
|
||||
)
|
||||
expect(parsed?.messages[1]?.text).toBe("Implemented the parser.")
|
||||
})
|
||||
|
||||
it("keeps multi-line message bodies intact", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ function formatClientName(value: string | null | undefined): string | null {
|
|||
if (lower === "claude desktop") return "Claude Desktop"
|
||||
if (lower === "claude code") return "Claude Code"
|
||||
if (lower === "opencode") return "OpenCode"
|
||||
if (lower === "cursor") return "Cursor"
|
||||
if (lower === "openclaw") return "OpenClaw"
|
||||
if (lower === "hermes") return "Hermes"
|
||||
if (lower === "amp") return "Amp"
|
||||
|
|
@ -136,6 +137,12 @@ function pluginIdentityFromSource(
|
|||
label: "OpenCode",
|
||||
iconSrc: "/images/plugins/opencode.svg",
|
||||
}
|
||||
case "cursor":
|
||||
return {
|
||||
pluginId: "cursor",
|
||||
label: "Cursor",
|
||||
iconSrc: "/images/plugins/cursor.png",
|
||||
}
|
||||
case "amp":
|
||||
return {
|
||||
pluginId: "amp",
|
||||
|
|
@ -164,7 +171,19 @@ function pluginIdentityFromSpace(
|
|||
.filter((tag): tag is string => typeof tag === "string" && !!tag)
|
||||
: []
|
||||
|
||||
for (const tag of [...containerTags, ...memorySpaceTags]) {
|
||||
const allTags = [...containerTags, ...memorySpaceTags]
|
||||
for (const tag of allTags) {
|
||||
if (/^cursor_(?:user|project)_[0-9a-f]{6,64}$/i.test(tag)) {
|
||||
return {
|
||||
pluginId: "cursor",
|
||||
label: "Cursor",
|
||||
iconSrc: "/images/plugins/cursor.png",
|
||||
projectId: tag.split("_").at(-1)?.slice(0, 6),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of allTags) {
|
||||
const plugin = detectPluginSpace(tag)
|
||||
if (plugin) return plugin
|
||||
}
|
||||
|
|
@ -380,7 +399,7 @@ function parseSessionTranscript(
|
|||
content: string,
|
||||
config: {
|
||||
kind: "codex-session" | "amp-thread" | "plugin-session"
|
||||
headerLabel: "Session" | "Amp thread"
|
||||
headerLabel: "Session" | "Amp thread" | "Conversation"
|
||||
pluginLabel: string
|
||||
pluginIconSrc?: string | null
|
||||
formatLabel: string
|
||||
|
|
@ -424,6 +443,61 @@ function parseSessionTranscript(
|
|||
}
|
||||
}
|
||||
|
||||
function parseLegacyCursorTranscript(
|
||||
content: string,
|
||||
plugin: PluginIdentity | null,
|
||||
): ParsedPluginDocument | null {
|
||||
if (
|
||||
plugin?.pluginId !== "cursor" ||
|
||||
!/^Cursor IDE session transcript:\s*/i.test(content)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const transcript = content.replace(/^Cursor IDE session transcript:\s*/i, "")
|
||||
const messages: PluginDocumentMessage[] = []
|
||||
const regex =
|
||||
/^(User|Assistant):\s*([\s\S]*?)(?=^(?:User|Assistant):\s*|(?![\s\S]))/gim
|
||||
|
||||
for (const match of transcript.matchAll(regex)) {
|
||||
const role =
|
||||
match[1]?.toLowerCase() === "user"
|
||||
? ("user" as const)
|
||||
: ("assistant" as const)
|
||||
const text = match[2]?.trim()
|
||||
if (!text) continue
|
||||
messages.push({
|
||||
id: `${role}-${messages.length}`,
|
||||
role,
|
||||
text,
|
||||
})
|
||||
}
|
||||
if (messages.length === 0) return null
|
||||
|
||||
const userCount = messages.filter((message) => message.role === "user").length
|
||||
const assistantCount = messages.filter(
|
||||
(message) => message.role === "assistant",
|
||||
).length
|
||||
const previewSource =
|
||||
messages.find((message) => message.role === "user")?.text ??
|
||||
messages[0]?.text ??
|
||||
"Conversation"
|
||||
|
||||
return {
|
||||
kind: "plugin-session",
|
||||
pluginLabel: plugin.label,
|
||||
pluginIconSrc: plugin.iconSrc ?? undefined,
|
||||
formatLabel: "Conversation",
|
||||
title: "Cursor conversation",
|
||||
preview: takePreview(previewSource, 140),
|
||||
summary: `${userCount} user message${userCount === 1 ? "" : "s"} and ${assistantCount} assistant message${assistantCount === 1 ? "" : "s"} captured from Cursor.`,
|
||||
artifacts: [],
|
||||
messages,
|
||||
sections: [],
|
||||
rawContent: content,
|
||||
}
|
||||
}
|
||||
|
||||
function parseRoleBlockTranscript(
|
||||
content: string,
|
||||
plugin: PluginIdentity | null,
|
||||
|
|
@ -710,6 +784,26 @@ export function parsePluginDocument(
|
|||
}
|
||||
}
|
||||
|
||||
if (plugin?.pluginId === "cursor") {
|
||||
const cursorSession = parseSessionTranscript(content, {
|
||||
kind: "plugin-session",
|
||||
headerLabel: "Conversation",
|
||||
pluginLabel: plugin.label,
|
||||
pluginIconSrc: plugin.iconSrc,
|
||||
formatLabel: "Conversation",
|
||||
})
|
||||
if (cursorSession) {
|
||||
if (clientName) {
|
||||
cursorSession.clientLabel = "Client"
|
||||
cursorSession.clientValue = clientName
|
||||
}
|
||||
return withIcon(cursorSession)
|
||||
}
|
||||
|
||||
const legacyCursorSession = parseLegacyCursorTranscript(content, plugin)
|
||||
if (legacyCursorSession) return withIcon(legacyCursorSession)
|
||||
}
|
||||
|
||||
if (plugin?.pluginId === "amp") {
|
||||
const ampThread = parseSessionTranscript(content, {
|
||||
kind: "amp-thread",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type PluginSpaceInfo = {
|
|||
| "openclaw"
|
||||
| "opencode"
|
||||
| "codex"
|
||||
| "cursor"
|
||||
| "amp"
|
||||
| "hermes"
|
||||
label: string
|
||||
|
|
@ -27,7 +28,14 @@ const PLUGINS: PluginDef[] = [
|
|||
id: "agents",
|
||||
label: "Agents",
|
||||
iconSrc: null,
|
||||
prefixes: ["user_project", "repo", "claudecode", "codex"],
|
||||
prefixes: [
|
||||
"user_project",
|
||||
"repo",
|
||||
"claudecode",
|
||||
"codex",
|
||||
"opencode",
|
||||
"cursor",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
|
|
@ -73,6 +81,7 @@ const PLUGIN_ICON_BY_LABEL: Record<string, string> = {
|
|||
OpenClaw: "/images/plugins/openclaw.svg",
|
||||
OpenCode: "/images/plugins/opencode.svg",
|
||||
Codex: "/images/plugins/codex.png",
|
||||
Cursor: "/images/plugins/cursor.png",
|
||||
Hermes: "/images/plugins/hermes.svg",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,5 +62,7 @@ export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
|
|||
export const agentSourceParam = parseAsStringLiteral([
|
||||
"claude-code",
|
||||
"codex",
|
||||
"opencode",
|
||||
"cursor",
|
||||
] as const)
|
||||
export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([])
|
||||
|
|
|
|||
|
|
@ -29,15 +29,10 @@ import {
|
|||
UpdateContainerTagSettingsRequestSchema,
|
||||
} from "../validation/api"
|
||||
|
||||
// Settings response schema - this is custom to console (not in shared validation)
|
||||
const SettingsResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
settings: z.object({
|
||||
excludeItems: z.array(z.string().min(1).max(20)).optional(),
|
||||
filterPrompt: z.string().min(1).max(750).optional(),
|
||||
includeItems: z.array(z.string().min(1).max(20)).optional(),
|
||||
shouldLLMFilter: z.boolean().optional(),
|
||||
}),
|
||||
const UpdateSettingsResponseSchema = z.object({
|
||||
orgId: z.string(),
|
||||
orgSlug: z.string(),
|
||||
updated: SettingsRequestSchema,
|
||||
})
|
||||
|
||||
// Analytics request schema - custom to console
|
||||
|
|
@ -195,11 +190,11 @@ export const apiSchema = createSchema({
|
|||
|
||||
// Settings operations
|
||||
"@get/settings": {
|
||||
output: z.object({}).passthrough(),
|
||||
output: SettingsRequestSchema,
|
||||
},
|
||||
"@patch/settings": {
|
||||
input: SettingsRequestSchema,
|
||||
output: SettingsResponseSchema,
|
||||
output: UpdateSettingsResponseSchema,
|
||||
},
|
||||
"@post/settings/reset": {
|
||||
input: z.object({ confirmation: z.string() }),
|
||||
|
|
|
|||
|
|
@ -801,7 +801,7 @@ export const SettingsRequestSchema = OrganizationSettingsSchema.omit({
|
|||
id: true,
|
||||
orgId: true,
|
||||
updatedAt: true,
|
||||
})
|
||||
}).partial()
|
||||
|
||||
export const ConnectionResponseSchema = z.object({
|
||||
createdAt: z.string().datetime(),
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ export const OrganizationSettingsSchema = z.object({
|
|||
filterPrompt: z.string().nullable().optional(),
|
||||
includeItems: z.array(z.string()).nullable().optional(),
|
||||
excludeItems: z.array(z.string()).nullable().optional(),
|
||||
workspacePrompt: z.string().max(1500).nullable().optional(),
|
||||
|
||||
// Google Drive custom keys
|
||||
googleDriveCustomKeyEnabled: z.boolean().default(false),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue