spool-memory 0.1.1

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog'
import { DraftFormFields } from '@/components/draft-form-fields'
import { ErrorBanner } from '@/components/error-banner'
import { emptyDraft, type DraftForm, buildMetadataDto, parseCsvField } from '@/lib/drafts'
import { ipc } from '@/lib/api/desktop'
import { asEnvelope } from '@/lib/error'
import { qk } from '@/lib/queryKeys'
import type { DesktopMemoryDraftRequest } from '@/lib/types/desktop'
import { useConfig } from '@/state/config'
import { Loader2 } from 'lucide-react'

type Mode = 'manual' | 'propose'

export function DraftDialog({
  mode,
  open,
  onOpenChange,
  onCreated,
}: {
  mode: Mode
  open: boolean
  onOpenChange: (open: boolean) => void
  onCreated: () => void
}) {
  const cfg = useConfig()
  const qc = useQueryClient()
  const [form, setForm] = React.useState<DraftForm>(emptyDraft)

  const submitMutation = useMutation({
    mutationFn: async (input: { mode: Mode; payload: DesktopMemoryDraftRequest }) => {
      if (input.mode === 'manual') {
        return ipc.recordManual(input.payload)
      }
      return ipc.proposeMemory(input.payload)
    },
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: qk.workbench(cfg.configPath) })
      onOpenChange(false)
      onCreated()
    },
  })

  React.useEffect(() => {
    if (open) {
      setForm({
        ...emptyDraft,
        source_ref: mode === 'manual' ? 'manual:desktop' : 'ai:desktop',
      })
      submitMutation.reset()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open, mode])

  function submit() {
    const payload: DesktopMemoryDraftRequest = {
      config_path: cfg.configPath,
      title: form.title.trim(),
      summary: form.summary.trim(),
      memory_type: form.memory_type.trim(),
      scope: form.scope,
      source_ref: form.source_ref.trim(),
      project_id: form.project_id.trim() || null,
      user_id: form.user_id.trim() || null,
      sensitivity: form.sensitivity.trim() || null,
      metadata: buildMetadataDto(form),
      entities: parseCsvField(form.entities),
      tags: parseCsvField(form.tags),
      triggers: parseCsvField(form.triggers),
      related_files: parseCsvField(form.related_files),
      related_records: parseCsvField(form.related_records),
      supersedes: form.supersedes.trim() || null,
      applies_to: parseCsvField(form.applies_to),
      valid_until: form.valid_until.trim() || null,
    }
    submitMutation.mutate({ mode, payload })
  }

  const loading = submitMutation.isPending
  const errorEnvelope = submitMutation.error ? asEnvelope(submitMutation.error) : null

  const title = mode === 'manual' ? '记录手动记忆' : '提交 AI 候选记忆'
  const description =
    mode === 'manual'
      ? '手动记录的记忆会以 Accepted 状态直接入库。'
      : 'AI 候选以 Candidate 状态入库,需要在审核队列里采纳后才可唤醒。'

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-h-[85vh] overflow-y-auto">
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        <DraftFormFields value={form} onChange={setForm} />
        {errorEnvelope && <ErrorBanner envelope={errorEnvelope} />}
        <DialogFooter>
          <Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
            取消
          </Button>
          <Button
            onClick={submit}
            disabled={
              loading ||
              !form.title.trim() ||
              !form.summary.trim() ||
              !form.memory_type.trim() ||
              !form.source_ref.trim()
            }
          >
            {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
            提交
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}