feat(spaces): per-space entity context + edit-space modal (#1078)

- "What to remember" per-space context — set on create, on edit, and in the space profile; steers what gets extracted into memory
- Edit-space modal (name + context) from the space pill's hover edit, mirroring the create modal
- Manual saves respect a space's configured context instead of overwriting it with the generic default
This commit is contained in:
MaheshtheDev 2026-06-12 20:51:37 +00:00
parent 40d025e178
commit 81ae72224f
7 changed files with 681 additions and 104 deletions

View file

@ -2,7 +2,7 @@
import { useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon, Loader2 } from "lucide-react"
@ -10,6 +10,7 @@ import { Button } from "@ui/components/button"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { analytics } from "@/lib/analytics"
import { $fetch } from "@lib/api"
const EMOJI_LIST = [
"📁",
@ -62,6 +63,25 @@ const EMOJI_LIST = [
"🤍",
]
export const CONTEXT_PRESETS: { label: string; text: string }[] = [
{
label: "Work project",
text: "Tracks a work project — decisions, owners, deadlines, and current status.",
},
{
label: "Client",
text: "About a client — meetings, requirements, and account context.",
},
{
label: "Research",
text: "Research notes — sources, key findings, and open questions.",
},
{
label: "Personal",
text: "My personal space — notes, ideas, and things to remember.",
},
]
export function AddSpaceModal({
isOpen,
onClose,
@ -72,6 +92,8 @@ export function AddSpaceModal({
onCreated?: (containerTag: string) => void
}) {
const [spaceName, setSpaceName] = useState("")
const [spaceContext, setSpaceContext] = useState("")
const [showContext, setShowContext] = useState(false)
const [emoji, setEmoji] = useState("📁")
const [isEmojiOpen, setIsEmojiOpen] = useState(false)
const { createProjectMutation } = useProjectMutations()
@ -79,6 +101,8 @@ export function AddSpaceModal({
const handleClose = () => {
onClose()
setSpaceName("")
setSpaceContext("")
setShowContext(false)
setEmoji("📁")
}
@ -89,11 +113,18 @@ export function AddSpaceModal({
createProjectMutation.mutate(
{ name: trimmedName, emoji: emoji || undefined },
{
onSuccess: (data) => {
onSuccess: async (data) => {
analytics.spaceCreated()
if (data?.containerTag) {
onCreated?.(data.containerTag)
const tag = data?.containerTag
const context = showContext ? spaceContext.trim() : ""
if (tag && context) {
try {
await $fetch(`@patch/container-tags/${tag}`, {
body: { entityContext: context },
})
} catch {}
}
if (tag) onCreated?.(tag)
handleClose()
},
},
@ -132,21 +163,21 @@ export function AddSpaceModal({
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<p
<DialogTitle
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Create new space
</p>
New space
</DialogTitle>
<p
className={cn(
"text-[#737373] font-medium text-[16px] leading-[1.35]",
dmSansClassName(),
"text-[#737373] text-[13px] leading-snug",
)}
>
Create spaces to organize your memories and documents and create
a context rich environment
Group related memories and give Nova context for this space.
</p>
</div>
<DialogPrimitive.Close
@ -222,6 +253,71 @@ export function AddSpaceModal({
/>
</div>
{!showContext ? (
<button
type="button"
onClick={() => setShowContext(true)}
className={cn(
dmSansClassName(),
"flex cursor-pointer items-center gap-1.5 self-start pl-1 text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#fafafa]",
)}
>
<span className="text-[16px] leading-none text-[#737373]">+</span>
<span className="inline-flex items-baseline gap-1.5">
Tell Nova what to remember
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
New
</span>
</span>
</button>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5 pl-1">
<span
className={cn(
dmSans125ClassName(),
"text-[12px] font-medium text-[#A3A3A3]",
)}
>
What to remember
</span>
<span className="ml-auto text-[11px] text-[#525966]">
Optional
</span>
</div>
<textarea
value={spaceContext}
onChange={(e) => setSpaceContext(e.target.value)}
placeholder="Tell Nova what matters here — it shapes which memories get extracted."
maxLength={750}
className={cn(
dmSansClassName(),
"min-h-[56px] w-full resize-y rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-4 py-3 text-[14px] leading-relaxed text-[#fafafa] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)]",
)}
style={{
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)",
}}
/>
<div className="flex flex-wrap items-center gap-1.5 pl-1">
<span className="text-[11px] text-[#737373]">Try a preset</span>
{CONTEXT_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setSpaceContext(preset.text)}
className={cn(
dmSansClassName(),
"cursor-pointer rounded-full bg-[#1E232B] px-2.5 py-1 text-[11px] font-medium text-[#C8C8C8] transition-colors hover:bg-[#262c36] hover:text-white",
)}
>
{preset.label}
</button>
))}
</div>
</div>
)}
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
@ -247,7 +343,7 @@ export function AddSpaceModal({
</>
) : (
<>
<span className="text-[10px] mr-1">+</span>
<span className="mr-1 text-[16px] leading-none">+</span>
Create Space
</>
)}

View file

@ -0,0 +1,206 @@
"use client"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Loader2, XIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { useSpaceContext, useUpdateSpace } from "@/hooks/use-space-context"
import { CONTEXT_PRESETS } from "@/components/add-space-modal"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
const INPUT_SHADOW =
"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)"
type EditSpaceModalProps = {
containerTag: string
currentName: string
currentEmoji?: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function EditSpaceModal({
containerTag,
currentName,
currentEmoji = "📁",
open,
onOpenChange,
}: EditSpaceModalProps) {
const isDefault = !containerTag || containerTag === DEFAULT_PROJECT_ID
const { data, isLoading } = useSpaceContext(containerTag, open)
const update = useUpdateSpace()
const [name, setName] = useState(currentName)
const [context, setContext] = useState("")
useEffect(() => {
if (!open) return
setName(data?.name ?? currentName ?? "")
setContext(data?.entityContext ?? "")
}, [open, data?.name, data?.entityContext, currentName])
const handleSave = () => {
update.mutate(
{
containerTag,
name: isDefault ? undefined : name.trim() || undefined,
entityContext: context.trim() ? context.trim() : null,
},
{ onSuccess: () => onOpenChange(false) },
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
dmSansClassName(),
)}
style={{
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",
}}
showCloseButton={false}
>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<DialogTitle
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Edit space
</DialogTitle>
<p
className={cn(
dmSansClassName(),
"text-[#737373] text-[13px] leading-snug",
)}
>
Rename this space and tell Nova what to remember in it.
</p>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
data-slot="dialog-close"
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex gap-[6px] items-center">
<div
className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex size-[45px] shrink-0 items-center justify-center rounded-[12px] p-3"
style={{ boxShadow: INPUT_SHADOW }}
aria-hidden
>
<span className="text-xl">{currentEmoji}</span>
</div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isDefault || update.isPending}
placeholder="Space name"
maxLength={120}
className={cn(
"flex-1 bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-3 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)] disabled:opacity-60",
dmSansClassName(),
)}
style={{ boxShadow: INPUT_SHADOW }}
/>
</div>
{isDefault && (
<span className="pl-1 text-[11px] text-[#525966]">
The default space can't be renamed.
</span>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5 pl-1">
<span
className={cn(
dmSans125ClassName(),
"text-[12px] font-medium text-[#A3A3A3]",
)}
>
What to remember
</span>
<span className="ml-auto text-[11px] text-[#525966]">
Optional
</span>
</div>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
disabled={isLoading || update.isPending}
placeholder="Tell Nova what matters here — it shapes which memories get extracted."
maxLength={750}
className={cn(
dmSansClassName(),
"min-h-[56px] w-full resize-y rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-4 py-3 text-[14px] leading-relaxed text-[#fafafa] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)] disabled:opacity-60",
)}
style={{ boxShadow: INPUT_SHADOW }}
/>
<div className="flex flex-wrap items-center gap-1.5 pl-1">
<span className="text-[11px] text-[#737373]">Try a preset</span>
{CONTEXT_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setContext(preset.text)}
className={cn(
dmSansClassName(),
"cursor-pointer rounded-full bg-[#1E232B] px-2.5 py-1 text-[11px] font-medium text-[#C8C8C8] transition-colors hover:bg-[#262c36] hover:text-white",
)}
>
{preset.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
onClick={() => onOpenChange(false)}
disabled={update.isPending}
className={cn(
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
dmSansClassName(),
)}
>
Cancel
</button>
<Button
variant="insideOut"
onClick={handleSave}
disabled={update.isPending}
className="px-4 py-[10px] rounded-full"
>
{update.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Saving
</>
) : (
"Save"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -207,6 +207,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete
enableEdit
/>
</>
)}

View file

@ -1,9 +1,14 @@
"use client"
import { Brain, X } from "lucide-react"
import { Brain, Loader, X } from "lucide-react"
import { motion } from "motion/react"
import { useEffect, useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { useSpaceProfile } from "@/hooks/use-space-profile"
import {
useSpaceContext,
useUpdateSpaceContext,
} from "@/hooks/use-space-context"
import { cn } from "@lib/utils"
type SpaceProfilePanelProps = {
@ -87,6 +92,85 @@ function ProfileSection({ label, items }: { label: string; items: string[] }) {
)
}
function SpaceContextEditor({ containerTag }: { containerTag: string }) {
const { data, isLoading } = useSpaceContext(containerTag)
const update = useUpdateSpaceContext()
const [value, setValue] = useState("")
const saved = data?.entityContext ?? ""
useEffect(() => {
setValue(data?.entityContext ?? "")
}, [data?.entityContext])
const dirty = value.trim() !== saved.trim()
const handleSave = () => {
update.mutate({
containerTag,
entityContext: value.trim() ? value.trim() : null,
})
}
return (
<div className="flex flex-col gap-2 rounded-[14px] border border-white/[0.08] bg-[#14161A] p-3">
<div className="flex flex-col gap-0.5">
<span
className={cn(
dmSans125ClassName(),
"text-[12px] font-semibold text-[#FAFAFA]",
)}
>
What to remember
</span>
<span className="text-[11px] leading-relaxed text-[#737373]">
Tell Nova what matters in this space it shapes which memories get
extracted.
</span>
</div>
<textarea
value={value}
onChange={(event) => setValue(event.target.value)}
disabled={isLoading || update.isPending}
placeholder="e.g. This space tracks Acme's billing project — decisions, owners, and deadlines."
maxLength={750}
className="min-h-[80px] w-full resize-y rounded-[10px] border border-white/[0.08] bg-[#0D121A] px-3 py-2.5 text-[12px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none disabled:opacity-60"
/>
<div className="flex items-center justify-between">
<span className="text-[11px] text-[#737373] tabular-nums">
{value.length}/750
</span>
{dirty && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setValue(saved)}
disabled={update.isPending}
className={cn(
dmSansClassName(),
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={update.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 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{update.isPending && <Loader className="size-3 animate-spin" />}
Save
</button>
</div>
)}
</div>
</div>
)
}
export function SpaceProfileContent({
containerTag,
onClose,
@ -125,6 +209,8 @@ export function SpaceProfileContent({
</div>
<div className="min-h-0 flex-1 overflow-y-auto pt-4 scrollbar-thin">
<SpaceContextEditor containerTag={containerTag} />
<div className="mt-5">
{isLoading ? (
<LoadingState />
) : error ? (
@ -141,6 +227,7 @@ export function SpaceProfileContent({
)}
</div>
</div>
</div>
)
}

View file

@ -7,10 +7,11 @@ import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { ChevronDownIcon, XIcon, Loader2, Trash2 } from "lucide-react"
import { ChevronDownIcon, Pencil, XIcon, Loader2, Trash2 } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { AddSpaceModal } from "./add-space-modal"
import { EditSpaceModal } from "./edit-space-modal"
import { SelectSpacesModal } from "./select-spaces-modal"
import { SpaceGlyph } from "./space-glyph"
import { useProjectMutations } from "@/hooks/use-project-mutations"
@ -54,6 +55,7 @@ export interface SpaceSelectorProps {
compact?: boolean
includeAuto?: boolean
hideCount?: boolean
enableEdit?: boolean
}
const triggerVariants = {
@ -114,9 +116,11 @@ export function SpaceSelector({
compact = false,
includeAuto = false,
hideCount = false,
enableEdit = false,
}: SpaceSelectorProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
const [showEditDialog, setShowEditDialog] = useState(false)
const [recents, setRecents] = useState<string[]>([])
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean
@ -231,6 +235,12 @@ export function SpaceSelector({
}
}, [allProjects, selectedProjects, pluginMetaMap, includeAuto, user?.id])
const canEditCurrent =
enableEdit &&
!displayInfo.isAuto &&
!displayInfo.plugin &&
!displayInfo.isOwnSpace
const pushRecent = useCallback((tag: string) => {
setRecents((prev) => {
const next = [tag, ...prev.filter((t) => t !== tag)].slice(0, RECENTS_MAX)
@ -371,6 +381,7 @@ export function SpaceSelector({
return (
<>
<div className="group relative inline-flex min-w-0 max-w-full">
<Tooltip>
<TooltipTrigger asChild>
<button
@ -384,10 +395,18 @@ export function SpaceSelector({
className={cn(
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
triggerVariants[variant],
variant === "default" && compact && "h-9 min-h-9 gap-1.5 px-2.5",
variant === "default" &&
compact &&
"h-9 min-h-9 gap-1.5 px-2.5",
dmSansClassName(),
triggerClassName,
)}
>
<span
className={cn(
"flex shrink-0 items-center",
canEditCurrent && "transition-opacity group-hover:opacity-0",
)}
>
{displayInfo.isAuto ? (
<AutoSpaceIcon size={compact ? 16 : 18} />
@ -421,8 +440,12 @@ export function SpaceSelector({
</span>
)
) : (
<SpaceGlyph emoji={displayInfo.emoji} size={compact ? 16 : 18} />
<SpaceGlyph
emoji={displayInfo.emoji}
size={compact ? 16 : 18}
/>
)}
</span>
<span
className={cn(
"min-w-0 truncate text-sm font-medium text-white",
@ -450,6 +473,23 @@ export function SpaceSelector({
Switch space
</TooltipContent>
</Tooltip>
{canEditCurrent && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setShowEditDialog(true)
}}
aria-label={`Edit ${displayInfo.name}`}
className={cn(
"pointer-events-none absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-md text-[#A1A1AA] opacity-0 transition-opacity hover:text-white group-hover:pointer-events-auto group-hover:opacity-100",
compact ? "left-2.5 size-4" : "left-3 size-[18px]",
)}
>
<Pencil className={compact ? "size-3" : "size-3.5"} />
</button>
)}
</div>
<AddSpaceModal
isOpen={showCreateDialog}
@ -475,6 +515,16 @@ export function SpaceSelector({
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
{canEditCurrent && (
<EditSpaceModal
containerTag={activeTag}
currentName={displayInfo.name}
currentEmoji={displayInfo.emoji ?? "📁"}
open={showEditDialog}
onOpenChange={setShowEditDialog}
/>
)}
<Dialog
open={deleteDialog.open}
onOpenChange={(open: boolean) => {

View file

@ -9,6 +9,7 @@ import { toast } from "sonner"
import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
import { fetchSpaceSettings, spaceSettingsKey } from "@/hooks/use-space-context"
/** Pull the human-readable message out of a $fetch error (handles `{error}`/`{message}`/string). */
function fetchErrorMessage(err: unknown, fallback: string): string {
@ -225,7 +226,23 @@ export function useDocumentMutations({
const queryClient = useQueryClient()
const { user } = useAuth()
const entityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
const defaultEntityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
// Skip when the space has its own context — sending one would overwrite the stored value.
const resolveEntityContext = async (
project: string,
): Promise<string | undefined> => {
try {
const settings = await queryClient.fetchQuery({
queryKey: spaceSettingsKey(project),
queryFn: () => fetchSpaceSettings(project),
staleTime: 60 * 1000,
})
return settings?.entityContext ? undefined : defaultEntityContext
} catch {
return defaultEntityContext
}
}
const noteMutation = useMutation({
mutationFn: async ({
@ -235,11 +252,12 @@ export function useDocumentMutations({
content: string
project: string
}) => {
const entityContext = await resolveEntityContext(project)
const response = await $fetch("@post/documents", {
body: {
content,
containerTags: [project],
entityContext,
...(entityContext !== undefined ? { entityContext } : {}),
metadata: { sm_source: "consumer" },
},
})
@ -299,11 +317,12 @@ export function useDocumentMutations({
const linkMutation = useMutation({
mutationFn: async ({ url, project }: { url: string; project: string }) => {
const entityContext = await resolveEntityContext(project)
const response = await $fetch("@post/documents", {
body: {
content: url,
containerTags: [project],
entityContext,
...(entityContext !== undefined ? { entityContext } : {}),
metadata: { sm_source: "consumer" },
},
})
@ -375,12 +394,15 @@ export function useDocumentMutations({
}): Promise<FileUploadBatchResult> => {
const applyMeta = fileEntries.length === 1
const failures: { id: string; message: string }[] = []
const entityContext = await resolveEntityContext(project)
const uploadOne = async (entry: FileUploadEntry) => {
const formData = new FormData()
formData.append("file", entry.file)
formData.append("containerTags", JSON.stringify([project]))
if (entityContext !== undefined) {
formData.append("entityContext", entityContext)
}
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
const response = await fetch(

View file

@ -0,0 +1,115 @@
"use client"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
export type SpaceSettings = {
containerTag: string
name: string | null
entityContext: string | null
}
export const spaceSettingsKey = (containerTag: string) =>
["container-tag-settings", containerTag] as const
export async function fetchSpaceSettings(
containerTag: string,
): Promise<SpaceSettings | null> {
const response = await $fetch(`@get/container-tags/${containerTag}`, {
disableValidation: true,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to load space settings")
}
const data = response.data as Partial<SpaceSettings> | null
if (!data) return null
return {
containerTag,
name: data.name ?? null,
entityContext: data.entityContext ?? null,
}
}
export function useSpaceContext(containerTag: string, enabled = true) {
return useQuery({
queryKey: spaceSettingsKey(containerTag),
queryFn: () => fetchSpaceSettings(containerTag),
enabled: enabled && !!containerTag,
staleTime: 60 * 1000,
})
}
export function useUpdateSpace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
containerTag,
name,
entityContext,
}: {
containerTag: string
name?: string
entityContext?: string | null
}) => {
const body: { name?: string; entityContext?: string | null } = {}
if (name !== undefined) body.name = name
if (entityContext !== undefined) body.entityContext = entityContext
const response = await $fetch(`@patch/container-tags/${containerTag}`, {
body,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to save space")
}
return response.data
},
onSuccess: (_data, { containerTag }) => {
queryClient.invalidateQueries({
queryKey: spaceSettingsKey(containerTag),
})
queryClient.invalidateQueries({ queryKey: ["container-tags"] })
toast.success("Space updated")
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save space",
)
},
})
}
export function useUpdateSpaceContext() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
containerTag,
entityContext,
}: {
containerTag: string
entityContext: string | null
}) => {
const response = await $fetch(`@patch/container-tags/${containerTag}`, {
body: { entityContext },
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to save space context",
)
}
return response.data
},
onSuccess: (_data, { containerTag }) => {
queryClient.invalidateQueries({
queryKey: spaceSettingsKey(containerTag),
})
toast.success("Space context saved")
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save space context",
)
},
})
}