import React from 'react'
import ReactDOM from 'react-dom/client'
import {
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { invoke as tauriInvoke } from '@tauri-apps/api/core'
import { queryClient } from '@/lib/queryClient'
import { bindMemorySync } from '@/lib/syncEvents'
import { ipc } from '@/lib/api/desktop'
import { qk } from '@/lib/queryKeys'
import { ConfigProvider, useConfig, buildDaemonRequest } from '@/state/config'
import { I18nProvider, useI18n } from '@/state/i18n'
import { buildMetadataFromFields } from '@/lib/drafts'
import { entrySummaryLine, stateLabel, stateVariant } from '@/lib/lifecycle-format'
import { asEnvelope } from '@/lib/error'
import {
ActionConfirmDialog,
type ActionConfirmMetadata,
} from '@/components/action-confirm-dialog'
import {
CommandPalette,
useCommandPaletteShortcut,
} from '@/components/command-palette'
import type {
DesktopLifecycleAction,
DesktopMemoryDraftRequest,
} from '@/lib/types/desktop'
import type { LedgerEntry } from '@/lib/types/lifecycle'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Archive,
ArrowRight,
CheckCircle2,
Inbox,
Loader2,
PanelRightOpen,
Plus,
Search,
Sparkles,
X,
Zap,
} from 'lucide-react'
import '@/styles/globals.css'
type QueueTab = 'pending' | 'wakeup_ready'
const MAX_VISIBLE = 50
function PopoverApp() {
const cfg = useConfig()
const { t } = useI18n()
const qc = useQueryClient()
const [tab, setTab] = React.useState<QueueTab>('pending')
const [search, setSearch] = React.useState('')
const [selectedId, setSelectedId] = React.useState<string | null>(null)
const [quickOpen, setQuickOpen] = React.useState(false)
const [quickTitle, setQuickTitle] = React.useState('')
const [quickSummary, setQuickSummary] = React.useState('')
const [errorText, setErrorText] = React.useState<string | null>(null)
const [pendingAction, setPendingAction] = React.useState<DesktopLifecycleAction | null>(null)
const [pendingTarget, setPendingTarget] = React.useState<{
recordId: string
title: string
} | null>(null)
const [paletteOpen, setPaletteOpen] = React.useState(false)
const searchRef = React.useRef<HTMLInputElement>(null)
useCommandPaletteShortcut(setPaletteOpen)
const daemon = React.useMemo(() => buildDaemonRequest(cfg), [cfg])
const configPath = cfg.configPath
const workbenchQuery = useQuery({
queryKey: qk.workbench(configPath),
queryFn: () =>
ipc.loadWorkbench({
config_path: configPath,
daemon,
}),
enabled: configPath.trim().length > 0,
})
const actionMutation = useMutation({
mutationFn: async (input: {
recordId: string
action: DesktopLifecycleAction
metadata: ActionConfirmMetadata
}) => {
await ipc.applyMemoryAction({
config_path: configPath,
record_id: input.recordId,
action: input.action,
metadata: {
actor: input.metadata.actor || null,
reason: input.metadata.reason || null,
evidence_refs: input.metadata.evidence_refs,
},
})
return input
},
onSuccess: ({ recordId }) => {
qc.invalidateQueries({ queryKey: qk.workbench(configPath) })
qc.invalidateQueries({ queryKey: qk.record(configPath, recordId) })
qc.invalidateQueries({ queryKey: qk.history(configPath, recordId) })
if (selectedId === recordId) setSelectedId(null)
setErrorText(null)
},
onError: (err) => {
setErrorText(asEnvelope(err).message ?? '操作失败')
},
})
const quickMutation = useMutation({
mutationFn: async (input: { title: string; summary: string }) => {
const payload: DesktopMemoryDraftRequest = {
config_path: configPath,
title: input.title,
summary: input.summary,
memory_type: 'preference',
scope: 'user',
source_ref: 'quick:popover',
project_id: null,
user_id: null,
sensitivity: null,
metadata: buildMetadataFromFields(cfg.defaultActor, '', ''),
entities: [],
tags: [],
triggers: [],
related_files: [],
related_records: [],
supersedes: null,
applies_to: [],
valid_until: null,
}
return ipc.recordManual(payload)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.workbench(configPath) })
setQuickTitle('')
setQuickSummary('')
setQuickOpen(false)
setErrorText(null)
},
onError: (err) => {
setErrorText(asEnvelope(err).message ?? '快记失败')
},
})
React.useEffect(() => {
const syncUnlistenPromise = bindMemorySync(qc)
return () => {
void syncUnlistenPromise.then((unlisten) => unlisten?.())
}
}, [qc])
React.useEffect(() => {
function onKey(event: KeyboardEvent) {
if (pendingAction) return
if (quickOpen) return
const target = event.target
const isEditing =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
if (isEditing) return
if (event.key === '/' && !event.metaKey && !event.ctrlKey && !event.altKey) {
event.preventDefault()
searchRef.current?.focus()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [pendingAction, quickOpen])
const snapshot = workbenchQuery.data ?? null
const rawEntries =
snapshot?.snapshot[tab === 'pending' ? 'pending_review' : 'wakeup_ready'] ?? []
const visibleEntries = React.useMemo(() => {
const q = search.trim().toLowerCase()
let list = rawEntries
if (q) {
list = list.filter(
(e) =>
e.record.title.toLowerCase().includes(q) ||
e.record.summary.toLowerCase().includes(q),
)
}
if (cfg.currentProjectId) {
list = list.filter((e) => e.record.project_id === cfg.currentProjectId)
}
return list.slice(0, MAX_VISIBLE)
}, [rawEntries, search, cfg.currentProjectId])
const pendingCount = snapshot?.snapshot.pending_review.length ?? 0
const readyCount = snapshot?.snapshot.wakeup_ready.length ?? 0
const actionLoading: { id: string; action: DesktopLifecycleAction } | null =
actionMutation.isPending && actionMutation.variables
? {
id: actionMutation.variables.recordId,
action: actionMutation.variables.action,
}
: null
const handleOpenMain = React.useCallback(() => {
void tauriInvoke('show_main_window').catch(() => undefined)
}, [])
const commandCtx = React.useMemo(
() => ({
onCreateManual: handleOpenMain,
onCreateProposal: handleOpenMain,
onImportSession: handleOpenMain,
onNavigateInbox: handleOpenMain,
onNavigateSessions: handleOpenMain,
onNavigateIndex: handleOpenMain,
onNavigateLint: handleOpenMain,
onNavigateSettings: handleOpenMain,
onOpenPopover: () => undefined,
onOpenMain: handleOpenMain,
}),
[handleOpenMain],
)
const requestAction = React.useCallback(
(entry: LedgerEntry, action: DesktopLifecycleAction) => {
setPendingAction(action)
setPendingTarget({ recordId: entry.record_id, title: entry.record.title })
},
[],
)
function closeDialog() {
setPendingAction(null)
setPendingTarget(null)
}
function handleConfirm(metadata: ActionConfirmMetadata) {
if (!pendingAction || !pendingTarget) return
actionMutation.mutate(
{
recordId: pendingTarget.recordId,
action: pendingAction,
metadata,
},
{ onSuccess: () => closeDialog() },
)
}
if (cfg.ready && !cfg.configPath.trim()) {
return (
<div className="flex h-screen items-center justify-center bg-bg-deep p-5">
<div className="animate-scale-in text-center space-y-3">
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<p className="text-[11px] text-muted-foreground">{t('popover.config.prompt')}</p>
<Button
size="sm"
className="gap-1 h-7 text-[11px]"
onClick={() => tauriInvoke('show_main_window').catch(() => undefined)}
>
{t('popover.config.open')}
<ArrowRight className="h-3 w-3" />
</Button>
</div>
</div>
)
}
return (
<div className="flex h-screen w-screen flex-col bg-bg-deep text-foreground">
{/* Header */}
<header className="flex h-10 shrink-0 items-center gap-2 border-b border-border-subtle px-2.5">
<div className="relative flex flex-1 items-center">
<Search className="pointer-events-none absolute left-2 h-3 w-3 text-muted-foreground" />
<input
ref={searchRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('popover.search.placeholder')}
className="h-7 w-full rounded-md bg-bg-elevated pl-7 pr-2.5 text-[11px] text-foreground placeholder:text-muted-foreground/60 outline-none transition-shadow duration-150 focus:shadow-glow-sm focus:bg-bg-hover"
/>
</div>
<button
onClick={handleOpenMain}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:bg-bg-elevated hover:text-foreground"
title={t('popover.open_main')}
>
<PanelRightOpen className="h-3.5 w-3.5" />
</button>
</header>
{/* Pill segment control */}
<div className="px-2.5 pt-2 pb-1">
<div className="flex h-7 rounded-md bg-bg-elevated p-0.5">
<button
onClick={() => setTab('pending')}
className={`flex flex-1 items-center justify-center gap-1 rounded text-[11px] font-medium transition-all duration-200 ease-out-expo ${
tab === 'pending'
? 'bg-bg-hover text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground/80'
}`}
>
<Inbox className="h-3 w-3" />
{t('popover.tab.pending')}
{pendingCount > 0 && (
<span className={`min-w-[16px] rounded-full px-1 py-0 text-[9px] font-semibold leading-[16px] text-center ${
tab === 'pending' ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'
}`}>
{pendingCount}
</span>
)}
</button>
<button
onClick={() => setTab('wakeup_ready')}
className={`flex flex-1 items-center justify-center gap-1 rounded text-[11px] font-medium transition-all duration-200 ease-out-expo ${
tab === 'wakeup_ready'
? 'bg-bg-hover text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground/80'
}`}
>
<Zap className="h-3 w-3" />
{t('popover.tab.wakeup')}
{readyCount > 0 && (
<span className={`min-w-[16px] rounded-full px-1 py-0 text-[9px] font-semibold leading-[16px] text-center ${
tab === 'wakeup_ready' ? 'bg-success/15 text-success' : 'bg-muted text-muted-foreground'
}`}>
{readyCount}
</span>
)}
</button>
</div>
</div>
{/* Project indicator */}
{cfg.currentProjectId && (
<div className="mx-2.5 mb-1 flex items-center gap-1 rounded bg-bg-elevated px-2 py-0.5 text-[10px]">
<span className="font-medium text-muted-foreground">{t('popover.project.label')}</span>
<span className="flex-1 truncate text-foreground/70">{cfg.currentProjectId}</span>
<button
type="button"
className="text-primary/80 hover:text-primary transition-colors"
onClick={handleOpenMain}
>
{t('popover.project.switch')}
</button>
</div>
)}
{/* Error */}
{errorText && (
<div className="mx-2.5 mb-1 flex items-start gap-2 rounded border border-destructive/30 bg-destructive/8 px-2.5 py-1.5 text-[11px] text-destructive animate-fade-in-up">
<span className="flex-1">{errorText}</span>
<button
type="button"
onClick={() => setErrorText(null)}
className="text-destructive/60 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
)}
{/* List */}
<div className="flex-1 overflow-y-auto px-1.5 pb-1.5">
{workbenchQuery.isLoading && (
<div className="flex items-center justify-center py-8 text-xs text-muted-foreground animate-fade-in-up">
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
{t('popover.loading')}
</div>
)}
{!workbenchQuery.isLoading && visibleEntries.length === 0 && (
<div className="flex flex-col items-center justify-center py-10 animate-fade-in-up">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/50 mb-2.5">
{tab === 'pending' ? (
<Inbox className="h-4 w-4 text-muted-foreground/60" />
) : (
<Zap className="h-4 w-4 text-muted-foreground/60" />
)}
</div>
<p className="text-xs font-medium text-muted-foreground/80">
{search.trim()
? t('popover.empty.search.title')
: tab === 'pending'
? t('popover.empty.pending.title')
: t('popover.empty.wakeup.title')}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/50 max-w-[220px] text-center leading-relaxed">
{search.trim()
? t('popover.empty.search.desc')
: tab === 'pending'
? t('popover.empty.pending.desc')
: t('popover.empty.wakeup.desc')}
</p>
</div>
)}
<ul className="stagger-children space-y-0.5">
{visibleEntries.map((entry) => (
<PopoverItem
key={entry.record_id}
entry={entry}
selected={selectedId === entry.record_id}
busyAction={
actionLoading && actionLoading.id === entry.record_id
? actionLoading.action
: null
}
disabled={actionMutation.isPending}
onSelect={() =>
setSelectedId(
selectedId === entry.record_id ? null : entry.record_id,
)
}
onAccept={() => requestAction(entry, 'accept')}
onArchive={() => requestAction(entry, 'archive')}
/>
))}
</ul>
</div>
{/* Footer — quick capture */}
<footer className="shrink-0 border-t border-border-subtle px-2.5 py-1.5">
{!quickOpen ? (
<button
onClick={() => setQuickOpen(true)}
className="flex h-7 w-full items-center justify-center gap-1 rounded-md border border-dashed border-border-subtle text-[11px] text-muted-foreground transition-all duration-150 hover:border-border-hover hover:bg-bg-elevated hover:text-foreground cursor-pointer"
>
<Plus className="h-3 w-3" />
{t('popover.quick.button')}
</button>
) : (
<div className="space-y-1 animate-fade-in-up">
<input
value={quickTitle}
onChange={(e) => setQuickTitle(e.target.value)}
placeholder={t('popover.quick.title.placeholder')}
className="h-7 w-full rounded bg-bg-elevated px-2 text-[11px] text-foreground placeholder:text-muted-foreground/50 outline-none focus:shadow-glow-sm"
autoFocus
/>
<input
value={quickSummary}
onChange={(e) => setQuickSummary(e.target.value)}
placeholder={t('popover.quick.summary.placeholder')}
className="h-7 w-full rounded bg-bg-elevated px-2 text-[11px] text-foreground placeholder:text-muted-foreground/50 outline-none focus:shadow-glow-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && quickTitle.trim() && quickSummary.trim()) {
quickMutation.mutate({ title: quickTitle.trim(), summary: quickSummary.trim() })
}
}}
/>
<div className="flex gap-1">
<button
className="flex h-6 flex-1 items-center justify-center rounded text-[11px] text-muted-foreground transition-colors hover:bg-bg-elevated hover:text-foreground"
onClick={() => {
setQuickOpen(false)
setQuickTitle('')
setQuickSummary('')
setErrorText(null)
}}
disabled={quickMutation.isPending}
>
{t('popover.quick.cancel')}
</button>
<button
className="flex h-6 flex-1 items-center justify-center gap-1 rounded bg-primary text-[11px] font-medium text-primary-foreground transition-all duration-150 hover:brightness-110 disabled:opacity-40"
disabled={
quickMutation.isPending ||
!quickTitle.trim() ||
!quickSummary.trim()
}
onClick={() =>
quickMutation.mutate({
title: quickTitle.trim(),
summary: quickSummary.trim(),
})
}
>
{quickMutation.isPending && (
<Loader2 className="h-2.5 w-2.5 animate-spin" />
)}
{t('popover.quick.save')}
</button>
</div>
</div>
)}
</footer>
<ActionConfirmDialog
open={pendingAction !== null && pendingTarget !== null}
action={pendingAction}
recordTitle={pendingTarget?.title}
defaultActor={cfg.defaultActor}
loading={actionMutation.isPending}
onConfirm={handleConfirm}
onCancel={closeDialog}
/>
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
ctx={commandCtx}
/>
</div>
)
}
function PopoverItem({
entry,
selected,
busyAction,
disabled,
onSelect,
onAccept,
onArchive,
}: {
entry: LedgerEntry
selected: boolean
busyAction: DesktopLifecycleAction | null
disabled: boolean
onSelect: () => void
onAccept: () => void
onArchive: () => void
}) {
const { t } = useI18n()
return (
<li className="group">
<button
type="button"
onClick={onSelect}
className={`relative block w-full rounded-md px-2 py-1.5 text-left transition-all duration-150 ease-out-expo ${
selected
? 'bg-bg-elevated shadow-glow-sm'
: 'hover:bg-bg-elevated/60'
}`}
>
{/* Left accent bar */}
<div
className={`absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-r transition-all duration-200 ease-out-expo ${
selected ? 'bg-primary opacity-100' : 'bg-primary opacity-0 group-hover:opacity-40'
}`}
/>
<div className="flex items-center gap-1">
<span className="line-clamp-1 flex-1 text-[11px] font-medium text-foreground">
{entrySummaryLine(entry)}
</span>
<Badge
variant={stateVariant[entry.record.state]}
className="shrink-0 text-[9px] bg-primary/10 text-primary border-0 h-4 px-1"
>
{stateLabel[entry.record.state]}
</Badge>
</div>
<p className="mt-0.5 line-clamp-1 text-[10px] text-muted-foreground/70">
{entry.record.summary.split('\n')[0]}
</p>
<div className="mt-0.5 flex items-center gap-1 text-[9px] text-muted-foreground/50">
<span>{entry.record.memory_type}</span>
<span className="opacity-40">·</span>
<span>{entry.record.scope}</span>
{entry.record.project_id && (
<>
<span className="opacity-40">·</span>
<span className="truncate max-w-[70px]">{entry.record.project_id}</span>
</>
)}
</div>
</button>
{selected && (
<div className="mx-2 mt-1 mb-1 flex gap-1 animate-fade-in-up">
<Button
size="sm"
className="h-6 flex-1 gap-1 text-[10px] bg-success hover:bg-success/90 text-success-foreground"
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onAccept()
}}
>
{busyAction === 'accept' ? (
<Loader2 className="h-2.5 w-2.5 animate-spin" />
) : (
<CheckCircle2 className="h-2.5 w-2.5" />
)}
{t('popover.action.accept')}
</Button>
<Button
size="sm"
variant="destructive"
className="h-6 flex-1 gap-1 text-[10px] bg-destructive/15 text-destructive hover:bg-destructive/25 border-0"
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onArchive()
}}
>
{busyAction === 'archive' ? (
<Loader2 className="h-2.5 w-2.5 animate-spin" />
) : (
<Archive className="h-2.5 w-2.5" />
)}
{t('popover.action.archive')}
</Button>
</div>
)}
</li>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<I18nProvider>
<ConfigProvider>
<PopoverApp />
</ConfigProvider>
</I18nProvider>
</QueryClientProvider>
</React.StrictMode>,
)