/ src / components / memory / memory-sheet.tsx
memory-sheet.tsx
  1  'use client'
  2  
  3  import { useState } from 'react'
  4  import { useAppStore } from '@/stores/use-app-store'
  5  import { createMemory } from '@/lib/memory'
  6  import { getMemoryTierForCategory } from '@/lib/memory-presentation'
  7  import { BottomSheet } from '@/components/shared/bottom-sheet'
  8  import { AgentAvatar } from '@/components/agents/agent-avatar'
  9  import { SheetFooter } from '@/components/shared/sheet-footer'
 10  import { inputClass } from '@/components/shared/form-styles'
 11  
 12  const CATEGORIES = ['note', 'fact', 'preference', 'finding', 'learning', 'general']
 13  
 14  export function MemorySheet() {
 15    const open = useAppStore((s) => s.memorySheetOpen)
 16    const setOpen = useAppStore((s) => s.setMemorySheetOpen)
 17    const triggerRefresh = useAppStore((s) => s.triggerMemoryRefresh)
 18    const agents = useAppStore((s) => s.agents)
 19    const memoryAgentFilter = useAppStore((s) => s.memoryAgentFilter)
 20  
 21    // Track open transitions to reset form
 22    const [prevOpen, setPrevOpen] = useState(false)
 23    const defaultAgentId = memoryAgentFilter && memoryAgentFilter !== '_global' ? memoryAgentFilter : null
 24  
 25    const [title, setTitle] = useState('')
 26    const [content, setContent] = useState('')
 27    const [category, setCategory] = useState('note')
 28    const [tier, setTier] = useState<'working' | 'durable' | 'archive'>(getMemoryTierForCategory('note'))
 29    const [agentId, setAgentId] = useState<string | null>(defaultAgentId)
 30    const [sharedWith, setSharedWith] = useState<string[]>([])
 31    const [saving, setSaving] = useState(false)
 32  
 33    // Reset form when sheet opens (getDerivedStateFromProps pattern)
 34    if (open && !prevOpen) {
 35      setPrevOpen(true)
 36      setAgentId(defaultAgentId)
 37      setSharedWith([])
 38      setTitle('')
 39      setContent('')
 40      setCategory('note')
 41      setTier(getMemoryTierForCategory('note'))
 42      setSaving(false)
 43    } else if (!open && prevOpen) {
 44      setPrevOpen(false)
 45    }
 46  
 47    const onClose = () => {
 48      setOpen(false)
 49    }
 50  
 51    const handleSave = async () => {
 52      if (!title.trim()) return
 53      setSaving(true)
 54      try {
 55        await createMemory({
 56          title: title.trim(),
 57          category,
 58          content,
 59          agentId,
 60          sessionId: null,
 61          sharedWith: sharedWith.length ? sharedWith : undefined,
 62          metadata: {
 63            tier,
 64            scope: agentId ? 'agent' : 'global',
 65            visibility: agentId ? (sharedWith.length ? 'shared' : 'private') : 'global',
 66          },
 67        })
 68        triggerRefresh()
 69        onClose()
 70      } catch {
 71        /* ignore */
 72      }
 73      setSaving(false)
 74    }
 75  
 76    const agentList = Object.values(agents).sort((a, b) => a.name.localeCompare(b.name))
 77    const selectedAgent = agentId ? agents[agentId] : null
 78  
 79    return (
 80      <BottomSheet open={open} onClose={onClose}>
 81        <div className="mb-8">
 82          <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2">New Memory</h2>
 83          <p className="text-[14px] text-text-3">Store a piece of knowledge for an agent or globally</p>
 84        </div>
 85  
 86        {/* Agent selector */}
 87        <div className="mb-6">
 88          <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Visibility</label>
 89          <div className="flex gap-2 flex-wrap">
 90            <button
 91              onClick={() => setAgentId(null)}
 92              className={`flex items-center gap-2 px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
 93                ${!agentId
 94                  ? 'bg-accent-soft border-accent-bright/20 text-accent-bright'
 95                  : 'bg-white/[0.02] border-white/[0.06] text-text-3 hover:text-text-2 hover:bg-white/[0.04]'}`}
 96              style={{ fontFamily: 'inherit' }}
 97            >
 98              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className={!agentId ? 'text-accent-bright' : 'text-text-3/60'}>
 99                <circle cx="12" cy="12" r="10" />
100                <line x1="2" y1="12" x2="22" y2="12" />
101                <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
102              </svg>
103              Global
104            </button>
105            {agentList.map((agent) => (
106              <button
107                key={agent.id}
108                onClick={() => setAgentId(agent.id)}
109                className={`flex items-center gap-2 px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
110                  ${agentId === agent.id
111                    ? 'bg-accent-soft border-accent-bright/20 text-accent-bright'
112                    : 'bg-white/[0.02] border-white/[0.06] text-text-3 hover:text-text-2 hover:bg-white/[0.04]'}`}
113                style={{ fontFamily: 'inherit' }}
114              >
115                <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={20} />
116                <span className="truncate max-w-[120px]">{agent.name}</span>
117              </button>
118            ))}
119          </div>
120          {selectedAgent && (
121            <p className="text-[11px] text-text-3/50 mt-2">
122              Owned by <span className="text-text-2">{selectedAgent.name}</span>. Add collaborators below if other agents should be able to recall it too.
123            </p>
124          )}
125          {!agentId && (
126            <p className="text-[11px] text-text-3/50 mt-2">
127              Global memories are accessible to every agent in the workspace.
128            </p>
129          )}
130        </div>
131  
132        {/* Share with (only when assigned to an agent) */}
133        {agentId && agentList.filter((a) => a.id !== agentId).length > 0 && (
134          <div className="mb-6">
135            <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Share with</label>
136            <div className="flex gap-2 flex-wrap">
137              {agentList
138                .filter((a) => a.id !== agentId)
139                .map((agent) => {
140                  const isShared = sharedWith.includes(agent.id)
141                  return (
142                    <button
143                      key={agent.id}
144                      onClick={() => setSharedWith(isShared ? sharedWith.filter((id) => id !== agent.id) : [...sharedWith, agent.id])}
145                      className={`flex items-center gap-2 px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
146                        ${isShared
147                          ? 'bg-accent-soft border-accent-bright/20 text-accent-bright'
148                          : 'bg-white/[0.02] border-white/[0.06] text-text-3 hover:text-text-2 hover:bg-white/[0.04]'}`}
149                      style={{ fontFamily: 'inherit' }}
150                    >
151                      <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={20} />
152                      <span className="truncate max-w-[120px]">{agent.name}</span>
153                    </button>
154                  )
155                })}
156            </div>
157            {sharedWith.length > 0 && (
158              <p className="text-[11px] text-text-3/50 mt-2">
159                Shared with {sharedWith.length} agent{sharedWith.length === 1 ? '' : 's'} in addition to the owner
160              </p>
161            )}
162          </div>
163        )}
164  
165        {/* Title */}
166        <div className="mb-6">
167          <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Title</label>
168          <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Memory title" className={inputClass} style={{ fontFamily: 'inherit' }} />
169        </div>
170  
171        {/* Category */}
172        <div className="mb-6">
173          <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Category</label>
174          <div className="flex gap-1.5 flex-wrap">
175            {CATEGORIES.map((c) => (
176              <button
177                key={c}
178                onClick={() => {
179                  setCategory(c)
180                  setTier(getMemoryTierForCategory(c))
181                }}
182                className={`px-3 py-1.5 rounded-[8px] text-[12px] font-600 capitalize cursor-pointer transition-all border-none
183                  ${category === c
184                    ? 'bg-accent-soft text-accent-bright'
185                    : 'bg-white/[0.03] text-text-3 hover:text-text-2 hover:bg-white/[0.05]'}`}
186                style={{ fontFamily: 'inherit' }}
187              >
188                {c}
189              </button>
190            ))}
191          </div>
192        </div>
193  
194        <div className="mb-6">
195          <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Tier</label>
196          <select
197            value={tier}
198            onChange={(e) => setTier(e.target.value as typeof tier)}
199            className={inputClass}
200            style={{ fontFamily: 'inherit' }}
201          >
202            <option value="working">Working: short-horizon, active context</option>
203            <option value="durable">Durable: keep this around as reusable knowledge</option>
204            <option value="archive">Archive: preserve, but keep less salient</option>
205          </select>
206          <p className="text-[11px] text-text-3/50 mt-2">
207            Tier controls how aggressively this memory should stay in active recall.
208          </p>
209        </div>
210  
211        {/* Content */}
212        <div className="mb-8">
213          <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Content</label>
214          <textarea
215            value={content}
216            onChange={(e) => setContent(e.target.value)}
217            placeholder="Memory content..."
218            rows={6}
219            className={`${inputClass} resize-y min-h-[150px]`}
220            style={{ fontFamily: 'inherit' }}
221          />
222        </div>
223  
224        <SheetFooter
225          onCancel={onClose}
226          onSave={handleSave}
227          saveLabel={saving ? 'Saving...' : 'Save'}
228          saveDisabled={!title.trim() || saving}
229        />
230      </BottomSheet>
231    )
232  }