supermemory/apps/web/hooks/use-brain-skills.impl.ts
sreedharsreeram 570ed22b6c feat(skills): surface Company Brain-created skills (#1412)
## What and why

Make skills created through Company Brain visible in the existing Settings catalog without a manual reload. The UI labels Slack-originated skills and refreshes the catalog while the Settings page is open.

```mermaid
flowchart LR
  A[Slack approval] --> B[Company Brain skill catalog]
  B --> C[Skills API query]
  C --> D[Settings Skills list]
  D --> E[Created by Company Brain label]
```

## Validation

- `bunx biome check apps/web/components/settings/company-brain-skills.tsx apps/web/components/settings/company-brain-skills/domain.ts apps/web/components/settings/company-brain-skills/skill-row.tsx apps/web/hooks/use-brain-skills.impl.ts` — passed

## Impact

Settings polling refreshes every 15 seconds and on window focus. It does not change the Nova/browser chat workflow or create skills from that surface.
2026-08-05 03:42:37 +00:00

187 lines
4.4 KiB
TypeScript

"use client"
import { useAuth } from "@lib/auth-context"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type {
NewSkillOrigin,
SkillDraft,
SkillOrigin,
SkillScope,
} from "@/components/settings/company-brain-skills/domain"
import { skillSaveRequestBody } from "@/components/settings/company-brain-skills/domain"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const BASE = `${BACKEND}/brain/skills`
export type BrainSkillStatus = "active" | "disabled"
export type BrainSkill = {
id: string
name: string
description: string
body: string
scope: SkillScope
status: BrainSkillStatus
creatorUserId: string
origin: SkillOrigin
canEdit: boolean
canDelete: boolean
version: number
createdAt: number
updatedAt: number
rejectionReason: string | null
}
export type BrainSkillsResponse = {
skills: BrainSkill[]
isAdmin: boolean
viewerId: string
}
export const brainSkillKeys = {
all: (orgId: string | undefined, userId: string | undefined) =>
["brain", "skills", orgId, userId] as const,
list: (orgId: string | undefined, userId: string | undefined) =>
[...brainSkillKeys.all(orgId, userId), "list"] as const,
}
class BrainSkillRequestError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = "BrainSkillRequestError"
this.status = status
}
}
async function responseError(response: Response, fallback: string) {
const body = (await response.json().catch(() => null)) as {
error?: string | { message?: string }
message?: string
} | null
const nested =
typeof body?.error === "object" ? body.error.message : undefined
const message =
body?.message ??
(typeof body?.error === "string" ? body.error : undefined) ??
nested ??
fallback
return new BrainSkillRequestError(message, response.status)
}
async function jsonRequest<T>(
url: string,
init: RequestInit,
fallback: string,
) {
const response = await fetch(url, { credentials: "include", ...init })
if (!response.ok) throw await responseError(response, fallback)
return (await response.json()) as T
}
export function useBrainSkills() {
const { org, user } = useAuth()
return useQuery({
queryKey: brainSkillKeys.list(org?.id, user?.id),
queryFn: () =>
jsonRequest<BrainSkillsResponse>(`${BASE}/`, {}, "Couldn't load skills."),
enabled: !!org?.id && !!user?.id,
staleTime: 0,
refetchOnWindowFocus: true,
refetchInterval: 15_000,
})
}
function useInvalidateBrainSkills() {
const { org, user } = useAuth()
const queryClient = useQueryClient()
return () =>
queryClient.invalidateQueries({
queryKey: brainSkillKeys.all(org?.id, user?.id),
})
}
export function useSaveBrainSkill() {
const invalidate = useInvalidateBrainSkills()
return useMutation({
mutationFn: ({
id,
draft,
createOrigin,
expectedVersion,
}: {
id: string | null
draft: SkillDraft
createOrigin?: NewSkillOrigin
expectedVersion?: number
}) =>
jsonRequest<{ skill: BrainSkill }>(
id ? `${BASE}/${id}` : `${BASE}/`,
{
method: id ? "PUT" : "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
skillSaveRequestBody(draft, id, createOrigin, expectedVersion),
),
},
"Couldn't save this skill.",
),
onSuccess: () => {
void invalidate()
},
onError: (error) => {
if (error instanceof BrainSkillRequestError && error.status === 409) {
void invalidate()
}
},
})
}
export function useDeleteBrainSkill() {
const invalidate = useInvalidateBrainSkills()
return useMutation({
mutationFn: ({
id,
expectedVersion,
}: {
id: string
expectedVersion: number
}) =>
jsonRequest<{ ok?: boolean }>(
`${BASE}/${id}`,
{
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ expectedVersion }),
},
"Couldn't delete this skill.",
),
onSuccess: () => {
void invalidate()
},
onError: (error) => {
if (error instanceof BrainSkillRequestError && error.status === 409) {
void invalidate()
}
},
})
}
export function useUploadBrainSkill() {
return useMutation({
mutationFn: (content: string) =>
jsonRequest<{
draft: Partial<SkillDraft> & { origin?: "upload" }
}>(
`${BASE}/upload`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ content }),
},
"Couldn't read this skill file.",
),
})
}