import { useRef, useState } from 'react' import { ArchiveSkillConfirmDialog, fireOptimistic } from '@/app/learning/archive-skill-confirm-dialog' import { CodeEditor } from '@/components/chat/code-editor' import { Button } from '@/components/ui/button' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes' import { useI18n } from '@/i18n' import { notifyError } from '@/store/notifications' import { evictStarmapNode, loadStarmapGraph } from '@/store/starmap' import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' export interface NodeMenuTarget { id: string kind: 'memory' | 'skill' label: string x: number y: number } interface NodeContextMenuProps { onClose: () => void onNodeRemoved: () => void target: NodeMenuTarget | null } interface EditState { content: string id: string label: string } /** Right-click actions for a star-map node: edit (modal) or delete (confirm). */ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextMenuProps) { const { t } = useI18n() const [editing, setEditing] = useState(null) const [deleting, setDeleting] = useState | null>(null) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) // Bumped on profile switch so an in-flight openEdit fetch from profile A can't // reopen the editor with A's node content after switching to B. const editEpoch = useRef(0) // A profile switch swaps the backend under an open edit/delete dialog — its // node id belongs to the previous profile, so a Save/Delete after the switch // would hit the newly active profile. Close everything on switch. useOnProfileSwitch(() => { editEpoch.current += 1 setEditing(null) setDeleting(null) setError(null) }) const noun = target?.kind === 'memory' ? 'memory' : 'skill' const openEdit = async () => { if (!target) { return } const epoch = editEpoch.current setLoading(true) setError(null) try { const detail = await getLearningNode(target.id) if (editEpoch.current !== epoch) { return } setEditing({ content: detail.content, id: target.id, label: target.label }) onClose() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } } const save = async () => { if (!editing) { return } setSaving(true) setError(null) try { const res = await editLearningNode(editing.id, editing.content) if (!res.ok) { throw new Error(res.message) } setEditing(null) void loadStarmapGraph(true) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSaving(false) } } const menuOpen = target && !editing && !deleting return ( <> {menuOpen ? ( <>
e.preventDefault()} /> {/* Styled to DropdownMenuContent/Item scale (rounded-lg card, p-1, text-xs rows) — the hand-rolled fixed positioning stays because the target is a canvas point, not a DOM anchor. */}
{target.label}
) : null} !value && !saving && setEditing(null)} open={Boolean(editing)}> {t.surfaces.editNode(editing?.label ?? '')}
{editing && ( !saving && setEditing(null)} onChange={content => setEditing(prev => (prev ? { ...prev, content } : prev))} onSave={() => void save()} /> )}
{error ?

{error}

: null}
{deleting?.kind === 'skill' ? ( { onNodeRemoved() return evictStarmapNode(deleting.id) }} onClose={() => setDeleting(null)} onFailure={(err, name) => notifyError(err, name)} open skillId={deleting.id} skillName={deleting.label} /> ) : ( setDeleting(null)} onConfirm={() => { if (!deleting) { return } const { id, label } = deleting const rollback = evictStarmapNode(id) onNodeRemoved() fireOptimistic( deleteLearningNode(id).then(res => { if (!res.ok) { throw new Error(res.message) } }), rollback, err => notifyError(err, label) ) }} open={Boolean(deleting)} title={t.surfaces.deleteNode(deleting?.label ?? '')} /> )} ) }