mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
40d025e178
commit
81ae72224f
7 changed files with 681 additions and 104 deletions
|
|
@ -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
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
206
apps/web/components/edit-space-modal.tsx
Normal file
206
apps/web/components/edit-space-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -207,6 +207,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
selectedProjects={selectedProjects}
|
||||
onValueChange={setSelectedProjects}
|
||||
enableDelete
|
||||
enableEdit
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,20 +209,23 @@ export function SpaceProfileContent({
|
|||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-4 scrollbar-thin">
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<p className="rounded-[12px] border border-white/[0.08] bg-[#14161A] p-3 text-[13px] text-[#A3A3A3]">
|
||||
Failed to load space profile.
|
||||
</p>
|
||||
) : totalCount === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<ProfileSection label="Key Facts" items={keyFacts} />
|
||||
<ProfileSection label="Recent Context" items={recentContext} />
|
||||
</div>
|
||||
)}
|
||||
<SpaceContextEditor containerTag={containerTag} />
|
||||
<div className="mt-5">
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<p className="rounded-[12px] border border-white/[0.08] bg-[#14161A] p-3 text-[13px] text-[#A3A3A3]">
|
||||
Failed to load space profile.
|
||||
</p>
|
||||
) : totalCount === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<ProfileSection label="Key Facts" items={keyFacts} />
|
||||
<ProfileSection label="Recent Context" items={recentContext} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,85 +381,115 @@ export function SpaceSelector({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="group relative inline-flex min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSelectSpacesModal(true)}
|
||||
aria-label={
|
||||
isLoading
|
||||
? "Loading spaces"
|
||||
: `Space: ${displayInfo.name}. Open selector.`
|
||||
}
|
||||
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",
|
||||
dmSansClassName(),
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center",
|
||||
canEditCurrent && "transition-opacity group-hover:opacity-0",
|
||||
)}
|
||||
>
|
||||
{displayInfo.isAuto ? (
|
||||
<AutoSpaceIcon size={compact ? 16 : 18} />
|
||||
) : displayInfo.isOwnSpace ? (
|
||||
<NovaOrb
|
||||
size={compact ? 14 : 16}
|
||||
className="shrink-0 blur-[0.45px]!"
|
||||
/>
|
||||
) : displayInfo.plugin ? (
|
||||
displayInfo.plugin.iconSrc ? (
|
||||
<Image
|
||||
src={displayInfo.plugin.iconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className={cn(
|
||||
"shrink-0 rounded-[3px]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(displayInfo.plugin.label)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<SpaceGlyph
|
||||
emoji={displayInfo.emoji}
|
||||
size={compact ? 16 : 18}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
compact ? "max-w-[7rem]" : "max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
title={isLoading ? undefined : displayInfo.name}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
{!compact &&
|
||||
!hideCount &&
|
||||
spaceCountData !== undefined &&
|
||||
spaceCountData > 0 && (
|
||||
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
|
||||
· {formatCount(spaceCountData)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 shrink-0 text-[#737373]"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Switch space
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canEditCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSelectSpacesModal(true)}
|
||||
aria-label={
|
||||
isLoading
|
||||
? "Loading spaces"
|
||||
: `Space: ${displayInfo.name}. Open selector.`
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setShowEditDialog(true)
|
||||
}}
|
||||
aria-label={`Edit ${displayInfo.name}`}
|
||||
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",
|
||||
dmSansClassName(),
|
||||
triggerClassName,
|
||||
"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]",
|
||||
)}
|
||||
>
|
||||
{displayInfo.isAuto ? (
|
||||
<AutoSpaceIcon size={compact ? 16 : 18} />
|
||||
) : displayInfo.isOwnSpace ? (
|
||||
<NovaOrb
|
||||
size={compact ? 14 : 16}
|
||||
className="shrink-0 blur-[0.45px]!"
|
||||
/>
|
||||
) : displayInfo.plugin ? (
|
||||
displayInfo.plugin.iconSrc ? (
|
||||
<Image
|
||||
src={displayInfo.plugin.iconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className={cn(
|
||||
"shrink-0 rounded-[3px]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(displayInfo.plugin.label)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<SpaceGlyph emoji={displayInfo.emoji} size={compact ? 16 : 18} />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
compact ? "max-w-[7rem]" : "max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
title={isLoading ? undefined : displayInfo.name}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
{!compact &&
|
||||
!hideCount &&
|
||||
spaceCountData !== undefined &&
|
||||
spaceCountData > 0 && (
|
||||
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
|
||||
· {formatCount(spaceCountData)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 shrink-0 text-[#737373]"
|
||||
aria-hidden
|
||||
/>
|
||||
<Pencil className={compact ? "size-3" : "size-3.5"} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Switch space
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</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) => {
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
formData.append("entityContext", entityContext)
|
||||
if (entityContext !== undefined) {
|
||||
formData.append("entityContext", entityContext)
|
||||
}
|
||||
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
|
||||
|
||||
const response = await fetch(
|
||||
|
|
|
|||
115
apps/web/hooks/use-space-context.ts
Normal file
115
apps/web/hooks/use-space-context.ts
Normal 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",
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue