spool-memory 0.1.1

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import { useConfig } from '@/state/config'
import { ipc } from '@/lib/api/desktop'
import { asEnvelope } from '@/lib/error'
import { invoke as tauriInvoke } from '@tauri-apps/api/core'
import { isTauriHost } from '@/lib/api/desktop'
import type {
  DesktopErrorEnvelope,
  DesktopStatusResponse,
} from '@/lib/types/desktop'
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { ErrorBanner } from '@/components/error-banner'
import { CheckCircle2, Loader2, RefreshCw, XCircle } from 'lucide-react'

type MemoryStats = {
  total: number
  wakeup_ready: number
  pending_review: number
  by_type: Record<string, number>
}

export function StatusPage() {
  const cfg = useConfig()
  const [status, setStatus] = React.useState<DesktopStatusResponse | null>(null)
  const [stats, setStats] = React.useState<MemoryStats | null>(null)
  const [loading, setLoading] = React.useState(false)
  const [error, setError] = React.useState<DesktopErrorEnvelope | null>(null)

  const load = React.useCallback(async () => {
    if (!cfg.configPath.trim() || !cfg.cwd.trim()) return
    setLoading(true)
    setError(null)
    try {
      const res = await ipc.collectStatus({
        config_path: cfg.configPath,
        vault_root_override: cfg.vaultRoot.trim() || null,
        cwd: cfg.cwd,
      })
      setStatus(res)
    } catch (err) {
      setError(asEnvelope(err))
    } finally {
      setLoading(false)
    }
  }, [cfg.configPath, cfg.cwd, cfg.vaultRoot])

  React.useEffect(() => {
    void load()
    if (isTauriHost()) {
      tauriInvoke<MemoryStats>('desktop_memory_stats')
        .then(setStats)
        .catch(() => {})
    }
  }, [load])

  return (
    <div className="space-y-4">
      {error && <ErrorBanner envelope={error} />}

      {stats && (
        <div className="grid gap-3 sm:grid-cols-3">
          <Card>
            <CardContent className="p-4 text-center">
              <p className="text-2xl font-bold">{stats.wakeup_ready}</p>
              <p className="text-xs text-muted-foreground">活跃记忆</p>
            </CardContent>
          </Card>
          <Card>
            <CardContent className="p-4 text-center">
              <p className="text-2xl font-bold">{stats.pending_review}</p>
              <p className="text-xs text-muted-foreground">待审核</p>
            </CardContent>
          </Card>
          <Card>
            <CardContent className="p-4 text-center">
              <p className="text-2xl font-bold">{stats.total}</p>
              <p className="text-xs text-muted-foreground">总记录</p>
            </CardContent>
          </Card>
        </div>
      )}

      {stats && Object.keys(stats.by_type).length > 0 && (
        <Card>
          <CardContent className="p-4">
            <div className="flex flex-wrap gap-2">
              {Object.entries(stats.by_type)
                .sort(([, a], [, b]) => b - a)
                .map(([type, count]) => (
                  <Badge key={type} variant="secondary" className="text-xs">
                    {type}: {count}
                  </Badge>
                ))}
            </div>
          </CardContent>
        </Card>
      )}

      <Card>
        <CardHeader className="pb-3">
          <div className="flex items-center justify-between">
            <div>
              <CardTitle className="text-base">系统状态</CardTitle>
              <CardDescription>
                配置、Vault、会话来源与 MCP 注册情况一览。
              </CardDescription>
            </div>
            <Button size="sm" variant="outline" onClick={load} disabled={loading}>
              {loading ? (
                <Loader2 className="h-3.5 w-3.5 animate-spin" />
              ) : (
                <RefreshCw className="h-3.5 w-3.5" />
              )}
              刷新
            </Button>
          </div>
        </CardHeader>
        <CardContent className="space-y-4">
          {!status && !loading && (
            <p className="text-sm text-muted-foreground">
              填写 config_path 与 cwd 后自动加载。
            </p>
          )}
          {status && (
            <>
              <div className="grid gap-2 sm:grid-cols-2">
                <StatusRow label="config_path 存在" ok={status.config_exists} />
                <StatusRow label="cwd 存在" ok={status.cwd_exists} detail={status.cwd} />
                <StatusRow
                  label="Vault 可访问"
                  ok={status.vault_available}
                  detail={status.vault_root ?? undefined}
                />
                <StatusRow
                  label="会话来源就绪"
                  ok={status.session_sources_available}
                />
                <StatusRow
                  label="MCP 配置文件检测"
                  ok={status.mcp_config_detected}
                />
                <StatusRow
                  label="Claude 注册 Spool MCP"
                  ok={status.claude_mcp_registered}
                />
                <StatusRow
                  label="Codex 注册 Spool MCP"
                  ok={status.codex_mcp_registered}
                />
              </div>
              <Separator />
              <div className="space-y-3">
                <div>
                  <p className="text-xs font-medium text-muted-foreground">
                    spool-mcp 可执行路径建议
                  </p>
                  <pre className="mt-1 overflow-auto rounded-md border bg-muted/30 p-2 text-xs">
                    {status.spool_mcp_command}
                  </pre>
                </div>
                <div>
                  <p className="text-xs font-medium text-muted-foreground">
                    Claude MCP 配置片段
                  </p>
                  <pre className="mt-1 overflow-auto rounded-md border bg-muted/30 p-2 text-xs">
                    {status.claude_mcp_snippet}
                  </pre>
                </div>
                <div>
                  <p className="text-xs font-medium text-muted-foreground">
                    Codex MCP 配置片段
                  </p>
                  <pre className="mt-1 overflow-auto rounded-md border bg-muted/30 p-2 text-xs">
                    {status.codex_mcp_snippet}
                  </pre>
                </div>
              </div>
              {status.recent_enhancement && (
                <>
                  <Separator />
                  <div>
                    <p className="text-xs font-medium text-muted-foreground">
                      最近一次 Prompt 优化 trace
                    </p>
                    <pre className="mt-1 max-h-[300px] overflow-auto rounded-md border bg-muted/30 p-2 text-xs">
                      {JSON.stringify(status.recent_enhancement, null, 2)}
                    </pre>
                  </div>
                </>
              )}
            </>
          )}
        </CardContent>
      </Card>
    </div>
  )
}

function StatusRow({
  label,
  ok,
  detail,
}: {
  label: string
  ok: boolean
  detail?: string
}) {
  return (
    <div className="flex items-start gap-2 rounded-md border bg-muted/10 px-3 py-2">
      {ok ? (
        <CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
      ) : (
        <XCircle className="mt-0.5 h-4 w-4 text-destructive" />
      )}
      <div className="min-w-0 flex-1">
        <p className="text-sm">{label}</p>
        {detail && (
          <p className="truncate text-xs text-muted-foreground" title={detail}>
            {detail}
          </p>
        )}
      </div>
      <Badge variant={ok ? 'default' : 'muted'} className="shrink-0 text-[10px]">
        {ok ? 'OK' : 'N/A'}
      </Badge>
    </div>
  )
}