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

693 lines
22 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
Bot,
Loader2,
MoreHorizontal,
Pencil,
Plus,
RefreshCw,
Search,
SlidersHorizontal,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import SessionList from "@/components/SessionList";
import { agentGlyph } from "@/components/agents/agent-icons";
import SpaceSectionHeader from "@/components/space/SpaceSectionHeader";
import ImportWizard from "@/components/space/ImportWizard";
import ScopeEditorModal from "@/components/space/ScopeEditorModal";
import { useAppShell } from "@/context/AppShellContext";
import { formatRelativeTime } from "@/lib/relative-time";
import {
deleteSession,
updateSessionTitle,
type SessionSummary,
} from "@/lib/session-api";
import { importChatHistory, listImportedSessions } from "@/lib/imports-api";
import {
deleteAgent,
ensureReadPermission,
getAgents,
saveAgent,
type ImportAgent,
} from "@/lib/chat-import/agent-store";
import {
assignSessionsToAgents,
readImportMeta,
} from "@/lib/chat-import/attribution";
import {
filterRefsByScope,
parseSessions,
scanDirectory,
SOURCE_LABEL,
type AgentScope,
type ImportSource,
} from "@/lib/chat-import";
/**
* "My Agents" — each named, scoped slice of an imported `.claude` / `.codex`
* folder is an agent. Conversations are normal sessions, grouped here by the
* agent that owns them (by `agent_id`, falling back to source + scope for
* pre-agent imports). The persisted folder handle lets the user rename, re-scope,
* refresh, or delete an agent without re-picking the folder.
*/
const UNGROUPED_PREFIX = "ungrouped:";
type CardView =
| {
kind: "agent";
key: string;
agent: ImportAgent;
count: number;
latest: number;
}
| {
kind: "ungrouped";
key: string;
source: ImportSource;
count: number;
latest: number;
};
function scopeSummary(
scope: AgentScope,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
if (scope.kind === "projects")
return t("{{count}} projects", { count: scope.cwds.length });
if (scope.kind === "dates")
return t("{{count}} days", { count: scope.days.length });
return t("All conversations");
}
export default function MyAgentsSection() {
const { t, i18n } = useTranslation();
const router = useRouter();
const { activeSessionId, setActiveSessionId } = useAppShell();
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [registry, setRegistry] = useState<ImportAgent[]>([]);
const [loading, setLoading] = useState(true);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [wizardOpen, setWizardOpen] = useState(false);
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const [note, setNote] = useState("");
const [renamingId, setRenamingId] = useState<string | null>(null);
const [scopeAgent, setScopeAgent] = useState<ImportAgent | null>(null);
const load = useCallback(async (force = false) => {
setLoading(true);
try {
const [imported, reg] = await Promise.all([
listImportedSessions(200, 0, { force }),
getAgents(),
]);
setSessions(imported);
setRegistry(reg);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(true);
}, [load]);
const { cards, byCard } = useMemo(() => {
const ordered = [...registry].sort(
(a, b) => (b.lastSyncAt || b.createdAt) - (a.lastSyncAt || a.createdAt),
);
const owner = assignSessionsToAgents(sessions, ordered);
const byCard = new Map<string, SessionSummary[]>();
const stats = new Map<string, { count: number; latest: number }>();
for (const session of sessions) {
const meta = readImportMeta(session);
if (!meta) continue;
const sid = session.session_id || session.id;
const key = owner.get(sid) ?? `${UNGROUPED_PREFIX}${meta.source}`;
const arr = byCard.get(key) ?? [];
arr.push(session);
byCard.set(key, arr);
const st = stats.get(key) ?? { count: 0, latest: 0 };
st.count += 1;
st.latest = Math.max(st.latest, session.updated_at);
stats.set(key, st);
}
const agentCards: CardView[] = ordered.map((agent) => ({
kind: "agent",
key: agent.id,
agent,
count: stats.get(agent.id)?.count ?? 0,
latest: stats.get(agent.id)?.latest ?? 0,
}));
const ungroupedCards: CardView[] = Array.from(stats.entries())
.filter(([key]) => key.startsWith(UNGROUPED_PREFIX))
.map(([key, st]) => ({
kind: "ungrouped",
key,
source: key.slice(UNGROUPED_PREFIX.length) as ImportSource,
count: st.count,
latest: st.latest,
}));
return { cards: [...agentCards, ...ungroupedCards], byCard };
}, [registry, sessions]);
// Keep a valid selection as cards load/change.
useEffect(() => {
if (selectedKey && cards.some((c) => c.key === selectedKey)) return;
setSelectedKey(cards[0]?.key ?? null);
}, [cards, selectedKey]);
const visibleSessions = useMemo(() => {
const list = selectedKey ? (byCard.get(selectedKey) ?? []) : [];
const needle = query.trim().toLowerCase();
if (!needle) return list;
return list.filter((session) =>
[session.title, session.last_message]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(needle)),
);
}, [byCard, selectedKey, query]);
const handleSelect = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
router.push(`/home/${sessionId}`);
},
[router, setActiveSessionId],
);
const handleRenameSession = useCallback(
async (sessionId: string, title: string) => {
await updateSessionTitle(sessionId, title);
await load(true);
},
[load],
);
const handleDeleteSession = useCallback(
async (sessionId: string) => {
if (!window.confirm(t("Delete this conversation?"))) return;
await deleteSession(sessionId);
if (activeSessionId === sessionId) setActiveSessionId(null);
setSessions((prev) =>
prev.filter((session) => session.session_id !== sessionId),
);
},
[activeSessionId, setActiveSessionId, t],
);
const refreshAgent = useCallback(
async (agent: ImportAgent) => {
setRefreshingId(agent.id);
setNote("");
try {
if (!agent.handle || !(await ensureReadPermission(agent.handle))) {
setNote(t("Refresh failed — try re-adding the agent."));
return;
}
const scan = await scanDirectory(agent.handle);
const refs = filterRefsByScope(
scan.projects.flatMap((p) => p.sessions),
agent.scope,
);
const normalized = await parseSessions(scan.source, refs);
const res = await importChatHistory(scan.source, normalized, {
id: agent.id,
name: agent.name,
});
await saveAgent({ ...agent, lastSyncAt: Date.now() });
await load(true);
setNote(
res.imported > 0
? t("Added {{count}} new conversations", { count: res.imported })
: t("Already up to date"),
);
} catch {
setNote(t("Refresh failed — try re-adding the agent."));
} finally {
setRefreshingId(null);
}
},
[load, t],
);
const commitRename = useCallback(
async (agent: ImportAgent, name: string) => {
setRenamingId(null);
const trimmed = name.trim();
if (!trimmed || trimmed === agent.name) return;
await saveAgent({ ...agent, name: trimmed });
await load(true);
},
[load],
);
const removeAgent = useCallback(
async (agent: ImportAgent) => {
const owned = byCard.get(agent.id) ?? [];
const message = owned.length
? t(
"Delete “{{name}}” and its {{count}} conversations? This removes them from your space and can't be undone.",
{ name: agent.name, count: owned.length },
)
: t("Delete “{{name}}”?", { name: agent.name });
if (!window.confirm(message)) return;
await deleteAgent(agent.id);
await Promise.allSettled(
owned.map((s) => deleteSession(s.session_id || s.id)),
);
await load(true);
},
[byCard, load, t],
);
const isEmpty = !loading && cards.length === 0;
return (
<div className="space-y-6">
<SpaceSectionHeader
icon={Bot}
title={t("Imported conversations")}
description={t(
"Your Claude Code and Codex conversations as named agents — open one to keep chatting, edit what each imports, or refresh to pull in new conversations.",
)}
action={
!isEmpty ? (
<button
type="button"
onClick={() => setWizardOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--foreground)] px-3 py-1.5 text-[12px] font-medium text-[var(--background)] shadow-sm transition-opacity hover:opacity-90"
>
<Plus className="h-3.5 w-3.5" />
{t("Add agent")}
</button>
) : null
}
/>
{isEmpty ? (
<EmptyState onAdd={() => setWizardOpen(true)} />
) : (
<>
<div className="grid gap-3 sm:grid-cols-2">
{cards.map((card) =>
card.kind === "agent" ? (
<AgentCard
key={card.key}
agent={card.agent}
count={card.count}
active={card.key === selectedKey}
refreshing={refreshingId === card.agent.id}
renaming={renamingId === card.agent.id}
lang={i18n.language}
onSelect={() => setSelectedKey(card.key)}
onRefresh={() => void refreshAgent(card.agent)}
onStartRename={() => setRenamingId(card.agent.id)}
onCommitRename={(name) => void commitRename(card.agent, name)}
onCancelRename={() => setRenamingId(null)}
onEditScope={() => setScopeAgent(card.agent)}
onDelete={() => void removeAgent(card.agent)}
/>
) : (
<UngroupedCard
key={card.key}
source={card.source}
count={card.count}
active={card.key === selectedKey}
onSelect={() => setSelectedKey(card.key)}
onReadd={() => setWizardOpen(true)}
/>
),
)}
</div>
{note && (
<p className="text-[11.5px] text-[var(--muted-foreground)]">
{note}
</p>
)}
<section className="rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-sm">
<div className="border-b border-[var(--border)]/60 px-4 py-3">
<label className="flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-[13px] text-[var(--muted-foreground)] focus-within:border-[var(--ring)]">
<Search size={14} strokeWidth={1.7} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("Search conversations...")}
className="min-w-0 flex-1 bg-transparent text-[13px] text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]/55"
/>
</label>
</div>
<div className="px-3 py-3">
<SessionList
sessions={visibleSessions}
activeSessionId={activeSessionId}
loading={loading}
onSelect={handleSelect}
onRename={handleRenameSession}
onDelete={handleDeleteSession}
/>
</div>
</section>
</>
)}
{wizardOpen && (
<ImportWizard
onClose={() => setWizardOpen(false)}
onImported={() => {
setWizardOpen(false);
void load(true);
}}
/>
)}
{scopeAgent && (
<ScopeEditorModal
agent={scopeAgent}
ownedSessions={byCard.get(scopeAgent.id) ?? []}
onClose={() => setScopeAgent(null)}
onSaved={() => {
setScopeAgent(null);
void load(true);
}}
/>
)}
</div>
);
}
function AgentCard({
agent,
count,
active,
refreshing,
renaming,
lang,
onSelect,
onRefresh,
onStartRename,
onCommitRename,
onCancelRename,
onEditScope,
onDelete,
}: {
agent: ImportAgent;
count: number;
active: boolean;
refreshing: boolean;
renaming: boolean;
lang: string;
onSelect: () => void;
onRefresh: () => void;
onStartRename: () => void;
onCommitRename: (name: string) => void;
onCancelRename: () => void;
onEditScope: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Member-expression render (``glyph.Icon``) — a bare local capitalized
// component trips react-hooks/static-components; this doesn't.
const glyph = { Icon: agentGlyph(agent.source) ?? Bot };
useEffect(() => {
if (!menuOpen) return;
const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [menuOpen]);
return (
<div
role="button"
tabIndex={0}
onClick={() => !renaming && onSelect()}
onKeyDown={(event) => {
if (renaming) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect();
}
}}
className={`group relative flex items-center gap-3 rounded-2xl border px-4 py-3 text-left transition-all ${
active
? "border-[var(--foreground)]/25 bg-[var(--card)] shadow-sm"
: "border-[var(--border)] bg-[var(--card)]/50 hover:border-[var(--border)] hover:bg-[var(--card)]"
}`}
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border)]/60 bg-[var(--background)] text-[var(--foreground)]">
<glyph.Icon size={20} strokeWidth={1.6} />
</span>
<div className="min-w-0 flex-1">
{renaming ? (
<RenameInput
initial={agent.name}
onCommit={onCommitRename}
onCancel={onCancelRename}
/>
) : (
<div className="truncate text-[13.5px] font-semibold tracking-tight text-[var(--foreground)]">
{agent.name}
</div>
)}
<div className="mt-0.5 truncate text-[11.5px] text-[var(--muted-foreground)]">
{SOURCE_LABEL[agent.source]} · {scopeSummary(agent.scope, t)} ·{" "}
{t("{{count}} conversations", { count })}
</div>
<div className="mt-0.5 truncate text-[10.5px] text-[var(--muted-foreground)]/80">
{agent.lastSyncAt
? t("Synced {{time}}", {
time: formatRelativeTime(agent.lastSyncAt / 1000, lang),
})
: t("Not synced yet")}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onRefresh();
}}
disabled={refreshing}
title={t("Refresh history")}
aria-label={t("Refresh history")}
className="rounded-lg border border-[var(--border)]/50 p-2 text-[var(--muted-foreground)] transition-colors hover:border-[var(--border)] hover:text-[var(--foreground)] disabled:opacity-50"
>
{refreshing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</button>
<div ref={menuRef} className="relative">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setMenuOpen((v) => !v);
}}
title={t("More")}
aria-label={t("More")}
aria-haspopup="menu"
aria-expanded={menuOpen}
className="rounded-lg border border-[var(--border)]/50 p-2 text-[var(--muted-foreground)] transition-colors hover:border-[var(--border)] hover:text-[var(--foreground)]"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{menuOpen && (
<div
role="menu"
onClick={(e) => e.stopPropagation()}
className="animate-fade-in absolute right-0 top-full z-20 mt-1.5 w-44 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] p-1 shadow-[0_14px_44px_rgba(0,0,0,0.16)]"
>
<MenuItem
icon={<Pencil size={14} />}
label={t("Rename")}
onClick={() => {
setMenuOpen(false);
onStartRename();
}}
/>
<MenuItem
icon={<SlidersHorizontal size={14} />}
label={t("Edit scope")}
onClick={() => {
setMenuOpen(false);
onEditScope();
}}
/>
<MenuItem
icon={<Trash2 size={14} />}
label={t("Delete agent")}
destructive
onClick={() => {
setMenuOpen(false);
onDelete();
}}
/>
</div>
)}
</div>
</div>
</div>
);
}
function RenameInput({
initial,
onCommit,
onCancel,
}: {
initial: string;
onCommit: (name: string) => void;
onCancel: () => void;
}) {
// Mounted fresh whenever rename starts, so it seeds from `initial` without an
// effect that would feed state back into a render.
const [draft, setDraft] = useState(initial);
return (
<input
autoFocus
value={draft}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => onCommit(draft)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") onCommit(draft);
else if (e.key === "Escape") onCancel();
}}
className="w-full rounded-md border border-[var(--primary)]/50 bg-[var(--background)] px-1.5 py-0.5 text-[13.5px] font-semibold text-[var(--foreground)] outline-none ring-2 ring-[var(--primary)]/15"
/>
);
}
function MenuItem({
icon,
label,
onClick,
destructive,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
destructive?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={`flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-[12.5px] transition-colors ${
destructive
? "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
: "text-[var(--foreground)] hover:bg-[var(--muted)]/60"
}`}
>
<span className="shrink-0 opacity-80">{icon}</span>
{label}
</button>
);
}
function UngroupedCard({
source,
count,
active,
onSelect,
onReadd,
}: {
source: ImportSource;
count: number;
active: boolean;
onSelect: () => void;
onReadd: () => void;
}) {
const { t } = useTranslation();
const glyph = { Icon: agentGlyph(source) ?? Bot };
return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect();
}
}}
className={`group flex items-center gap-3 rounded-2xl border border-dashed px-4 py-3 text-left transition-all ${
active
? "border-[var(--foreground)]/25 bg-[var(--card)] shadow-sm"
: "border-[var(--border)] bg-[var(--card)]/40 hover:bg-[var(--card)]"
}`}
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border)]/60 bg-[var(--background)] text-[var(--muted-foreground)]">
<glyph.Icon size={20} strokeWidth={1.6} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13.5px] font-semibold tracking-tight text-[var(--foreground)]">
{SOURCE_LABEL[source]}
</div>
<div className="mt-0.5 truncate text-[11.5px] text-[var(--muted-foreground)]">
{t("Ungrouped")} · {t("{{count}} conversations", { count })}
</div>
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onReadd();
}}
className="shrink-0 rounded-lg border border-[var(--border)]/50 px-2.5 py-1.5 text-[11px] font-medium text-[var(--muted-foreground)] transition-colors hover:border-[var(--border)] hover:text-[var(--foreground)]"
>
{t("Add agent")}
</button>
</div>
);
}
function EmptyState({ onAdd }: { onAdd: () => void }) {
const { t } = useTranslation();
return (
<section className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-[var(--border)] bg-[var(--card)]/40 px-6 py-16 text-center">
<span className="flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--border)]/60 bg-[var(--card)] text-[var(--muted-foreground)] shadow-sm">
<Bot size={20} strokeWidth={1.6} />
</span>
<div className="space-y-1.5">
<h2 className="font-serif text-[16px] font-semibold tracking-tight text-[var(--foreground)]">
{t("No agents yet")}
</h2>
<p className="mx-auto max-w-sm text-[12.5px] leading-relaxed text-[var(--muted-foreground)]">
{t(
"Add your Claude Code or Codex folder to bring its conversations in — then chat with them or refresh anytime.",
)}
</p>
</div>
<button
type="button"
onClick={onAdd}
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--foreground)] px-4 py-2 text-[12.5px] font-medium text-[var(--background)] shadow-sm transition-opacity hover:opacity-90"
>
<Plus className="h-3.5 w-3.5" />
{t("Add agent")}
</button>
</section>
);
}