Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

398 lines
16 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Check,
History as HistoryIcon,
Loader2,
MessageSquare,
Search,
Sparkles,
UserRound,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import PickerShell from "@/components/common/PickerShell";
import PickerHeader from "@/components/common/PickerHeader";
import {
getSession,
listSessions,
type SessionDetail,
type SessionSummary,
} from "@/lib/session-api";
import { normalizeMessageContent, truncateText } from "@/lib/message-content";
export interface SelectedHistorySession {
sessionId: string;
title: string;
}
interface HistorySessionPickerProps {
open: boolean;
onClose: () => void;
onApply: (sessions: SelectedHistorySession[]) => void;
}
/**
* Format a backend session timestamp (stored as float seconds via time.time())
* into a localized string. Returns an empty string when the timestamp is
* missing or non-positive so we don't render nonsensical 1970 dates.
*/
function formatSessionTimestamp(value?: number): string {
if (!value || value <= 0) return "";
// Backend stores REAL seconds. JS Date expects milliseconds.
return new Date(value * 1000).toLocaleString();
}
function sessionKey(session: SessionSummary): string {
return session.session_id || session.id;
}
export default function HistorySessionPicker({
open,
onClose,
onApply,
}: HistorySessionPickerProps) {
const { t } = useTranslation();
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
// The session shown in the right-hand preview pane. Driven by hover/focus
// and click so the preview tracks wherever the user's attention is — the
// list reads like a mail client: glide over a row, see its conversation.
const [activeId, setActiveId] = useState<string | null>(null);
// Fetched session transcripts, cached so re-hovering a row is instant.
const [details, setDetails] = useState<Record<string, SessionDetail>>({});
const detailsRef = useRef(details);
useEffect(() => {
detailsRef.current = details;
}, [details]);
const [previewLoadingId, setPreviewLoadingId] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let mounted = true;
const load = async () => {
setLoading(true);
try {
const data = await listSessions(200, 0, { force: true });
if (!mounted) return;
setSessions(data);
// Default the preview to the most recent session so the right pane is
// never blank on open.
setActiveId((prev) => {
if (prev && data.some((s) => sessionKey(s) === prev)) return prev;
return data.length ? sessionKey(data[0]) : null;
});
} catch {
if (!mounted) return;
setSessions([]);
} finally {
if (mounted) setLoading(false);
}
};
void load();
return () => {
mounted = false;
};
}, [open]);
// Lazily fetch the active session's transcript for the preview pane.
useEffect(() => {
if (!open || !activeId) return;
if (detailsRef.current[activeId]) return;
let cancelled = false;
setPreviewLoadingId(activeId);
getSession(activeId)
.then((detail) => {
if (cancelled) return;
setDetails((prev) => ({ ...prev, [activeId]: detail }));
})
.catch(() => {
/* preview is best-effort; the list still works without it */
})
.finally(() => {
if (cancelled) return;
setPreviewLoadingId((cur) => (cur === activeId ? null : cur));
});
return () => {
cancelled = true;
};
}, [activeId, open]);
const filteredSessions = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return sessions;
return sessions.filter((session) => {
const title = String(session.title || "").toLowerCase();
const lastMessage = normalizeMessageContent(
session.last_message,
).toLowerCase();
return title.includes(keyword) || lastMessage.includes(keyword);
});
}, [query, sessions]);
const toggleSession = (id: string) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
);
};
const handleApply = () => {
const selected = sessions
.filter((session) => selectedIds.includes(sessionKey(session)))
.map((session) => ({
sessionId: sessionKey(session),
title: session.title || t("Untitled session"),
}));
onApply(selected);
onClose();
};
const activeSession = activeId
? sessions.find((s) => sessionKey(s) === activeId)
: undefined;
const activeDetail = activeId ? details[activeId] : undefined;
const activeLoading =
previewLoadingId !== null && previewLoadingId === activeId;
return (
<PickerShell
open={open}
onClose={onClose}
labelledBy="history-picker-title"
className="p-4 backdrop-blur-md"
backdropClass="bg-[var(--background)]/65"
>
<div className="surface-card flex h-[78vh] max-h-[660px] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-[0_22px_70px_rgba(0,0,0,0.18)]">
<PickerHeader
icon={HistoryIcon}
titleId="history-picker-title"
title={t("Select History Sessions")}
subtitle={t(
"Choose one or more past conversations to analyze before this turn.",
)}
onClose={onClose}
/>
<div className="flex min-h-0 flex-1">
{/* ── Left: searchable, selectable session list ── */}
<div className="flex w-full min-w-0 flex-col border-r border-[var(--border)] md:w-[42%] md:max-w-[380px]">
<div className="flex items-center gap-2 px-4 pb-3 pt-4">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted-foreground)]" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("Search sessions by title or last message")}
className="w-full rounded-xl border border-[var(--border)] bg-[var(--card)] py-2.5 pl-9 pr-3 text-[13px] text-[var(--foreground)] outline-none transition focus:border-[var(--primary)]/50 focus:ring-2 focus:ring-[var(--primary)]/15"
/>
</div>
{selectedIds.length > 0 && (
<button
onClick={() => setSelectedIds([])}
className="shrink-0 rounded-xl border border-[var(--border)] bg-[var(--card)] px-3 py-2.5 text-[12px] font-medium text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
{t("Clear")}
</button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{loading ? (
<div className="flex min-h-[280px] items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-[var(--muted-foreground)]" />
</div>
) : filteredSessions.length ? (
<div className="flex flex-col gap-0.5">
{filteredSessions.map((session) => {
const id = sessionKey(session);
const selected = selectedIds.includes(id);
const active = id === activeId;
return (
<button
key={id}
onClick={() => {
toggleSession(id);
setActiveId(id);
}}
onMouseEnter={() => setActiveId(id)}
onFocus={() => setActiveId(id)}
className={`group relative flex w-full items-start gap-2.5 rounded-xl px-2.5 py-2.5 text-left transition-colors ${
active
? "bg-[var(--muted)]/60"
: selected
? "bg-[var(--primary)]/[0.05] hover:bg-[var(--muted)]/40"
: "hover:bg-[var(--muted)]/35"
}`}
>
{/* Accent rail marks the previewed row */}
<span
className={`absolute inset-y-1.5 left-0 w-[2.5px] rounded-full bg-[var(--primary)] transition-opacity ${
active ? "opacity-100" : "opacity-0"
}`}
/>
<span
className={`mt-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[6px] border transition-colors ${
selected
? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]"
: "border-[var(--border)] text-transparent group-hover:border-[var(--muted-foreground)]/60"
}`}
>
<Check size={11} strokeWidth={3} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--foreground)]">
{session.title || t("Untitled session")}
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-[var(--muted-foreground)]/85">
<MessageSquare size={11} strokeWidth={1.8} />
{session.message_count ?? 0} {t("messages")}
</span>
</span>
</button>
);
})}
</div>
) : (
<div className="px-6 py-14 text-center text-[13px] text-[var(--muted-foreground)]">
{t("No matching sessions found.")}
</div>
)}
</div>
</div>
{/* ── Right: live preview of the focused session ── */}
<div className="hidden min-w-0 flex-1 flex-col bg-[var(--background)]/30 md:flex">
{activeSession ? (
<>
<div className="flex items-start justify-between gap-3 border-b border-[var(--border)]/70 px-5 py-3.5">
<div className="min-w-0">
<div className="truncate text-[14px] font-semibold text-[var(--foreground)]">
{activeSession.title || t("Untitled session")}
</div>
<div className="mt-0.5 flex items-center gap-2.5 text-[11px] text-[var(--muted-foreground)]">
<span>
{activeSession.message_count ?? 0} {t("messages")}
</span>
{formatSessionTimestamp(
activeSession.updated_at || activeSession.created_at,
) && (
<span>
{formatSessionTimestamp(
activeSession.updated_at ||
activeSession.created_at,
)}
</span>
)}
</div>
</div>
{selectedIds.includes(activeId!) && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-[var(--primary)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--primary)]">
<Check size={10} strokeWidth={3} />
{t("Selected")}
</span>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{activeLoading && !activeDetail ? (
<div className="flex min-h-[200px] items-center justify-center gap-2 text-[12px] text-[var(--muted-foreground)]">
<Loader2 className="h-4 w-4 animate-spin" />
{t("Loading preview…")}
</div>
) : activeDetail ? (
<ConversationPreview detail={activeDetail} />
) : (
<div className="flex min-h-[200px] items-center justify-center text-[12px] text-[var(--muted-foreground)]">
{t("No messages in this session.")}
</div>
)}
</div>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 px-8 text-center text-[var(--muted-foreground)]">
<HistoryIcon className="h-6 w-6 opacity-40" strokeWidth={1.6} />
<p className="text-[12px]">
{t("Select a session to preview it here.")}
</p>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-[var(--border)] px-5 py-3.5">
<div className="text-[12px] text-[var(--muted-foreground)]">
{selectedIds.length === 1
? t("1 session selected")
: t("{{n}} sessions selected", { n: selectedIds.length })}
</div>
<button
onClick={handleApply}
disabled={!selectedIds.length}
className="btn-primary rounded-xl bg-[var(--primary)] px-4 py-2.5 text-[13px] font-medium text-[var(--primary-foreground)] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("Use Selected Sessions ({{n}})", { n: selectedIds.length })}
</button>
</div>
</div>
</PickerShell>
);
}
/**
* Read-only transcript rendering for the preview pane. We render the stored
* final `content` of each message (not the streaming trace) as plain,
* role-labelled text — enough to recognize a conversation at a glance without
* pulling in the full markdown/KaTeX renderer.
*/
function ConversationPreview({ detail }: { detail: SessionDetail }) {
const { t } = useTranslation();
const turns = useMemo(
() =>
(detail.messages || [])
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({
id: m.id,
role: m.role,
text: truncateText(normalizeMessageContent(m.content), 1400),
}))
.filter((m) => m.text.trim().length > 0),
[detail.messages],
);
if (turns.length === 0) {
return (
<div className="flex min-h-[200px] items-center justify-center text-[12px] text-[var(--muted-foreground)]">
{t("No messages in this session.")}
</div>
);
}
return (
<div className="space-y-4">
{turns.map((turn) => {
const isUser = turn.role === "user";
return (
<div key={turn.id}>
<div className="mb-1 flex items-center gap-1.5 text-[11px] font-medium text-[var(--muted-foreground)]">
{isUser ? (
<UserRound size={12} strokeWidth={1.9} />
) : (
<Sparkles size={12} strokeWidth={1.9} />
)}
{isUser ? t("You") : t("Assistant")}
</div>
<div className="whitespace-pre-wrap break-words text-[12.5px] leading-relaxed text-[var(--foreground)]/85">
{turn.text}
</div>
</div>
);
})}
</div>
);
}