import * as React from 'react'
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { useConfig } from '@/state/config'
import { ipc } from '@/lib/api/desktop'
import { asEnvelope } from '@/lib/error'
import { qk } from '@/lib/queryKeys'
import type {
DesktopErrorEnvelope,
DesktopImportSessionResponse,
DesktopSessionDetailResponse,
DesktopSessionItem,
DesktopSessionMessage,
ImportCandidateDto,
} 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 {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ErrorBanner } from '@/components/error-banner'
import {
Download,
Filter,
Loader2,
MessageSquare,
Play,
Sparkles,
Trash2,
} from 'lucide-react'
const PER_PAGE = 20
const MESSAGE_PAGE_SIZE = 50
const PROVIDER_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'claude', label: 'Claude Code' },
{ value: 'codex', label: 'Codex' },
{ value: 'gemini', label: 'Gemini CLI' },
] as const
export function SessionsPage({ onImported }: { onImported?: () => void } = {}) {
const cfg = useConfig()
const qc = useQueryClient()
const configPath = cfg.configPath
const [providerFilter, setProviderFilter] = React.useState('')
const [selectedId, setSelectedId] = React.useState<string | null>(null)
const [preview, setPreview] = React.useState<DesktopImportSessionResponse | null>(null)
const [actionError, setActionError] = React.useState<DesktopErrorEnvelope | null>(null)
// Local detail state to support appending paginated messages without abandoning
// the cached query data.
const [detailMessages, setDetailMessages] = React.useState<DesktopSessionMessage[]>([])
const [detailMessagesShown, setDetailMessagesShown] = React.useState(0)
const [detailHasMore, setDetailHasMore] = React.useState(false)
const listSentinelRef = React.useRef<HTMLDivElement>(null)
const messageSentinelRef = React.useRef<HTMLDivElement>(null)
const sessionsQuery = useInfiniteQuery({
queryKey: qk.sessionsList(configPath, providerFilter),
queryFn: ({ pageParam }) =>
ipc.browseSessions({
config_path: configPath,
page: pageParam as number,
per_page: PER_PAGE,
provider: providerFilter || null,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.has_more ? lastPage.page + 1 : undefined,
enabled: configPath.trim().length > 0,
})
const allSessions: DesktopSessionItem[] = React.useMemo(
() => sessionsQuery.data?.pages.flatMap((p) => p.sessions) ?? [],
[sessionsQuery.data]
)
const totalCount = sessionsQuery.data?.pages[0]?.total ?? 0
const hasMore = sessionsQuery.hasNextPage
const loadingList = sessionsQuery.isFetching
const sessionDetailQuery = useQuery({
queryKey: selectedId
? qk.sessionDetail(configPath, selectedId)
: ['sessionDetail', '__none__'],
queryFn: () =>
ipc.getSession({
config_path: configPath,
session_id: selectedId as string,
message_offset: null,
message_limit: MESSAGE_PAGE_SIZE,
}),
enabled: Boolean(selectedId) && configPath.trim().length > 0,
})
// Sync local pagination state when initial detail loads / changes.
React.useEffect(() => {
const data = sessionDetailQuery.data
if (!data) {
setDetailMessages([])
setDetailMessagesShown(0)
setDetailHasMore(false)
return
}
setDetailMessages(data.messages)
setDetailMessagesShown(data.showing_recent_messages)
setDetailHasMore(data.has_more_messages)
}, [sessionDetailQuery.data])
const loadMoreMessagesMutation = useMutation({
mutationFn: async () => {
if (!selectedId) throw new Error('no session selected')
return ipc.getSession({
config_path: configPath,
session_id: selectedId,
message_offset: detailMessages.length,
message_limit: MESSAGE_PAGE_SIZE,
})
},
onSuccess: (res) => {
if (!res) return
setDetailMessages((prev) => [...prev, ...res.messages])
setDetailMessagesShown((prev) => prev + res.messages.length)
setDetailHasMore(res.has_more_messages)
},
})
const continueMutation = useMutation({
mutationFn: () =>
ipc.continueSession({ config_path: configPath, session_id: selectedId as string }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.sessionsList(configPath, providerFilter) })
},
})
const deleteMutation = useMutation({
mutationFn: () =>
ipc.deleteSession({ config_path: configPath, session_id: selectedId as string }),
onSuccess: () => {
setSelectedId(null)
qc.invalidateQueries({ queryKey: qk.sessionsList(configPath, providerFilter) })
},
})
const injectMutation = useMutation({
mutationFn: async () => {
const session = sessionDetailQuery.data?.session
if (!session) throw new Error('no session detail')
const cwd = session.cwd || ''
const res = await ipc.runContext({
config_path: configPath,
vault_root_override: cfg.vaultRoot.trim() || null,
cwd,
task: session.title || 'session context',
files: [],
target: 'claude' as never,
format: 'prompt' as never,
})
if (res.rendered && navigator.clipboard) {
await navigator.clipboard.writeText(res.rendered)
}
return res
},
})
const previewImportMutation = useMutation({
mutationFn: async () => {
const detail = sessionDetailQuery.data
if (!selectedId || !detail) throw new Error('no session selected')
return ipc.importSession({
config_path: configPath,
provider: detail.session.provider as 'claude' | 'codex',
session_id: selectedId,
apply: false,
actor: 'desktop-ui',
})
},
onSuccess: (res) => {
setPreview(res)
},
})
const confirmImportMutation = useMutation({
mutationFn: async () => {
const detail = sessionDetailQuery.data
if (!selectedId || !detail || !preview) throw new Error('no preview')
return ipc.importSession({
config_path: configPath,
provider: detail.session.provider as 'claude' | 'codex',
session_id: selectedId,
apply: true,
actor: 'desktop-ui',
})
},
onSuccess: () => {
setPreview(null)
qc.invalidateQueries({ queryKey: qk.workbench(configPath) })
onImported?.()
},
})
// IntersectionObserver for session list infinite scroll
React.useEffect(() => {
const sentinel = listSentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingList) {
void sessionsQuery.fetchNextPage()
}
},
{ threshold: 0.1 }
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasMore, loadingList, sessionsQuery])
function handleProviderChange(value: string) {
setProviderFilter(value)
}
const acting: 'continue' | 'delete' | 'import' | 'inject' | null =
continueMutation.isPending
? 'continue'
: deleteMutation.isPending
? 'delete'
: previewImportMutation.isPending
? 'import'
: injectMutation.isPending
? 'inject'
: null
const queryError =
sessionsQuery.error ??
sessionDetailQuery.error ??
continueMutation.error ??
deleteMutation.error ??
injectMutation.error ??
previewImportMutation.error ??
confirmImportMutation.error ??
loadMoreMessagesMutation.error ??
null
const errorEnvelope = actionError ?? (queryError ? asEnvelope(queryError) : null)
React.useEffect(() => {
setActionError(null)
}, [selectedId])
const detail = sessionDetailQuery.data ?? null
const loadingDetail = sessionDetailQuery.isFetching
const loadingMore = loadMoreMessagesMutation.isPending
const applying = confirmImportMutation.isPending
return (
<div className="grid gap-4 lg:grid-cols-[400px_1fr]">
<Card className="h-[calc(100vh-13rem)] overflow-hidden flex flex-col">
<CardHeader className="pb-3 shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
会话列表
{loadingList && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
)}
</CardTitle>
</div>
<CardDescription>
{totalCount > 0
? `共 ${totalCount} 条 · 已加载 ${allSessions.length} 条`
: loadingList
? '加载中...'
: '暂无会话'}
</CardDescription>
<div className="flex items-center gap-1 pt-1">
<Filter className="h-3 w-3 text-muted-foreground" />
<select
className="h-7 rounded-md border bg-background px-2 text-xs"
value={providerFilter}
onChange={(e) => handleProviderChange(e.target.value)}
aria-label="Provider filter"
>
{PROVIDER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</CardHeader>
<CardContent className="overflow-y-auto px-3 pb-3 flex-1">
{allSessions.length === 0 && !loadingList && (
<div className="px-3 py-8 text-center">
<p className="text-sm text-muted-foreground">暂无会话</p>
<p className="mt-2 text-xs text-muted-foreground">
在 AI 客户端开启会话后会自动出现
</p>
</div>
)}
<div className="space-y-1">
{allSessions.map((s) => (
<SessionRow
key={`${s.provider}:${s.session_id}`}
item={s}
selected={s.session_id === selectedId}
onSelect={() => setSelectedId(s.session_id)}
/>
))}
</div>
{hasMore && (
<div ref={listSentinelRef} className="flex justify-center py-3">
{loadingList && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
)}
</CardContent>
</Card>
<div className="space-y-4">
{errorEnvelope && <ErrorBanner envelope={errorEnvelope} />}
{!selectedId && !errorEnvelope && (
<Card>
<CardContent className="p-10 text-center text-sm text-muted-foreground">
选择左侧的会话查看对话记录与关联的记忆记录。
</CardContent>
</Card>
)}
{selectedId && detail && (
<SessionDetail
detail={detail}
messages={detailMessages}
messagesShown={detailMessagesShown}
hasMoreMessages={detailHasMore}
loading={loadingDetail}
loadingMore={loadingMore}
acting={acting}
onContinue={() => continueMutation.mutate()}
onDelete={() => deleteMutation.mutate()}
onImport={() => previewImportMutation.mutate()}
onInjectContext={() => injectMutation.mutate()}
onLoadMore={() => loadMoreMessagesMutation.mutate()}
messageSentinelRef={messageSentinelRef}
/>
)}
</div>
<Dialog
open={preview !== null}
onOpenChange={(open) => {
if (!open) setPreview(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>导入预览 · {preview?.session_ref}</DialogTitle>
<DialogDescription>
扫描 {preview?.total_messages} 条消息,识别 {preview?.candidate_count} 条候选记忆。
确认后以 candidate 状态写入 ledger,需在"记忆审核"tab 再做接受/归档。
</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] space-y-2 overflow-y-auto">
{preview?.candidates.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">
未识别到候选。可以关闭本窗口换条会话再试。
</p>
)}
{preview?.candidates.map((c, idx) => (
<CandidateRow key={`${c.source_ref}-${idx}`} candidate={c} />
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={applying}>
取消
</Button>
</DialogClose>
<Button
onClick={() => confirmImportMutation.mutate()}
disabled={applying || !preview || preview.candidate_count === 0}
>
{applying && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
确认导入 {preview?.candidate_count ?? 0} 条候选
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function CandidateRow({ candidate }: { candidate: ImportCandidateDto }) {
return (
<div className="rounded-md border px-3 py-2 text-sm">
<div className="mb-1 flex items-center gap-2 text-xs">
<Badge variant="default">{candidate.signal}</Badge>
<Badge variant="outline">{candidate.memory_type}</Badge>
<Badge variant="muted">{candidate.scope}</Badge>
</div>
<p className="font-medium">{candidate.title}</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground line-clamp-3">
{candidate.summary}
</p>
</div>
)
}
function SessionRow({
item,
selected,
onSelect,
}: {
item: DesktopSessionItem
selected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
className={`w-full rounded-md border px-3 py-2 text-left transition-colors ${
selected ? 'border-primary bg-accent' : 'border-transparent hover:bg-accent/60'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-sm font-medium">{item.title}</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{item.provider}
</Badge>
</div>
{item.summary && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{item.summary}</p>
)}
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3" />
<span>{item.record_count} 条记忆</span>
{item.pending_review_count > 0 && (
<Badge variant="secondary" className="text-[10px]">
待审 {item.pending_review_count}
</Badge>
)}
<span className="ml-auto truncate">{item.updated_at.slice(0, 19)}</span>
</div>
{item.project_path && (
<p className="mt-0.5 truncate text-[10px] text-muted-foreground/70 font-mono">
{item.project_path}
</p>
)}
</button>
)
}
function SessionDetail({
detail,
messages,
messagesShown,
hasMoreMessages,
loading,
loadingMore,
acting,
onContinue,
onDelete,
onImport,
onInjectContext,
onLoadMore,
messageSentinelRef,
}: {
detail: DesktopSessionDetailResponse
messages: DesktopSessionMessage[]
messagesShown: number
hasMoreMessages: boolean
loading: boolean
loadingMore: boolean
acting: 'continue' | 'delete' | 'import' | 'inject' | null
onContinue: () => void
onDelete: () => void
onImport: () => void
onInjectContext: () => void
onLoadMore: () => void
messageSentinelRef: React.RefObject<HTMLDivElement | null>
}) {
const { session } = detail
// Auto-load more messages when sentinel is visible
React.useEffect(() => {
const sentinel = messageSentinelRef.current
if (!sentinel || !hasMoreMessages) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMoreMessages && !loadingMore) {
onLoadMore()
}
},
{ threshold: 0.1 }
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasMoreMessages, loadingMore, onLoadMore, messageSentinelRef])
return (
<>
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{session.title}</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-2 pt-1 text-xs">
<Badge variant="outline">{session.provider}</Badge>
<span>{session.session_id}</span>
<span className="text-muted-foreground">{session.updated_at}</span>
</CardDescription>
</div>
{loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
</CardHeader>
<CardContent className="space-y-3">
{session.summary && (
<p className="text-sm leading-relaxed text-muted-foreground">{session.summary}</p>
)}
{session.cwd && (
<p className="text-xs">
cwd: <span className="font-mono">{session.cwd}</span>
</p>
)}
{session.project_path && session.project_path !== session.cwd && (
<p className="text-xs">
project: <span className="font-mono">{session.project_path}</span>
</p>
)}
{session.source_path && (
<p className="text-xs">
source: <span className="font-mono">{session.source_path}</span>
</p>
)}
<Separator />
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={onContinue} disabled={acting !== null}>
{acting === 'continue' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
继续会话
</Button>
<Button size="sm" variant="outline" onClick={onInjectContext} disabled={acting !== null}>
{acting === 'inject' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
生成上下文
</Button>
<Button
size="sm"
variant="secondary"
onClick={onImport}
disabled={acting !== null}
>
{acting === 'import' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
导入为候选
</Button>
<Button
size="sm"
variant="destructive"
onClick={onDelete}
disabled={acting !== null}
>
{acting === 'delete' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
删除
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">消息</CardTitle>
<CardDescription>
共 {detail.total_messages} 条 · 已加载 {messagesShown} 条
{hasMoreMessages && '(滚动加载更多)'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{messages.map((m, idx) => (
<MessageCard key={idx} message={m} />
))}
{hasMoreMessages && (
<div ref={messageSentinelRef as React.LegacyRef<HTMLDivElement>} className="flex justify-center py-3">
{loadingMore && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
)}
</CardContent>
</Card>
</>
)
}
function MessageCard({ message }: { message: DesktopSessionMessage }) {
const isUser = message.role === 'user'
return (
<div
className={`rounded-md border px-3 py-2 text-sm ${
isUser ? 'bg-accent/40' : 'bg-muted/20'
}`}
>
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant={isUser ? 'default' : 'outline'}>{message.role}</Badge>
<span>{message.timestamp}</span>
{message.truncated && <Badge variant="muted">truncated</Badge>}
</div>
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
</div>
)
}