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

235 lines
7.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AlertTriangle, Loader2, SlidersHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import Modal from "@/components/common/Modal";
import Button from "@/components/ui/Button";
import ScopePicker from "@/components/space/ScopePicker";
import { deleteSession, type SessionSummary } from "@/lib/session-api";
import { importChatHistory } from "@/lib/imports-api";
import {
ensureReadPermission,
saveAgent,
type ImportAgent,
} from "@/lib/chat-import/agent-store";
import { readImportMeta, sessionUnitKey } from "@/lib/chat-import/attribution";
import {
buildSelectGroups,
parseSessions,
scanDirectory,
selectionUnit,
type AgentScope,
type SelectGroup,
} from "@/lib/chat-import";
interface ScopeEditorModalProps {
agent: ImportAgent;
/** Conversations currently attributed to this agent — used to prune ones
* that fall out of scope after the edit. */
ownedSessions: SessionSummary[];
onClose: () => void;
onSaved: () => void;
}
type Phase = "loading" | "ready" | "saving" | "error";
function initialKeys(scope: AgentScope, groups: SelectGroup[]): Set<string> {
if (scope.kind === "all") return new Set(groups.map((g) => g.key));
const inScope = scope.kind === "projects" ? scope.cwds : scope.days;
const set = new Set(inScope);
return new Set(groups.filter((g) => set.has(g.key)).map((g) => g.key));
}
/**
* Re-pick which projects/days an agent owns. Saving re-scans the folder so new
* conversations in still-selected units flow in, then offers to remove ones
* that just fell out of scope so "what the agent contains" matches the picks.
*/
export default function ScopeEditorModal({
agent,
ownedSessions,
onClose,
onSaved,
}: ScopeEditorModalProps) {
const { t, i18n } = useTranslation();
const [phase, setPhase] = useState<Phase>("loading");
const [groups, setGroups] = useState<SelectGroup[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const unit = selectionUnit(agent.source);
useEffect(() => {
let active = true;
const run = async () => {
try {
if (!agent.handle || !(await ensureReadPermission(agent.handle))) {
if (active) setPhase("error");
return;
}
const scan = await scanDirectory(agent.handle);
const next = buildSelectGroups(scan);
if (!active) return;
setGroups(next);
setSelected(initialKeys(agent.scope, next));
setPhase("ready");
} catch {
if (active) setPhase("error");
}
};
void run();
return () => {
active = false;
};
}, [agent]);
const toggleKey = useCallback((key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const selectedRefs = useMemo(
() => groups.filter((g) => selected.has(g.key)).flatMap((g) => g.sessions),
[groups, selected],
);
const handleSave = useCallback(async () => {
setPhase("saving");
try {
const keys = [...selected];
const scope: AgentScope =
unit === "dates"
? { kind: "dates", days: keys }
: { kind: "projects", cwds: keys };
// Pull in conversations for the (possibly newly) in-scope units; this
// also backfills agent attribution on already-imported ones.
const normalized = await parseSessions(agent.source, selectedRefs);
await importChatHistory(agent.source, normalized, {
id: agent.id,
name: agent.name,
});
// Anything this agent owned whose unit is no longer selected is now out
// of scope — offer to remove it so the agent only holds what was picked.
const keySet = new Set(keys);
const orphaned = ownedSessions.filter((s) => {
const meta = readImportMeta(s);
if (!meta) return false;
return !keySet.has(sessionUnitKey(agent.source, meta, s.created_at));
});
if (orphaned.length > 0) {
const ok = window.confirm(
t(
"{{count}} conversations are no longer in scope. Remove them from your space?",
{ count: orphaned.length },
),
);
if (ok) {
await Promise.allSettled(
orphaned.map((s) => deleteSession(s.session_id || s.id)),
);
}
}
await saveAgent({ ...agent, scope, lastSyncAt: Date.now() });
onSaved();
} catch {
setPhase("error");
}
}, [agent, selected, selectedRefs, unit, ownedSessions, t, onSaved]);
return (
<Modal
isOpen
onClose={onClose}
title={t("Edit scope")}
titleIcon={<SlidersHorizontal className="h-[18px] w-[18px]" />}
width="xl"
closeOnBackdrop={phase !== "saving"}
closeOnEscape={phase !== "saving"}
showCloseButton={phase !== "saving"}
footer={
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[12px] text-[var(--muted-foreground)]">
{agent.name}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={phase !== "ready"}
>
{phase === "saving" ? t("Saving…") : t("Save scope")}
</Button>
</div>
</div>
}
>
<div className="px-5 py-5">
{phase === "loading" && (
<CenteredStatus
icon={<Loader2 className="h-5 w-5 animate-spin" />}
title={t("Reading your folder…")}
/>
)}
{phase === "error" && (
<CenteredStatus
icon={
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
}
title={t("Couldn't read this folder")}
subtitle={t("Refresh failed — try re-adding the agent.")}
/>
)}
{(phase === "ready" || phase === "saving") && (
<ScopePicker
groups={groups}
unit={unit}
selected={selected}
onToggle={toggleKey}
onSelectAll={() => setSelected(new Set(groups.map((g) => g.key)))}
onClearAll={() => setSelected(new Set())}
lang={i18n.language}
/>
)}
</div>
</Modal>
);
}
function CenteredStatus({
icon,
title,
subtitle,
}: {
icon: React.ReactNode;
title: string;
subtitle?: string;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border)]/60 bg-[var(--card)] text-[var(--muted-foreground)] shadow-sm">
{icon}
</span>
<div className="space-y-1">
<p className="text-[14px] font-medium text-[var(--foreground)]">
{title}
</p>
{subtitle ? (
<p className="mx-auto max-w-sm text-[12px] leading-relaxed text-[var(--muted-foreground)]">
{subtitle}
</p>
) : null}
</div>
</div>
);
}