222 lines
7.2 KiB
TypeScript
222 lines
7.2 KiB
TypeScript
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<EditState | null>(null)
|
|
const [deleting, setDeleting] = useState<Omit<NodeMenuTarget, 'x' | 'y'> | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<null | string>(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 ? (
|
|
<>
|
|
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={e => 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. */}
|
|
<div
|
|
className="fixed z-50 min-w-36 rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 shadow-md backdrop-blur-md"
|
|
style={{ left: target.x, top: target.y }}
|
|
>
|
|
<div className="truncate px-2 py-1 text-[0.68rem] text-muted-foreground">{target.label}</div>
|
|
<button
|
|
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs hover:bg-(--ui-control-active-background) hover:text-foreground disabled:opacity-50"
|
|
disabled={loading}
|
|
onClick={() => void openEdit()}
|
|
type="button"
|
|
>
|
|
{target.kind === 'skill' ? t.surfaces.editSkill : t.surfaces.editMemory}
|
|
</button>
|
|
<button
|
|
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs text-destructive hover:bg-destructive/10"
|
|
onClick={() => {
|
|
setDeleting({ id: target.id, kind: target.kind, label: target.label })
|
|
onClose()
|
|
}}
|
|
type="button"
|
|
>
|
|
{target.kind === 'skill' ? t.surfaces.archiveSkill : t.surfaces.deleteMemory}
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
<Dialog onOpenChange={value => !value && !saving && setEditing(null)} open={Boolean(editing)}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{t.surfaces.editNode(editing?.label ?? '')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="h-80">
|
|
{editing && (
|
|
<CodeEditor
|
|
filePath={noun === 'skill' ? 'SKILL.md' : 'memory.md'}
|
|
framed
|
|
initialValue={editing.content}
|
|
key={editing.id}
|
|
onCancel={() => !saving && setEditing(null)}
|
|
onChange={content => setEditing(prev => (prev ? { ...prev, content } : prev))}
|
|
onSave={() => void save()}
|
|
/>
|
|
)}
|
|
</div>
|
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
<DialogFooter>
|
|
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
|
|
{t.common.cancel}
|
|
</Button>
|
|
<Button disabled={saving} onClick={() => void save()}>
|
|
{saving ? t.common.saving : t.common.save}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{deleting?.kind === 'skill' ? (
|
|
<ArchiveSkillConfirmDialog
|
|
onApply={() => {
|
|
onNodeRemoved()
|
|
|
|
return evictStarmapNode(deleting.id)
|
|
}}
|
|
onClose={() => setDeleting(null)}
|
|
onFailure={(err, name) => notifyError(err, name)}
|
|
open
|
|
skillId={deleting.id}
|
|
skillName={deleting.label}
|
|
/>
|
|
) : (
|
|
<ConfirmDialog
|
|
confirmLabel={t.common.delete}
|
|
description={t.surfaces.permanentMemory}
|
|
destructive
|
|
dismissOnConfirm
|
|
onClose={() => 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 ?? '')}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|