mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): team & persona import/edit flows (#288)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,10 @@ const overrides = new Map([
|
||||
["src-tauri/src/managed_agents/runtime.rs", 650], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle
|
||||
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
|
||||
["src/features/agents/hooks.ts", 520], // agent query/mutation surface now includes built-in persona library activation
|
||||
["src/features/agents/ui/AgentsView.tsx", 850], // remote agent lifecycle controls + persona/team management + built-in catalog/library state orchestration
|
||||
["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration
|
||||
["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation
|
||||
["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation
|
||||
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
|
||||
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
|
||||
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
|
||||
["src/shared/api/types.ts", 530], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields
|
||||
|
||||
@@ -46,12 +46,14 @@ import { ManagedAgentsSection } from "./ManagedAgentsSection";
|
||||
import { PersonaCatalogDialog } from "./PersonaCatalogDialog";
|
||||
import { PersonaDialog } from "./PersonaDialog";
|
||||
import { PersonaDeleteDialog } from "./PersonaDeleteDialog";
|
||||
import { PersonaImportUpdateDialog } from "./PersonaImportUpdateDialog";
|
||||
import { PersonasSection } from "./PersonasSection";
|
||||
import { RelayDirectorySection } from "./RelayDirectorySection";
|
||||
import { SecretRevealDialog } from "./SecretRevealDialog";
|
||||
import { TeamDeleteDialog } from "./TeamDeleteDialog";
|
||||
import { TeamDialog } from "./TeamDialog";
|
||||
import { TeamImportDialog } from "./TeamImportDialog";
|
||||
import { TeamImportUpdateDialog } from "./TeamImportUpdateDialog";
|
||||
import { TeamsSection } from "./TeamsSection";
|
||||
import { TokenRevealDialog } from "./TokenRevealDialog";
|
||||
import {
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
importPersonaDialogState,
|
||||
type PersonaDialogState,
|
||||
} from "./personaDialogState";
|
||||
import { usePersonaImportActions } from "./usePersonaImportActions";
|
||||
import { useTeamActions } from "./useTeamActions";
|
||||
|
||||
type PersonaFeedbackSurface = "catalog" | "library";
|
||||
@@ -117,6 +120,15 @@ export function AgentsView() {
|
||||
refetchRelayAgents: () => void relayAgentsQuery.refetch(),
|
||||
},
|
||||
);
|
||||
const personaImportActions = usePersonaImportActions(
|
||||
personasQuery.data ?? [],
|
||||
{
|
||||
clearPersonaFeedback: () => clearPersonaFeedback("library"),
|
||||
setPersonaNoticeMessage,
|
||||
setPersonaErrorMessage,
|
||||
setPersonaDialogState,
|
||||
},
|
||||
);
|
||||
const [batchImportResult, setBatchImportResult] =
|
||||
React.useState<ParsePersonaFilesResult | null>(null);
|
||||
const [batchImportFileName, setBatchImportFileName] = React.useState("");
|
||||
@@ -684,11 +696,15 @@ export function AgentsView() {
|
||||
: null
|
||||
}
|
||||
initialValues={personaDialogState?.initialValues ?? null}
|
||||
isImportPending={personaImportActions.isApplyingPersonaImportUpdate}
|
||||
isPending={
|
||||
createPersonaMutation.isPending || updatePersonaMutation.isPending
|
||||
}
|
||||
providers={acpProvidersQuery.data ?? []}
|
||||
providersLoading={acpProvidersQuery.isLoading}
|
||||
onImportUpdateFile={
|
||||
personaImportActions.handleEditDialogImportUpdateFile
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPersonaDialogState(null);
|
||||
@@ -743,15 +759,18 @@ export function AgentsView() {
|
||||
: null
|
||||
}
|
||||
initialValues={teamActions.teamDialogState?.initialValues ?? null}
|
||||
isImportPending={teamActions.isApplyingTeamImportUpdate}
|
||||
isPending={
|
||||
teamActions.createTeamMutation.isPending ||
|
||||
teamActions.updateTeamMutation.isPending
|
||||
}
|
||||
onImportUpdateFile={teamActions.handleEditDialogImportUpdateFile}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
teamActions.setTeamDialogState(null);
|
||||
}
|
||||
}}
|
||||
onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas}
|
||||
onSubmit={teamActions.handleTeamSubmit}
|
||||
open={teamActions.teamDialogState !== null}
|
||||
personas={libraryPersonas}
|
||||
@@ -810,6 +829,45 @@ export function AgentsView() {
|
||||
open={teamActions.teamImportPreview !== null}
|
||||
preview={teamActions.teamImportPreview?.preview ?? null}
|
||||
/>
|
||||
<TeamImportUpdateDialog
|
||||
fileName={teamActions.teamImportTargetPreview?.fileName ?? ""}
|
||||
isPending={
|
||||
teamActions.isApplyingTeamImportUpdate ||
|
||||
teamActions.updateTeamMutation.isPending
|
||||
}
|
||||
onApply={teamActions.handleTeamImportUpdateApply}
|
||||
onClear={teamActions.clearImportUpdateAndReturnToEdit}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
teamActions.closeImportUpdateDialog();
|
||||
}
|
||||
}}
|
||||
open={teamActions.teamImportTarget !== null}
|
||||
personas={libraryPersonas}
|
||||
preview={teamActions.teamImportTargetPreview?.preview ?? null}
|
||||
team={teamActions.teamImportTarget}
|
||||
/>
|
||||
<PersonaImportUpdateDialog
|
||||
fileName={
|
||||
personaImportActions.personaImportTargetPreview?.fileName ?? ""
|
||||
}
|
||||
isPending={
|
||||
personaImportActions.isApplyingPersonaImportUpdate ||
|
||||
updatePersonaMutation.isPending
|
||||
}
|
||||
onApply={personaImportActions.handleImportUpdateApply}
|
||||
onClear={personaImportActions.clearImportUpdateAndReturnToEdit}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
personaImportActions.closeImportUpdateDialog();
|
||||
}
|
||||
}}
|
||||
open={personaImportActions.personaImportTarget !== null}
|
||||
persona={personaImportActions.personaImportTarget}
|
||||
preview={
|
||||
personaImportActions.personaImportTargetPreview?.preview ?? null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import * as React from "react";
|
||||
import { RefreshCw, Upload } from "lucide-react";
|
||||
|
||||
import type {
|
||||
AcpProvider,
|
||||
CreatePersonaInput,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,6 +18,12 @@ import {
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import {
|
||||
getImportButtonLabel,
|
||||
getImportButtonTone,
|
||||
getImportErrorLabel,
|
||||
IMPORT_ERROR_VISIBILITY_MS,
|
||||
} from "./personaDialogImportState";
|
||||
|
||||
type PersonaDialogProps = {
|
||||
open: boolean;
|
||||
@@ -24,10 +33,16 @@ type PersonaDialogProps = {
|
||||
initialValues: CreatePersonaInput | UpdatePersonaInput | null;
|
||||
error: Error | null;
|
||||
isPending: boolean;
|
||||
isImportPending?: boolean;
|
||||
providers: AcpProvider[];
|
||||
providersLoading?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
|
||||
onImportUpdateFile?: (
|
||||
personaId: string,
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function PersonaDialog({
|
||||
@@ -38,10 +53,12 @@ export function PersonaDialog({
|
||||
initialValues,
|
||||
error,
|
||||
isPending,
|
||||
isImportPending = false,
|
||||
providers,
|
||||
providersLoading = false,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
onImportUpdateFile,
|
||||
}: PersonaDialogProps) {
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
const [avatarUrl, setAvatarUrl] = React.useState("");
|
||||
@@ -49,6 +66,17 @@ export function PersonaDialog({
|
||||
const [provider, setProvider] = React.useState("");
|
||||
const [model, setModel] = React.useState("");
|
||||
const [namePoolText, setNamePoolText] = React.useState("");
|
||||
const [isImportingUpdate, setIsImportingUpdate] = React.useState(false);
|
||||
const [importErrorMessage, setImportErrorMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isWindowFileDragOver, setIsWindowFileDragOver] = React.useState(false);
|
||||
const isEditMode = Boolean(initialValues && "id" in initialValues);
|
||||
const editPersonaId =
|
||||
isEditMode && initialValues && "id" in initialValues
|
||||
? initialValues.id
|
||||
: null;
|
||||
const canImportPersonaUpdate = isEditMode && Boolean(onImportUpdateFile);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !initialValues) {
|
||||
@@ -66,8 +94,118 @@ export function PersonaDialog({
|
||||
: undefined
|
||||
)?.join(", ") ?? "",
|
||||
);
|
||||
setImportErrorMessage(null);
|
||||
setIsImportingUpdate(false);
|
||||
}, [initialValues, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !canImportPersonaUpdate) {
|
||||
setIsWindowFileDragOver(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let dragDepth = 0;
|
||||
|
||||
function isFileDrag(event: DragEvent): boolean {
|
||||
return Array.from(event.dataTransfer?.types ?? []).includes("Files");
|
||||
}
|
||||
|
||||
function handleWindowDragEnter(event: DragEvent) {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
dragDepth += 1;
|
||||
setIsWindowFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleWindowDragOver(event: DragEvent) {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
setIsWindowFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleWindowDragLeave(event: DragEvent) {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (dragDepth === 0) {
|
||||
setIsWindowFileDragOver(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowDrop(event: DragEvent) {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
setIsWindowFileDragOver(false);
|
||||
}
|
||||
|
||||
window.addEventListener("dragenter", handleWindowDragEnter);
|
||||
window.addEventListener("dragover", handleWindowDragOver);
|
||||
window.addEventListener("dragleave", handleWindowDragLeave);
|
||||
window.addEventListener("drop", handleWindowDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("dragenter", handleWindowDragEnter);
|
||||
window.removeEventListener("dragover", handleWindowDragOver);
|
||||
window.removeEventListener("dragleave", handleWindowDragLeave);
|
||||
window.removeEventListener("drop", handleWindowDrop);
|
||||
};
|
||||
}, [canImportPersonaUpdate, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !importErrorMessage) {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(() => {
|
||||
setImportErrorMessage(null);
|
||||
}, IMPORT_ERROR_VISIBILITY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [importErrorMessage, open]);
|
||||
|
||||
async function handleImportUpdateSelection(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
) {
|
||||
if (!editPersonaId || !onImportUpdateFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImportErrorMessage(null);
|
||||
setIsImportingUpdate(true);
|
||||
try {
|
||||
await onImportUpdateFile(editPersonaId, fileBytes, fileName);
|
||||
} catch (error) {
|
||||
setImportErrorMessage(
|
||||
getImportErrorLabel(error instanceof Error ? error.message : null),
|
||||
);
|
||||
} finally {
|
||||
setIsImportingUpdate(false);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
fileInputRef: importFileInputRef,
|
||||
isDragOver: isImportDragOver,
|
||||
dropHandlers: importDropHandlers,
|
||||
handleFileChange: handleImportFileChange,
|
||||
openFilePicker: openImportFilePicker,
|
||||
} = useFileImportZone({
|
||||
onImportFile: (fileBytes, fileName) => {
|
||||
void handleImportUpdateSelection(fileBytes, fileName);
|
||||
},
|
||||
});
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
setDisplayName("");
|
||||
@@ -76,6 +214,9 @@ export function PersonaDialog({
|
||||
setProvider("");
|
||||
setModel("");
|
||||
setNamePoolText("");
|
||||
setImportErrorMessage(null);
|
||||
setIsImportingUpdate(false);
|
||||
setIsWindowFileDragOver(false);
|
||||
}
|
||||
|
||||
onOpenChange(next);
|
||||
@@ -110,13 +251,26 @@ export function PersonaDialog({
|
||||
await onSubmit(baseInput);
|
||||
}
|
||||
|
||||
const importButtonTone = getImportButtonTone({
|
||||
isWindowFileDragOver,
|
||||
isImportDragOver,
|
||||
importErrorMessage,
|
||||
});
|
||||
const importButtonLabel = getImportButtonLabel({
|
||||
isWindowFileDragOver,
|
||||
isImportDragOver,
|
||||
importErrorMessage,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="max-w-2xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
{description.trim().length > 0 ? (
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
|
||||
@@ -255,27 +409,70 @@ export function PersonaDialog({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
displayName.trim().length === 0 ||
|
||||
systemPrompt.trim().length === 0 ||
|
||||
isPending
|
||||
}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-border/60 px-6 py-4">
|
||||
<div className="flex min-h-8 items-center">
|
||||
{canImportPersonaUpdate ? (
|
||||
<>
|
||||
<input
|
||||
accept=".json,.png,.zip"
|
||||
className="hidden"
|
||||
onChange={handleImportFileChange}
|
||||
ref={importFileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-2 rounded-md border px-3 text-xs font-medium transition-colors",
|
||||
importButtonTone === "drag"
|
||||
? "border-dashed border-primary/70 bg-primary/10 text-primary"
|
||||
: importButtonTone === "error"
|
||||
? "border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/15"
|
||||
: "border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
disabled={isPending || isImportPending || isImportingUpdate}
|
||||
type="button"
|
||||
{...importDropHandlers}
|
||||
onClick={openImportFilePicker}
|
||||
title={
|
||||
importButtonTone === "error"
|
||||
? importButtonLabel
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span className="max-w-[16rem] truncate">
|
||||
{importButtonLabel}
|
||||
</span>
|
||||
{isImportingUpdate ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
displayName.trim().length === 0 ||
|
||||
systemPrompt.trim().length === 0 ||
|
||||
isPending
|
||||
}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
271
desktop/src/features/agents/ui/PersonaImportUpdateDialog.tsx
Normal file
271
desktop/src/features/agents/ui/PersonaImportUpdateDialog.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
buildPersonaImportPlan,
|
||||
hasAnyPersonaImportChanges,
|
||||
} from "./personaImportPlan";
|
||||
import {
|
||||
getFieldPreview,
|
||||
getFieldSecondaryText,
|
||||
} from "./personaImportUpdateState";
|
||||
|
||||
type PersonaImportUpdateDialogProps = {
|
||||
open: boolean;
|
||||
persona: AgentPersona | null;
|
||||
preview: ParsedPersonaPreview | null;
|
||||
fileName: string;
|
||||
isPending: boolean;
|
||||
onClear: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onApply: (input: { selectedFields: string[] }) => Promise<void>;
|
||||
};
|
||||
|
||||
export function PersonaImportUpdateDialog({
|
||||
open,
|
||||
persona,
|
||||
preview,
|
||||
fileName,
|
||||
isPending,
|
||||
onClear,
|
||||
onOpenChange,
|
||||
onApply,
|
||||
}: PersonaImportUpdateDialogProps) {
|
||||
const [selectedFields, setSelectedFields] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
|
||||
const plan = React.useMemo(() => {
|
||||
if (!persona || !preview) {
|
||||
return null;
|
||||
}
|
||||
return buildPersonaImportPlan({ persona, preview });
|
||||
}, [persona, preview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(null);
|
||||
setSelectedFields(new Set());
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !plan) {
|
||||
return;
|
||||
}
|
||||
setSelectedFields(new Set(plan.fields.map((field) => field.field)));
|
||||
}, [open, plan]);
|
||||
|
||||
function toggleField(field: string, checked: boolean) {
|
||||
setSelectedFields((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.add(field);
|
||||
} else {
|
||||
next.delete(field);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function runApply() {
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await onApply({
|
||||
selectedFields: Array.from(selectedFields),
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to apply imported persona update.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = hasAnyPersonaImportChanges(plan);
|
||||
const selectedCount = selectedFields.size;
|
||||
|
||||
function renderLineChangeSummary(
|
||||
addedLines: number,
|
||||
removedLines: number,
|
||||
emphasize = true,
|
||||
) {
|
||||
const addedClass = emphasize
|
||||
? addedLines > 0
|
||||
? "text-status-added"
|
||||
: "text-muted-foreground"
|
||||
: "text-muted-foreground";
|
||||
const separatorClass = "text-muted-foreground";
|
||||
const removedClass = emphasize
|
||||
? removedLines > 0
|
||||
? "text-status-deleted"
|
||||
: "text-muted-foreground"
|
||||
: "text-muted-foreground";
|
||||
const opacityClass = emphasize ? "opacity-100" : "opacity-50";
|
||||
|
||||
return (
|
||||
<p
|
||||
className={`shrink-0 text-xs font-medium tabular-nums transition-opacity ${opacityClass}`}
|
||||
>
|
||||
<span className={addedClass}>+{addedLines}</span>
|
||||
<span className={separatorClass}> / </span>
|
||||
<span className={removedClass}>-{removedLines}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-3xl flex-col overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 space-y-1 border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Import persona</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-card/80 px-3 py-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium">
|
||||
{fileName || "Imported file"}
|
||||
</p>
|
||||
<button
|
||||
aria-label="Clear import"
|
||||
className="shrink-0 text-sm text-primary underline-offset-4 hover:underline"
|
||||
disabled={isPending}
|
||||
onClick={onClear}
|
||||
type="button"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview && plan ? (
|
||||
<div className="space-y-4">
|
||||
{hasChanges ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Fields that will be updated{" "}
|
||||
<span className="font-bold">
|
||||
({selectedCount}/{plan.fields.length})
|
||||
</span>
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{plan.fields.map((fieldChange) => {
|
||||
const shouldUpdate = selectedFields.has(
|
||||
fieldChange.field,
|
||||
);
|
||||
const previewText = getFieldSecondaryText(
|
||||
shouldUpdate,
|
||||
getFieldPreview(
|
||||
fieldChange.importedValue,
|
||||
`No ${fieldChange.label.toLowerCase()} in import.`,
|
||||
),
|
||||
getFieldPreview(
|
||||
fieldChange.existingValue,
|
||||
`No current ${fieldChange.label.toLowerCase()}.`,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/80 px-3 py-2.5"
|
||||
key={fieldChange.field}
|
||||
>
|
||||
<Checkbox
|
||||
checked={shouldUpdate}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleField(fieldChange.field, Boolean(checked))
|
||||
}
|
||||
/>
|
||||
{fieldChange.field === "avatarUrl" ? (
|
||||
<ProfileAvatar
|
||||
avatarUrl={
|
||||
shouldUpdate
|
||||
? fieldChange.importedValue || null
|
||||
: fieldChange.existingValue || null
|
||||
}
|
||||
className="h-8 w-8 rounded-lg text-xs"
|
||||
label={persona?.displayName ?? ""}
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold tracking-tight">
|
||||
{fieldChange.label}
|
||||
</p>
|
||||
<p
|
||||
className={`truncate text-xs ${
|
||||
shouldUpdate
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{previewText}
|
||||
</p>
|
||||
</div>
|
||||
{renderLineChangeSummary(
|
||||
fieldChange.addedLines,
|
||||
fieldChange.removedLines,
|
||||
shouldUpdate,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-card/60 px-4 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight text-muted-foreground">
|
||||
no changes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No import preview is available.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!preview || isPending || selectedCount === 0}
|
||||
onClick={() => void runApply()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
Apply update
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type RemoveMembersConfirmDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isPending: boolean;
|
||||
memberNames: string[];
|
||||
onKeepAgents: () => void;
|
||||
onRemoveAgents: () => void;
|
||||
};
|
||||
|
||||
export function RemoveMembersConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
isPending,
|
||||
memberNames,
|
||||
onKeepAgents,
|
||||
onRemoveAgents,
|
||||
}: RemoveMembersConfirmDialogProps) {
|
||||
const count = memberNames.length;
|
||||
const plural = count !== 1;
|
||||
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Remove {count} member{plural ? "s" : ""}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{memberNames.join(", ")} will be removed from this team. Do you also
|
||||
want to remove the {plural ? "agents" : "agent"} completely?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={onKeepAgents}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Keep {plural ? "agents" : "agent"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={onRemoveAgents}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Remove {plural ? "agents" : "agent"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { RefreshCw, Upload } from "lucide-react";
|
||||
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import type {
|
||||
@@ -6,6 +7,8 @@ import type {
|
||||
CreateTeamInput,
|
||||
UpdateTeamInput,
|
||||
} from "@/shared/api/types";
|
||||
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import {
|
||||
@@ -18,10 +21,18 @@ import {
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { personaCatalogCopy } from "./personaLibraryCopy";
|
||||
import { RemoveMembersConfirmDialog } from "./RemoveMembersConfirmDialog";
|
||||
import {
|
||||
getImportButtonLabel,
|
||||
getImportButtonTone,
|
||||
getImportErrorLabel,
|
||||
IMPORT_ERROR_VISIBILITY_MS,
|
||||
} from "./teamDialogImportState";
|
||||
import {
|
||||
copySelectedPersonaIds,
|
||||
countMissingPersonaIds,
|
||||
filterAvailablePersonaIds,
|
||||
orderPersonasByInitiallySelected,
|
||||
} from "./teamDialogSelection";
|
||||
|
||||
type TeamDialogProps = {
|
||||
@@ -33,8 +44,15 @@ type TeamDialogProps = {
|
||||
personas: AgentPersona[];
|
||||
error: Error | null;
|
||||
isPending: boolean;
|
||||
isImportPending?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (input: CreateTeamInput | UpdateTeamInput) => Promise<void>;
|
||||
onDeleteRemovedPersonas?: (personaIds: string[]) => Promise<void>;
|
||||
onImportUpdateFile?: (
|
||||
teamId: string,
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function TeamDialog({
|
||||
@@ -46,14 +64,33 @@ export function TeamDialog({
|
||||
personas,
|
||||
error,
|
||||
isPending,
|
||||
isImportPending = false,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
onDeleteRemovedPersonas,
|
||||
onImportUpdateFile,
|
||||
}: TeamDialogProps) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [teamDescription, setTeamDescription] = React.useState("");
|
||||
const [selectedPersonaIds, setSelectedPersonaIds] = React.useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [
|
||||
initialSelectedPersonaIdsForSort,
|
||||
setInitialSelectedPersonaIdsForSort,
|
||||
] = React.useState<string[]>([]);
|
||||
const [isImportingUpdate, setIsImportingUpdate] = React.useState(false);
|
||||
const [importErrorMessage, setImportErrorMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [confirmRemovalOpen, setConfirmRemovalOpen] = React.useState(false);
|
||||
const isEditMode = Boolean(initialValues && "id" in initialValues);
|
||||
const editTeamId =
|
||||
isEditMode && initialValues && "id" in initialValues
|
||||
? initialValues.id
|
||||
: null;
|
||||
const canImportTeamUpdate = isEditMode && Boolean(onImportUpdateFile);
|
||||
const [isWindowFileDragOver, setIsWindowFileDragOver] = React.useState(false);
|
||||
const missingInitialPersonaCount = React.useMemo(() => {
|
||||
if (!initialValues) {
|
||||
return 0;
|
||||
@@ -70,13 +107,131 @@ export function TeamDialog({
|
||||
setName(initialValues.name);
|
||||
setTeamDescription(initialValues.description ?? "");
|
||||
setSelectedPersonaIds(copySelectedPersonaIds(initialValues.personaIds));
|
||||
setInitialSelectedPersonaIdsForSort(
|
||||
copySelectedPersonaIds(initialValues.personaIds),
|
||||
);
|
||||
setImportErrorMessage(null);
|
||||
setIsImportingUpdate(false);
|
||||
}, [initialValues, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !canImportTeamUpdate) {
|
||||
setIsWindowFileDragOver(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let dragDepth = 0;
|
||||
|
||||
function isFileDrag(event: DragEvent): boolean {
|
||||
return Array.from(event.dataTransfer?.types ?? []).includes("Files");
|
||||
}
|
||||
|
||||
function handleWindowDragEnter(event: DragEvent) {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
dragDepth += 1;
|
||||
setIsWindowFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleWindowDragOver(event: DragEvent) {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
setIsWindowFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleWindowDragLeave(event: DragEvent) {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (dragDepth === 0) {
|
||||
setIsWindowFileDragOver(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowDrop(event: DragEvent) {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
setIsWindowFileDragOver(false);
|
||||
}
|
||||
|
||||
window.addEventListener("dragenter", handleWindowDragEnter);
|
||||
window.addEventListener("dragover", handleWindowDragOver);
|
||||
window.addEventListener("dragleave", handleWindowDragLeave);
|
||||
window.addEventListener("drop", handleWindowDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("dragenter", handleWindowDragEnter);
|
||||
window.removeEventListener("dragover", handleWindowDragOver);
|
||||
window.removeEventListener("dragleave", handleWindowDragLeave);
|
||||
window.removeEventListener("drop", handleWindowDrop);
|
||||
};
|
||||
}, [canImportTeamUpdate, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !importErrorMessage) {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(() => {
|
||||
setImportErrorMessage(null);
|
||||
}, IMPORT_ERROR_VISIBILITY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [importErrorMessage, open]);
|
||||
|
||||
async function handleImportUpdateSelection(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
) {
|
||||
if (!editTeamId || !onImportUpdateFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImportErrorMessage(null);
|
||||
setIsImportingUpdate(true);
|
||||
try {
|
||||
await onImportUpdateFile(editTeamId, fileBytes, fileName);
|
||||
} catch (error) {
|
||||
setImportErrorMessage(
|
||||
getImportErrorLabel(error instanceof Error ? error.message : null),
|
||||
);
|
||||
} finally {
|
||||
setIsImportingUpdate(false);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
fileInputRef: importFileInputRef,
|
||||
isDragOver: isImportDragOver,
|
||||
dropHandlers: importDropHandlers,
|
||||
handleFileChange: handleImportFileChange,
|
||||
openFilePicker: openImportFilePicker,
|
||||
} = useFileImportZone({
|
||||
onImportFile: (fileBytes, fileName) => {
|
||||
void handleImportUpdateSelection(fileBytes, fileName);
|
||||
},
|
||||
});
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
setName("");
|
||||
setTeamDescription("");
|
||||
setSelectedPersonaIds([]);
|
||||
setInitialSelectedPersonaIdsForSort([]);
|
||||
setImportErrorMessage(null);
|
||||
setIsImportingUpdate(false);
|
||||
setIsWindowFileDragOver(false);
|
||||
setConfirmRemovalOpen(false);
|
||||
}
|
||||
|
||||
onOpenChange(next);
|
||||
@@ -90,167 +245,283 @@ export function TeamDialog({
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!initialValues) {
|
||||
return;
|
||||
}
|
||||
const removedPersonaIds = React.useMemo(() => {
|
||||
if (!isEditMode || !initialValues || !("id" in initialValues)) return [];
|
||||
const currentSet = new Set(selectedPersonaIds);
|
||||
return initialValues.personaIds.filter(
|
||||
(id) => !currentSet.has(id) && personas.some((p) => p.id === id),
|
||||
);
|
||||
}, [isEditMode, initialValues, selectedPersonaIds, personas]);
|
||||
|
||||
const removedPersonaNames = React.useMemo(
|
||||
() =>
|
||||
removedPersonaIds
|
||||
.map((id) => personas.find((p) => p.id === id)?.displayName)
|
||||
.filter(Boolean),
|
||||
[removedPersonaIds, personas],
|
||||
);
|
||||
|
||||
function buildSubmitInput(): CreateTeamInput | UpdateTeamInput {
|
||||
const baseInput = {
|
||||
name,
|
||||
description: teamDescription.trim() || undefined,
|
||||
personaIds: filterAvailablePersonaIds(selectedPersonaIds, personas),
|
||||
};
|
||||
|
||||
if ("id" in initialValues) {
|
||||
await onSubmit({ id: initialValues.id, ...baseInput });
|
||||
if (initialValues && "id" in initialValues) {
|
||||
return { id: initialValues.id, ...baseInput };
|
||||
}
|
||||
return baseInput;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!initialValues) return;
|
||||
|
||||
if (removedPersonaIds.length > 0 && isEditMode && onDeleteRemovedPersonas) {
|
||||
setConfirmRemovalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(baseInput);
|
||||
await onSubmit(buildSubmitInput());
|
||||
}
|
||||
|
||||
async function handleSubmitKeepAgents() {
|
||||
setConfirmRemovalOpen(false);
|
||||
await onSubmit(buildSubmitInput());
|
||||
}
|
||||
|
||||
async function handleSubmitDeleteAgents() {
|
||||
setConfirmRemovalOpen(false);
|
||||
await onSubmit(buildSubmitInput());
|
||||
if (onDeleteRemovedPersonas && removedPersonaIds.length > 0) {
|
||||
await onDeleteRemovedPersonas(removedPersonaIds);
|
||||
}
|
||||
}
|
||||
|
||||
const importButtonTone = getImportButtonTone({
|
||||
isWindowFileDragOver,
|
||||
isImportDragOver,
|
||||
importErrorMessage,
|
||||
});
|
||||
const importButtonLabel = getImportButtonLabel({
|
||||
isWindowFileDragOver,
|
||||
isImportDragOver,
|
||||
importErrorMessage,
|
||||
});
|
||||
const orderedPersonas = React.useMemo(
|
||||
() =>
|
||||
orderPersonasByInitiallySelected(
|
||||
personas,
|
||||
initialSelectedPersonaIdsForSort,
|
||||
),
|
||||
[initialSelectedPersonaIdsForSort, personas],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="max-w-2xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="max-w-2xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description.trim().length > 0 ? (
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="team-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
autoCorrect="off"
|
||||
disabled={isPending}
|
||||
id="team-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Engineering Squad"
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="team-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
autoCorrect="off"
|
||||
disabled={isPending}
|
||||
id="team-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Engineering Squad"
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="team-description">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-20"
|
||||
disabled={isPending}
|
||||
id="team-description"
|
||||
onChange={(event) => setTeamDescription(event.target.value)}
|
||||
placeholder="Optional description for this team."
|
||||
value={teamDescription}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="team-description"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-20"
|
||||
disabled={isPending}
|
||||
id="team-description"
|
||||
onChange={(event) => setTeamDescription(event.target.value)}
|
||||
placeholder="Optional description for this team."
|
||||
value={teamDescription}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Personas</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select the personas to include in this team.
|
||||
</p>
|
||||
{missingInitialPersonaCount > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Personas</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select the personas to include in this team.
|
||||
</p>
|
||||
{missingInitialPersonaCount > 0 ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
This team references {missingInitialPersonaCount} persona
|
||||
{missingInitialPersonaCount === 1 ? "" : "s"} that{" "}
|
||||
{missingInitialPersonaCount === 1 ? "is" : "are"} no longer
|
||||
in My Agents. Save to remove them, or add them back to My
|
||||
Agents first.
|
||||
</p>
|
||||
) : null}
|
||||
{personas.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{personaCatalogCopy.teamEmptyState}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="max-h-60 space-y-1 overflow-y-auto rounded-lg border border-border/70 p-2"
|
||||
role="listbox"
|
||||
aria-label="Personas"
|
||||
aria-multiselectable="true"
|
||||
>
|
||||
{orderedPersonas.map((persona) => {
|
||||
const isSelected = selectedPersonaIds.includes(
|
||||
persona.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/50"
|
||||
key={persona.id}
|
||||
onClick={() => {
|
||||
if (!isPending) {
|
||||
togglePersona(persona.id);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!isPending &&
|
||||
(event.key === "Enter" || event.key === " ")
|
||||
) {
|
||||
event.preventDefault();
|
||||
togglePersona(persona.id);
|
||||
}
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isPending}
|
||||
onCheckedChange={() => togglePersona(persona.id)}
|
||||
/>
|
||||
<ProfileAvatar
|
||||
avatarUrl={persona.avatarUrl}
|
||||
className="h-6 w-6 rounded-full text-[10px]"
|
||||
label={persona.displayName}
|
||||
/>
|
||||
<span className="text-sm">{persona.displayName}</span>
|
||||
{persona.isBuiltIn ? (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Built-in
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
This team references {missingInitialPersonaCount} persona
|
||||
{missingInitialPersonaCount === 1 ? "" : "s"} that{" "}
|
||||
{missingInitialPersonaCount === 1 ? "is" : "are"} no longer in
|
||||
My Agents. Save to remove them, or add them back to My Agents
|
||||
first.
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{personas.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{personaCatalogCopy.teamEmptyState}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="max-h-60 space-y-1 overflow-y-auto rounded-lg border border-border/70 p-2"
|
||||
role="listbox"
|
||||
aria-label="Personas"
|
||||
aria-multiselectable="true"
|
||||
>
|
||||
{personas.map((persona) => {
|
||||
const isSelected = selectedPersonaIds.includes(persona.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/50"
|
||||
key={persona.id}
|
||||
onClick={() => {
|
||||
if (!isPending) {
|
||||
togglePersona(persona.id);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!isPending &&
|
||||
(event.key === "Enter" || event.key === " ")
|
||||
) {
|
||||
event.preventDefault();
|
||||
togglePersona(persona.id);
|
||||
}
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isPending}
|
||||
onCheckedChange={() => togglePersona(persona.id)}
|
||||
/>
|
||||
<ProfileAvatar
|
||||
avatarUrl={persona.avatarUrl}
|
||||
className="h-6 w-6 rounded-full text-[10px]"
|
||||
label={persona.displayName}
|
||||
/>
|
||||
<span className="text-sm">{persona.displayName}</span>
|
||||
{persona.isBuiltIn ? (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Built-in
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-border/60 px-6 py-4">
|
||||
<div className="flex min-h-8 items-center">
|
||||
{canImportTeamUpdate ? (
|
||||
<>
|
||||
<input
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleImportFileChange}
|
||||
ref={importFileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-2 rounded-md border px-3 text-xs font-medium transition-colors",
|
||||
importButtonTone === "drag"
|
||||
? "border-dashed border-primary/70 bg-primary/10 text-primary"
|
||||
: importButtonTone === "error"
|
||||
? "border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/15"
|
||||
: "border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
disabled={
|
||||
isPending || isImportPending || isImportingUpdate
|
||||
}
|
||||
type="button"
|
||||
{...importDropHandlers}
|
||||
onClick={openImportFilePicker}
|
||||
title={
|
||||
importButtonTone === "error"
|
||||
? importButtonLabel
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span className="max-w-[16rem] truncate">
|
||||
{importButtonLabel}
|
||||
</span>
|
||||
{isImportingUpdate ? (
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
name.trim().length === 0 ||
|
||||
selectedPersonaIds.length === 0 ||
|
||||
isPending
|
||||
}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
name.trim().length === 0 ||
|
||||
selectedPersonaIds.length === 0 ||
|
||||
isPending
|
||||
}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<RemoveMembersConfirmDialog
|
||||
open={confirmRemovalOpen}
|
||||
onOpenChange={setConfirmRemovalOpen}
|
||||
isPending={isPending}
|
||||
memberNames={removedPersonaNames as string[]}
|
||||
onKeepAgents={() => void handleSubmitKeepAgents()}
|
||||
onRemoveAgents={() => void handleSubmitDeleteAgents()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
655
desktop/src/features/agents/ui/TeamImportUpdateDialog.tsx
Normal file
655
desktop/src/features/agents/ui/TeamImportUpdateDialog.tsx
Normal file
@@ -0,0 +1,655 @@
|
||||
import * as React from "react";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import type { ParsedTeamPreview } from "@/shared/api/tauriTeams";
|
||||
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
|
||||
import { RemoveMembersConfirmDialog } from "./RemoveMembersConfirmDialog";
|
||||
import { buildTeamImportPlan } from "./teamImportPlan";
|
||||
import {
|
||||
getAddMemberSecondaryText,
|
||||
getMissingMemberSecondaryText,
|
||||
hasAnyImportChanges,
|
||||
} from "./teamImportUpdateState";
|
||||
|
||||
type TeamImportUpdateDialogProps = {
|
||||
open: boolean;
|
||||
team: AgentTeam | null;
|
||||
personas: AgentPersona[];
|
||||
preview: ParsedTeamPreview | null;
|
||||
fileName: string;
|
||||
isPending: boolean;
|
||||
onClear: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onApply: (input: {
|
||||
personas: AgentPersona[];
|
||||
updateTeamInfo: boolean;
|
||||
selectedUpdatedPersonaIds: string[];
|
||||
selectedNewMemberIndexes: number[];
|
||||
missingPersonaIdsToRemove: string[];
|
||||
deleteRemovedAgents: boolean;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
export function TeamImportUpdateDialog({
|
||||
open,
|
||||
team,
|
||||
personas,
|
||||
preview,
|
||||
fileName,
|
||||
isPending,
|
||||
onClear,
|
||||
onOpenChange,
|
||||
onApply,
|
||||
}: TeamImportUpdateDialogProps) {
|
||||
const [updateTeamInfo, setUpdateTeamInfo] = React.useState(true);
|
||||
const [selectedUpdatedPersonaIds, setSelectedUpdatedPersonaIds] =
|
||||
React.useState<Set<string>>(new Set());
|
||||
const [selectedNewMemberIndexes, setSelectedNewMemberIndexes] =
|
||||
React.useState<Set<number>>(new Set());
|
||||
const [missingPersonaIdsToRemove, setMissingPersonaIdsToRemove] =
|
||||
React.useState<Set<string>>(new Set());
|
||||
const [confirmRemovalOpen, setConfirmRemovalOpen] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
|
||||
const plan = React.useMemo(() => {
|
||||
if (!team || !preview) {
|
||||
return null;
|
||||
}
|
||||
return buildTeamImportPlan({ team, personas, preview });
|
||||
}, [team, personas, preview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(null);
|
||||
setUpdateTeamInfo(true);
|
||||
setSelectedUpdatedPersonaIds(new Set());
|
||||
setSelectedNewMemberIndexes(new Set());
|
||||
setMissingPersonaIdsToRemove(new Set());
|
||||
setConfirmRemovalOpen(false);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !plan) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedUpdatedPersonaIds(
|
||||
new Set(plan.membersToUpdate.map((member) => member.existing.id)),
|
||||
);
|
||||
setSelectedNewMemberIndexes(
|
||||
new Set(plan.newMembers.map((member) => member.importedIndex)),
|
||||
);
|
||||
}, [open, plan]);
|
||||
|
||||
function toggleMissingPersona(personaId: string, checked: boolean) {
|
||||
setMissingPersonaIdsToRemove((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.add(personaId);
|
||||
} else {
|
||||
next.delete(personaId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleUpdatedPersona(personaId: string, checked: boolean) {
|
||||
setSelectedUpdatedPersonaIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.add(personaId);
|
||||
} else {
|
||||
next.delete(personaId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNewMember(importedIndex: number, checked: boolean) {
|
||||
setSelectedNewMemberIndexes((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.add(importedIndex);
|
||||
} else {
|
||||
next.delete(importedIndex);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function runApply(deleteRemovedAgents: boolean) {
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await onApply({
|
||||
personas,
|
||||
updateTeamInfo,
|
||||
selectedUpdatedPersonaIds: Array.from(selectedUpdatedPersonaIds),
|
||||
selectedNewMemberIndexes: Array.from(selectedNewMemberIndexes),
|
||||
missingPersonaIdsToRemove: Array.from(missingPersonaIdsToRemove),
|
||||
deleteRemovedAgents,
|
||||
});
|
||||
setConfirmRemovalOpen(false);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to apply imported team update.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const removableCount = missingPersonaIdsToRemove.size;
|
||||
const removableMembers =
|
||||
plan?.missingMembers.filter((member) =>
|
||||
missingPersonaIdsToRemove.has(member.existing.id),
|
||||
) ?? [];
|
||||
const selectedUpdatedCount = selectedUpdatedPersonaIds.size;
|
||||
const selectedNewCount = selectedNewMemberIndexes.size;
|
||||
|
||||
function renderLineChangeSummary(
|
||||
addedLines: number,
|
||||
removedLines: number,
|
||||
emphasize = true,
|
||||
) {
|
||||
const addedClass = emphasize
|
||||
? addedLines > 0
|
||||
? "text-status-added"
|
||||
: "text-muted-foreground"
|
||||
: "text-muted-foreground";
|
||||
const separatorClass = emphasize
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground";
|
||||
const removedClass = emphasize
|
||||
? removedLines > 0
|
||||
? "text-status-deleted"
|
||||
: "text-muted-foreground"
|
||||
: "text-muted-foreground";
|
||||
const opacityClass = emphasize ? "opacity-100" : "opacity-50";
|
||||
|
||||
return (
|
||||
<p
|
||||
className={`shrink-0 text-xs font-medium tabular-nums transition-opacity ${opacityClass}`}
|
||||
>
|
||||
<span className={addedClass}>+{addedLines}</span>
|
||||
<span className={separatorClass}> / </span>
|
||||
<span className={removedClass}>-{removedLines}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function getPromptPreview(
|
||||
prompt: string,
|
||||
emptyFallback: string,
|
||||
maxLength = 240,
|
||||
): string {
|
||||
const normalized = prompt.replace(/\r\n/g, "\n").trim();
|
||||
if (normalized.length === 0) {
|
||||
return emptyFallback;
|
||||
}
|
||||
const firstNonEmptyLine =
|
||||
normalized
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? normalized;
|
||||
if (firstNonEmptyLine.length <= maxLength) {
|
||||
return firstNonEmptyLine;
|
||||
}
|
||||
return `${firstNonEmptyLine.slice(0, maxLength).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function getTeamNamePreview(teamName: string | null | undefined): string {
|
||||
return (teamName ?? "").trim() || "Untitled team";
|
||||
}
|
||||
|
||||
function normalizeTeamTextLines(value: string): string[] {
|
||||
const normalized = value.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n").map((line) => line.trimEnd());
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function getTeamSnapshotLines(
|
||||
teamName: string | null | undefined,
|
||||
description: string | null | undefined,
|
||||
): string[] {
|
||||
return [
|
||||
`name:${(teamName ?? "").trim()}`,
|
||||
...normalizeTeamTextLines(description ?? "").map(
|
||||
(line) => `description:${line}`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function countLineChanges(
|
||||
previousLines: string[],
|
||||
nextLines: string[],
|
||||
): {
|
||||
addedLines: number;
|
||||
removedLines: number;
|
||||
} {
|
||||
const previousLength = previousLines.length;
|
||||
const nextLength = nextLines.length;
|
||||
|
||||
if (previousLength === 0) {
|
||||
return { addedLines: nextLength, removedLines: 0 };
|
||||
}
|
||||
if (nextLength === 0) {
|
||||
return { addedLines: 0, removedLines: previousLength };
|
||||
}
|
||||
|
||||
const lcs = Array.from({ length: previousLength + 1 }, () =>
|
||||
Array<number>(nextLength + 1).fill(0),
|
||||
);
|
||||
|
||||
for (let i = previousLength - 1; i >= 0; i -= 1) {
|
||||
for (let j = nextLength - 1; j >= 0; j -= 1) {
|
||||
if (previousLines[i] === nextLines[j]) {
|
||||
lcs[i][j] = lcs[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
let addedLines = 0;
|
||||
let removedLines = 0;
|
||||
|
||||
while (i < previousLength && j < nextLength) {
|
||||
if (previousLines[i] === nextLines[j]) {
|
||||
i += 1;
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
removedLines += 1;
|
||||
i += 1;
|
||||
} else {
|
||||
addedLines += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
removedLines += previousLength - i;
|
||||
addedLines += nextLength - j;
|
||||
|
||||
return { addedLines, removedLines };
|
||||
}
|
||||
|
||||
const teamLineChanges =
|
||||
team && preview
|
||||
? countLineChanges(
|
||||
getTeamSnapshotLines(team.name, team.description),
|
||||
getTeamSnapshotLines(preview.name, preview.description),
|
||||
)
|
||||
: { addedLines: 0, removedLines: 0 };
|
||||
const hasChanges = hasAnyImportChanges(plan, teamLineChanges);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-3xl flex-col overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 space-y-1 border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Import team</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-card/80 px-3 py-2">
|
||||
<p className="min-w-0 truncate text-sm font-medium">
|
||||
{fileName || "Imported file"}
|
||||
</p>
|
||||
<button
|
||||
aria-label="Clear import"
|
||||
className="shrink-0 text-sm text-primary underline-offset-4 hover:underline"
|
||||
disabled={isPending}
|
||||
onClick={onClear}
|
||||
type="button"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview && plan ? (
|
||||
<div className="space-y-4">
|
||||
{hasChanges ? (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Team info</p>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/80 px-3 py-2.5">
|
||||
<Checkbox
|
||||
checked={updateTeamInfo}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
setUpdateTeamInfo(Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold tracking-tight">
|
||||
{updateTeamInfo
|
||||
? getTeamNamePreview(preview.name)
|
||||
: getTeamNamePreview(team?.name)}
|
||||
</p>
|
||||
<p
|
||||
className={`truncate text-xs ${
|
||||
updateTeamInfo
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{updateTeamInfo
|
||||
? getPromptPreview(
|
||||
preview.description ?? "",
|
||||
"No team description in import.",
|
||||
)
|
||||
: getPromptPreview(
|
||||
team?.description ?? "",
|
||||
"No current team description.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{renderLineChangeSummary(
|
||||
teamLineChanges.addedLines,
|
||||
teamLineChanges.removedLines,
|
||||
updateTeamInfo,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Members that will be updated{" "}
|
||||
<span className="font-bold">
|
||||
({selectedUpdatedCount}/
|
||||
{plan.membersToUpdate.length})
|
||||
</span>
|
||||
</p>
|
||||
{plan.membersToUpdate.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{plan.membersToUpdate.map((member) => {
|
||||
const shouldUpdate =
|
||||
selectedUpdatedPersonaIds.has(
|
||||
member.existing.id,
|
||||
);
|
||||
const previewAvatarUrl = shouldUpdate
|
||||
? (member.imported.avatar_url ??
|
||||
member.existing.avatarUrl)
|
||||
: member.existing.avatarUrl;
|
||||
const previewName = shouldUpdate
|
||||
? member.imported.display_name
|
||||
: member.existing.displayName;
|
||||
const previewPrompt = shouldUpdate
|
||||
? getPromptPreview(
|
||||
member.imported.system_prompt,
|
||||
"No member text in import.",
|
||||
)
|
||||
: getPromptPreview(
|
||||
member.existing.systemPrompt,
|
||||
"No current member text.",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/80 px-3 py-2.5"
|
||||
key={member.existing.id}
|
||||
>
|
||||
<Checkbox
|
||||
checked={shouldUpdate}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleUpdatedPersona(
|
||||
member.existing.id,
|
||||
Boolean(checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ProfileAvatar
|
||||
avatarUrl={previewAvatarUrl}
|
||||
className="h-8 w-8 rounded-lg text-xs"
|
||||
label={previewName}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold tracking-tight">
|
||||
{previewName}
|
||||
</p>
|
||||
<p
|
||||
className={`truncate text-xs ${
|
||||
shouldUpdate
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{previewPrompt}
|
||||
</p>
|
||||
</div>
|
||||
{renderLineChangeSummary(
|
||||
member.addedLines,
|
||||
member.removedLines,
|
||||
shouldUpdate,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Add members{" "}
|
||||
<span className="font-bold">
|
||||
({selectedNewCount}/{plan.newMembers.length})
|
||||
</span>
|
||||
</p>
|
||||
{plan.newMembers.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{plan.newMembers.map((member) => {
|
||||
const shouldAdd = selectedNewMemberIndexes.has(
|
||||
member.importedIndex,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/80 px-3 py-2.5"
|
||||
key={`${member.importedIndex}-${member.imported.display_name}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={shouldAdd}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleNewMember(
|
||||
member.importedIndex,
|
||||
Boolean(checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ProfileAvatar
|
||||
avatarUrl={member.imported.avatar_url}
|
||||
className="h-8 w-8 rounded-lg text-xs"
|
||||
label={member.imported.display_name}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold tracking-tight">
|
||||
{member.imported.display_name}
|
||||
</p>
|
||||
<p
|
||||
className={`truncate text-xs ${
|
||||
shouldAdd
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{getAddMemberSecondaryText(
|
||||
shouldAdd,
|
||||
getPromptPreview(
|
||||
member.imported.system_prompt,
|
||||
"No member text in import.",
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{renderLineChangeSummary(
|
||||
member.addedLines,
|
||||
0,
|
||||
shouldAdd,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Remove members not in import{" "}
|
||||
<span className="font-bold">
|
||||
({removableCount}/{plan.missingMembers.length})
|
||||
</span>
|
||||
</p>
|
||||
{plan.missingMembers.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{plan.missingMembers.map((member) => {
|
||||
const shouldRemove =
|
||||
missingPersonaIdsToRemove.has(
|
||||
member.existing.id,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/80 px-3 py-2.5"
|
||||
key={member.existing.id}
|
||||
>
|
||||
<Checkbox
|
||||
checked={shouldRemove}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleMissingPersona(
|
||||
member.existing.id,
|
||||
Boolean(checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ProfileAvatar
|
||||
avatarUrl={member.existing.avatarUrl}
|
||||
className="h-8 w-8 rounded-lg text-xs"
|
||||
label={member.existing.displayName}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold tracking-tight">
|
||||
{member.existing.displayName}
|
||||
</p>
|
||||
<p
|
||||
className={`truncate text-xs ${
|
||||
shouldRemove
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{getMissingMemberSecondaryText(
|
||||
shouldRemove,
|
||||
getPromptPreview(
|
||||
member.existing.systemPrompt,
|
||||
"No current member text.",
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{renderLineChangeSummary(
|
||||
0,
|
||||
member.removedLines,
|
||||
shouldRemove,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-card/60 px-4 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight text-muted-foreground">
|
||||
no changes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan.unresolvedPersonaIds.length > 0 ? (
|
||||
<p className="rounded-lg border border-amber-300/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800">
|
||||
This team currently references{" "}
|
||||
{plan.unresolvedPersonaIds.length} missing member
|
||||
{plan.unresolvedPersonaIds.length === 1 ? "" : "s"} and
|
||||
they cannot be updated by import.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No import preview is available.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!preview || isPending}
|
||||
onClick={() => {
|
||||
if (removableCount > 0) {
|
||||
setConfirmRemovalOpen(true);
|
||||
return;
|
||||
}
|
||||
void runApply(false);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
Apply update
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<RemoveMembersConfirmDialog
|
||||
open={confirmRemovalOpen}
|
||||
onOpenChange={setConfirmRemovalOpen}
|
||||
isPending={isPending}
|
||||
memberNames={removableMembers.map(
|
||||
(member) => member.existing.displayName,
|
||||
)}
|
||||
onKeepAgents={() => void runApply(false)}
|
||||
onRemoveAgents={() => void runApply(true)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getImportButtonLabel,
|
||||
getImportButtonTone,
|
||||
getImportErrorLabel,
|
||||
IMPORT_ERROR_VISIBILITY_MS,
|
||||
} from "./personaDialogImportState.ts";
|
||||
|
||||
test("getImportErrorLabel falls back to Invalid format for empty values", () => {
|
||||
assert.equal(getImportErrorLabel(null), "Invalid format");
|
||||
assert.equal(getImportErrorLabel(""), "Invalid format");
|
||||
assert.equal(getImportErrorLabel(" "), "Invalid format");
|
||||
});
|
||||
|
||||
test("getImportErrorLabel keeps meaningful parse errors", () => {
|
||||
assert.equal(
|
||||
getImportErrorLabel("Failed to parse persona file."),
|
||||
"Failed to parse persona file.",
|
||||
);
|
||||
});
|
||||
|
||||
test("import button label prioritizes drag-drop affordance", () => {
|
||||
assert.equal(
|
||||
getImportButtonLabel({
|
||||
isWindowFileDragOver: true,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"Drop .persona.json to import",
|
||||
);
|
||||
assert.equal(
|
||||
getImportButtonLabel({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: true,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"Drop .persona.json to import",
|
||||
);
|
||||
});
|
||||
|
||||
test("import button label shows error when not dragging", () => {
|
||||
assert.equal(
|
||||
getImportButtonLabel({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"Invalid format",
|
||||
);
|
||||
assert.equal(
|
||||
getImportButtonLabel({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: null,
|
||||
}),
|
||||
"Import",
|
||||
);
|
||||
});
|
||||
|
||||
test("import button tone follows drag > error > default priority", () => {
|
||||
assert.equal(
|
||||
getImportButtonTone({
|
||||
isWindowFileDragOver: true,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"drag",
|
||||
);
|
||||
assert.equal(
|
||||
getImportButtonTone({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
getImportButtonTone({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: null,
|
||||
}),
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
test("import error visibility duration is 5 seconds", () => {
|
||||
assert.equal(IMPORT_ERROR_VISIBILITY_MS, 5_000);
|
||||
});
|
||||
40
desktop/src/features/agents/ui/personaDialogImportState.ts
Normal file
40
desktop/src/features/agents/ui/personaDialogImportState.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
type ImportButtonStateInput = {
|
||||
isWindowFileDragOver: boolean;
|
||||
isImportDragOver: boolean;
|
||||
importErrorMessage: string | null;
|
||||
};
|
||||
|
||||
export const IMPORT_ERROR_VISIBILITY_MS = 5_000;
|
||||
|
||||
export function getImportErrorLabel(errorMessage: string | null): string {
|
||||
const message = (errorMessage ?? "").trim();
|
||||
return message.length > 0 ? message : "Invalid format";
|
||||
}
|
||||
|
||||
export function getImportButtonLabel({
|
||||
isWindowFileDragOver,
|
||||
isImportDragOver,
|
||||
importErrorMessage,
|
||||
}: ImportButtonStateInput): string {
|
||||
if (isWindowFileDragOver || isImportDragOver) {
|
||||
return "Drop .persona.json to import";
|
||||
}
|
||||
if (importErrorMessage !== null) {
|
||||
return getImportErrorLabel(importErrorMessage);
|
||||
}
|
||||
return "Import";
|
||||
}
|
||||
|
||||
export function getImportButtonTone({
|
||||
isWindowFileDragOver,
|
||||
isImportDragOver,
|
||||
importErrorMessage,
|
||||
}: ImportButtonStateInput): "drag" | "error" | "default" {
|
||||
if (isWindowFileDragOver || isImportDragOver) {
|
||||
return "drag";
|
||||
}
|
||||
if (importErrorMessage !== null) {
|
||||
return "error";
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
@@ -60,6 +60,9 @@ test("editPersonaDialogState preserves the persona id for updates", () => {
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
assert.equal(state.title, "Edit persona");
|
||||
assert.equal(state.description, "");
|
||||
assert.equal(state.submitLabel, "Save changes");
|
||||
assert.deepEqual(state.initialValues, {
|
||||
id: "persona-2",
|
||||
displayName: "Ralph",
|
||||
|
||||
@@ -52,9 +52,8 @@ export function editPersonaDialogState(
|
||||
persona: AgentPersona,
|
||||
): PersonaDialogState {
|
||||
return {
|
||||
title: `Edit ${persona.displayName}`,
|
||||
description:
|
||||
"Update this saved persona. New deployments will use the updated values.",
|
||||
title: "Edit persona",
|
||||
description: "",
|
||||
submitLabel: "Save changes",
|
||||
initialValues: {
|
||||
id: persona.id,
|
||||
|
||||
160
desktop/src/features/agents/ui/personaImportPlan.test.mjs
Normal file
160
desktop/src/features/agents/ui/personaImportPlan.test.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildPersonaImportPlan,
|
||||
hasAnyPersonaImportChanges,
|
||||
} from "./personaImportPlan.ts";
|
||||
|
||||
function createPersona(overrides = {}) {
|
||||
return {
|
||||
id: "persona-1",
|
||||
displayName: "Alice",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "Be helpful.",
|
||||
provider: null,
|
||||
model: null,
|
||||
namePool: [],
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPreview(overrides = {}) {
|
||||
return {
|
||||
displayName: "Alice",
|
||||
systemPrompt: "Be helpful.",
|
||||
avatarDataUrl: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
namePool: [],
|
||||
sourceFile: "alice.persona.json",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildPersonaImportPlan returns empty fields when nothing changed", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona(),
|
||||
preview: createPreview(),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 0);
|
||||
});
|
||||
|
||||
test("buildPersonaImportPlan detects display name change", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona({ displayName: "Alice" }),
|
||||
preview: createPreview({ displayName: "Alicia" }),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 1);
|
||||
assert.equal(plan.fields[0]?.field, "displayName");
|
||||
assert.equal(plan.fields[0]?.label, "Display name");
|
||||
assert.equal(plan.fields[0]?.existingValue, "Alice");
|
||||
assert.equal(plan.fields[0]?.importedValue, "Alicia");
|
||||
assert.equal(plan.fields[0]?.addedLines, 1);
|
||||
assert.equal(plan.fields[0]?.removedLines, 1);
|
||||
});
|
||||
|
||||
test("buildPersonaImportPlan detects system prompt change with line counts", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona({ systemPrompt: "Line one\nLine two" }),
|
||||
preview: createPreview({
|
||||
systemPrompt: "Line one\nLine two\nLine three",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 1);
|
||||
assert.equal(plan.fields[0]?.field, "systemPrompt");
|
||||
assert.equal(plan.fields[0]?.addedLines, 1);
|
||||
assert.equal(plan.fields[0]?.removedLines, 0);
|
||||
});
|
||||
|
||||
test("buildPersonaImportPlan detects avatar change", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona({ avatarUrl: "https://old.png" }),
|
||||
preview: createPreview({ avatarDataUrl: "https://new.png" }),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 1);
|
||||
assert.equal(plan.fields[0]?.field, "avatarUrl");
|
||||
});
|
||||
|
||||
test("buildPersonaImportPlan detects provider change", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona({ provider: "goose" }),
|
||||
preview: createPreview({ provider: "claude" }),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 1);
|
||||
assert.equal(plan.fields[0]?.field, "provider");
|
||||
assert.equal(plan.fields[0]?.label, "Preferred runtime");
|
||||
});
|
||||
|
||||
test("buildPersonaImportPlan detects model change", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona({ model: "gpt-4o" }),
|
||||
preview: createPreview({ model: "claude-sonnet-4-20250514" }),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 1);
|
||||
assert.equal(plan.fields[0]?.field, "model");
|
||||
assert.equal(plan.fields[0]?.label, "Preferred model");
|
||||
});
|
||||
|
||||
test("buildPersonaImportPlan detects name pool change", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona({ namePool: ["Birch", "Compass"] }),
|
||||
preview: createPreview({ namePool: ["Ridge", "Thistle"] }),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 1);
|
||||
assert.equal(plan.fields[0]?.field, "namePool");
|
||||
assert.equal(plan.fields[0]?.label, "Instance name pool");
|
||||
});
|
||||
|
||||
test("buildPersonaImportPlan detects multiple field changes", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona({
|
||||
displayName: "Alice",
|
||||
systemPrompt: "Old prompt",
|
||||
model: "gpt-4o",
|
||||
}),
|
||||
preview: createPreview({
|
||||
displayName: "Alicia",
|
||||
systemPrompt: "New prompt",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 3);
|
||||
const fieldNames = plan.fields.map((f) => f.field);
|
||||
assert.deepEqual(fieldNames, ["displayName", "systemPrompt", "model"]);
|
||||
});
|
||||
|
||||
test("hasAnyPersonaImportChanges returns false for empty plan", () => {
|
||||
assert.equal(hasAnyPersonaImportChanges({ fields: [] }), false);
|
||||
assert.equal(hasAnyPersonaImportChanges(null), false);
|
||||
});
|
||||
|
||||
test("hasAnyPersonaImportChanges returns true when fields have changes", () => {
|
||||
assert.equal(
|
||||
hasAnyPersonaImportChanges({
|
||||
fields: [
|
||||
{
|
||||
field: "displayName",
|
||||
label: "Display name",
|
||||
existingValue: "A",
|
||||
importedValue: "B",
|
||||
addedLines: 1,
|
||||
removedLines: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
196
desktop/src/features/agents/ui/personaImportPlan.ts
Normal file
196
desktop/src/features/agents/ui/personaImportPlan.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
|
||||
type LineChangeCounts = {
|
||||
addedLines: number;
|
||||
removedLines: number;
|
||||
};
|
||||
|
||||
export type PersonaImportFieldChange = {
|
||||
field: string;
|
||||
label: string;
|
||||
existingValue: string;
|
||||
importedValue: string;
|
||||
addedLines: number;
|
||||
removedLines: number;
|
||||
};
|
||||
|
||||
export type PersonaImportPlan = {
|
||||
fields: PersonaImportFieldChange[];
|
||||
};
|
||||
|
||||
type BuildPersonaImportPlanInput = {
|
||||
persona: AgentPersona;
|
||||
preview: ParsedPersonaPreview;
|
||||
};
|
||||
|
||||
function normalizeOptionalText(value: string | null | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizePromptLines(prompt: string): string[] {
|
||||
const normalized = prompt.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n").map((line) => line.trimEnd());
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function countLineChanges(
|
||||
previousLines: string[],
|
||||
nextLines: string[],
|
||||
): LineChangeCounts {
|
||||
const previousLength = previousLines.length;
|
||||
const nextLength = nextLines.length;
|
||||
|
||||
if (previousLength === 0) {
|
||||
return { addedLines: nextLength, removedLines: 0 };
|
||||
}
|
||||
if (nextLength === 0) {
|
||||
return { addedLines: 0, removedLines: previousLength };
|
||||
}
|
||||
|
||||
const lcs = Array.from({ length: previousLength + 1 }, () =>
|
||||
Array<number>(nextLength + 1).fill(0),
|
||||
);
|
||||
|
||||
for (let i = previousLength - 1; i >= 0; i -= 1) {
|
||||
for (let j = nextLength - 1; j >= 0; j -= 1) {
|
||||
if (previousLines[i] === nextLines[j]) {
|
||||
lcs[i][j] = lcs[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
let addedLines = 0;
|
||||
let removedLines = 0;
|
||||
|
||||
while (i < previousLength && j < nextLength) {
|
||||
if (previousLines[i] === nextLines[j]) {
|
||||
i += 1;
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
removedLines += 1;
|
||||
i += 1;
|
||||
} else {
|
||||
addedLines += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
removedLines += previousLength - i;
|
||||
addedLines += nextLength - j;
|
||||
|
||||
return { addedLines, removedLines };
|
||||
}
|
||||
|
||||
function singleLineChanges(
|
||||
existing: string,
|
||||
imported: string,
|
||||
): LineChangeCounts {
|
||||
return countLineChanges(
|
||||
normalizePromptLines(existing),
|
||||
normalizePromptLines(imported),
|
||||
);
|
||||
}
|
||||
|
||||
function namePoolToString(pool: string[] | undefined): string {
|
||||
return (pool ?? []).join(", ");
|
||||
}
|
||||
|
||||
export function buildPersonaImportPlan({
|
||||
persona,
|
||||
preview,
|
||||
}: BuildPersonaImportPlanInput): PersonaImportPlan {
|
||||
const fields: PersonaImportFieldChange[] = [];
|
||||
|
||||
const existingDisplayName = normalizeOptionalText(persona.displayName);
|
||||
const importedDisplayName = normalizeOptionalText(preview.displayName);
|
||||
if (existingDisplayName !== importedDisplayName) {
|
||||
fields.push({
|
||||
field: "displayName",
|
||||
label: "Display name",
|
||||
existingValue: existingDisplayName,
|
||||
importedValue: importedDisplayName,
|
||||
...singleLineChanges(existingDisplayName, importedDisplayName),
|
||||
});
|
||||
}
|
||||
|
||||
const existingAvatar = normalizeOptionalText(persona.avatarUrl);
|
||||
const importedAvatar = normalizeOptionalText(preview.avatarDataUrl);
|
||||
if (existingAvatar !== importedAvatar) {
|
||||
fields.push({
|
||||
field: "avatarUrl",
|
||||
label: "Avatar",
|
||||
existingValue: existingAvatar,
|
||||
importedValue: importedAvatar,
|
||||
...singleLineChanges(existingAvatar, importedAvatar),
|
||||
});
|
||||
}
|
||||
|
||||
const existingPrompt = normalizeOptionalText(persona.systemPrompt);
|
||||
const importedPrompt = normalizeOptionalText(preview.systemPrompt);
|
||||
if (existingPrompt !== importedPrompt) {
|
||||
fields.push({
|
||||
field: "systemPrompt",
|
||||
label: "System prompt",
|
||||
existingValue: existingPrompt,
|
||||
importedValue: importedPrompt,
|
||||
...countLineChanges(
|
||||
normalizePromptLines(existingPrompt),
|
||||
normalizePromptLines(importedPrompt),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const existingProvider = normalizeOptionalText(persona.provider);
|
||||
const importedProvider = normalizeOptionalText(preview.provider);
|
||||
if (existingProvider !== importedProvider) {
|
||||
fields.push({
|
||||
field: "provider",
|
||||
label: "Preferred runtime",
|
||||
existingValue: existingProvider,
|
||||
importedValue: importedProvider,
|
||||
...singleLineChanges(existingProvider, importedProvider),
|
||||
});
|
||||
}
|
||||
|
||||
const existingModel = normalizeOptionalText(persona.model);
|
||||
const importedModel = normalizeOptionalText(preview.model);
|
||||
if (existingModel !== importedModel) {
|
||||
fields.push({
|
||||
field: "model",
|
||||
label: "Preferred model",
|
||||
existingValue: existingModel,
|
||||
importedValue: importedModel,
|
||||
...singleLineChanges(existingModel, importedModel),
|
||||
});
|
||||
}
|
||||
|
||||
const existingNamePool = namePoolToString(persona.namePool);
|
||||
const importedNamePool = namePoolToString(preview.namePool);
|
||||
if (existingNamePool !== importedNamePool) {
|
||||
fields.push({
|
||||
field: "namePool",
|
||||
label: "Instance name pool",
|
||||
existingValue: existingNamePool,
|
||||
importedValue: importedNamePool,
|
||||
...singleLineChanges(existingNamePool, importedNamePool),
|
||||
});
|
||||
}
|
||||
|
||||
return { fields };
|
||||
}
|
||||
|
||||
export function hasAnyPersonaImportChanges(
|
||||
plan: PersonaImportPlan | null,
|
||||
): boolean {
|
||||
return (plan?.fields.length ?? 0) > 0;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getFieldPreview,
|
||||
getFieldSecondaryText,
|
||||
getSelectedFieldCount,
|
||||
} from "./personaImportUpdateState.ts";
|
||||
|
||||
test("getFieldSecondaryText returns imported preview when selected", () => {
|
||||
assert.equal(
|
||||
getFieldSecondaryText(true, "New prompt text", "Old prompt text"),
|
||||
"New prompt text",
|
||||
);
|
||||
});
|
||||
|
||||
test("getFieldSecondaryText returns current value when not selected", () => {
|
||||
assert.equal(
|
||||
getFieldSecondaryText(false, "New prompt text", "Old prompt text"),
|
||||
"Old prompt text",
|
||||
);
|
||||
});
|
||||
|
||||
test("getFieldPreview returns fallback for empty values", () => {
|
||||
assert.equal(getFieldPreview("", "No value."), "No value.");
|
||||
assert.equal(getFieldPreview(" ", "No value."), "No value.");
|
||||
assert.equal(getFieldPreview("\n\n", "No value."), "No value.");
|
||||
});
|
||||
|
||||
test("getFieldPreview returns first non-empty line", () => {
|
||||
assert.equal(getFieldPreview("Hello world", "fallback"), "Hello world");
|
||||
assert.equal(
|
||||
getFieldPreview("\n First line\nSecond line", "fallback"),
|
||||
"First line",
|
||||
);
|
||||
});
|
||||
|
||||
test("getFieldPreview truncates long lines", () => {
|
||||
const longLine = "a".repeat(300);
|
||||
const result = getFieldPreview(longLine, "fallback", 240);
|
||||
assert.equal(result.length, 241);
|
||||
assert.ok(result.endsWith("…"));
|
||||
});
|
||||
|
||||
test("getSelectedFieldCount counts selected fields", () => {
|
||||
const fields = [
|
||||
{
|
||||
field: "displayName",
|
||||
label: "Display name",
|
||||
existingValue: "A",
|
||||
importedValue: "B",
|
||||
addedLines: 1,
|
||||
removedLines: 1,
|
||||
},
|
||||
{
|
||||
field: "systemPrompt",
|
||||
label: "System prompt",
|
||||
existingValue: "X",
|
||||
importedValue: "Y",
|
||||
addedLines: 1,
|
||||
removedLines: 1,
|
||||
},
|
||||
{
|
||||
field: "model",
|
||||
label: "Preferred model",
|
||||
existingValue: "m1",
|
||||
importedValue: "m2",
|
||||
addedLines: 1,
|
||||
removedLines: 1,
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
getSelectedFieldCount(fields, new Set(["displayName", "model"])),
|
||||
2,
|
||||
);
|
||||
assert.equal(getSelectedFieldCount(fields, new Set()), 0);
|
||||
assert.equal(
|
||||
getSelectedFieldCount(
|
||||
fields,
|
||||
new Set(["displayName", "systemPrompt", "model"]),
|
||||
),
|
||||
3,
|
||||
);
|
||||
});
|
||||
36
desktop/src/features/agents/ui/personaImportUpdateState.ts
Normal file
36
desktop/src/features/agents/ui/personaImportUpdateState.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { PersonaImportFieldChange } from "./personaImportPlan";
|
||||
|
||||
export function getFieldSecondaryText(
|
||||
shouldUpdate: boolean,
|
||||
importedPreview: string,
|
||||
currentPreview: string,
|
||||
): string {
|
||||
return shouldUpdate ? importedPreview : currentPreview;
|
||||
}
|
||||
|
||||
export function getFieldPreview(
|
||||
value: string,
|
||||
emptyFallback: string,
|
||||
maxLength = 240,
|
||||
): string {
|
||||
const normalized = value.replace(/\r\n/g, "\n").trim();
|
||||
if (normalized.length === 0) {
|
||||
return emptyFallback;
|
||||
}
|
||||
const firstNonEmptyLine =
|
||||
normalized
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? normalized;
|
||||
if (firstNonEmptyLine.length <= maxLength) {
|
||||
return firstNonEmptyLine;
|
||||
}
|
||||
return `${firstNonEmptyLine.slice(0, maxLength).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function getSelectedFieldCount(
|
||||
fields: PersonaImportFieldChange[],
|
||||
selectedFields: Set<string>,
|
||||
): number {
|
||||
return fields.filter((field) => selectedFields.has(field.field)).length;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getImportButtonLabel,
|
||||
getImportButtonTone,
|
||||
getImportErrorLabel,
|
||||
IMPORT_ERROR_VISIBILITY_MS,
|
||||
} from "./teamDialogImportState.ts";
|
||||
|
||||
test("getImportErrorLabel falls back to Invalid format for empty values", () => {
|
||||
assert.equal(getImportErrorLabel(null), "Invalid format");
|
||||
assert.equal(getImportErrorLabel(""), "Invalid format");
|
||||
assert.equal(getImportErrorLabel(" "), "Invalid format");
|
||||
});
|
||||
|
||||
test("getImportErrorLabel keeps meaningful parse errors", () => {
|
||||
assert.equal(
|
||||
getImportErrorLabel("Failed to parse team file."),
|
||||
"Failed to parse team file.",
|
||||
);
|
||||
});
|
||||
|
||||
test("import button label prioritizes drag-drop affordance", () => {
|
||||
assert.equal(
|
||||
getImportButtonLabel({
|
||||
isWindowFileDragOver: true,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"Drop .team.json to import",
|
||||
);
|
||||
assert.equal(
|
||||
getImportButtonLabel({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: true,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"Drop .team.json to import",
|
||||
);
|
||||
});
|
||||
|
||||
test("import button label shows error when not dragging", () => {
|
||||
assert.equal(
|
||||
getImportButtonLabel({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"Invalid format",
|
||||
);
|
||||
assert.equal(
|
||||
getImportButtonLabel({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: null,
|
||||
}),
|
||||
"Import",
|
||||
);
|
||||
});
|
||||
|
||||
test("import button tone follows drag > error > default priority", () => {
|
||||
assert.equal(
|
||||
getImportButtonTone({
|
||||
isWindowFileDragOver: true,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"drag",
|
||||
);
|
||||
assert.equal(
|
||||
getImportButtonTone({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: "Invalid format",
|
||||
}),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
getImportButtonTone({
|
||||
isWindowFileDragOver: false,
|
||||
isImportDragOver: false,
|
||||
importErrorMessage: null,
|
||||
}),
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
test("import error visibility duration is 5 seconds", () => {
|
||||
assert.equal(IMPORT_ERROR_VISIBILITY_MS, 5_000);
|
||||
});
|
||||
40
desktop/src/features/agents/ui/teamDialogImportState.ts
Normal file
40
desktop/src/features/agents/ui/teamDialogImportState.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
type ImportButtonStateInput = {
|
||||
isWindowFileDragOver: boolean;
|
||||
isImportDragOver: boolean;
|
||||
importErrorMessage: string | null;
|
||||
};
|
||||
|
||||
export const IMPORT_ERROR_VISIBILITY_MS = 5_000;
|
||||
|
||||
export function getImportErrorLabel(errorMessage: string | null): string {
|
||||
const message = (errorMessage ?? "").trim();
|
||||
return message.length > 0 ? message : "Invalid format";
|
||||
}
|
||||
|
||||
export function getImportButtonLabel({
|
||||
isWindowFileDragOver,
|
||||
isImportDragOver,
|
||||
importErrorMessage,
|
||||
}: ImportButtonStateInput): string {
|
||||
if (isWindowFileDragOver || isImportDragOver) {
|
||||
return "Drop .team.json to import";
|
||||
}
|
||||
if (importErrorMessage !== null) {
|
||||
return getImportErrorLabel(importErrorMessage);
|
||||
}
|
||||
return "Import";
|
||||
}
|
||||
|
||||
export function getImportButtonTone({
|
||||
isWindowFileDragOver,
|
||||
isImportDragOver,
|
||||
importErrorMessage,
|
||||
}: ImportButtonStateInput): "drag" | "error" | "default" {
|
||||
if (isWindowFileDragOver || isImportDragOver) {
|
||||
return "drag";
|
||||
}
|
||||
if (importErrorMessage !== null) {
|
||||
return "error";
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
copySelectedPersonaIds,
|
||||
countMissingPersonaIds,
|
||||
filterAvailablePersonaIds,
|
||||
orderPersonasByInitiallySelected,
|
||||
} from "./teamDialogSelection.ts";
|
||||
|
||||
function createPersona(id) {
|
||||
@@ -64,3 +65,28 @@ test("filterAvailablePersonaIds drops missing personas at submit time", () => {
|
||||
["persona:available"],
|
||||
);
|
||||
});
|
||||
|
||||
test("orderPersonasByInitiallySelected keeps initially selected personas at top", () => {
|
||||
const personas = [
|
||||
createPersona("persona:michelangelo"),
|
||||
createPersona("persona:milhouse"),
|
||||
createPersona("persona:ned"),
|
||||
createPersona("persona:raphael"),
|
||||
];
|
||||
|
||||
const ordered = orderPersonasByInitiallySelected(personas, [
|
||||
"persona:milhouse",
|
||||
"persona:ned",
|
||||
"persona:missing",
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
ordered.map((persona) => persona.id),
|
||||
[
|
||||
"persona:milhouse",
|
||||
"persona:ned",
|
||||
"persona:michelangelo",
|
||||
"persona:raphael",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,3 +24,22 @@ export function filterAvailablePersonaIds(
|
||||
const availablePersonaIds = getAvailablePersonaIds(personas);
|
||||
return personaIds.filter((personaId) => availablePersonaIds.has(personaId));
|
||||
}
|
||||
|
||||
export function orderPersonasByInitiallySelected(
|
||||
personas: AgentPersona[],
|
||||
initialSelectedPersonaIds: string[],
|
||||
): AgentPersona[] {
|
||||
const selectedIds = new Set(initialSelectedPersonaIds);
|
||||
const selected: AgentPersona[] = [];
|
||||
const unselected: AgentPersona[] = [];
|
||||
|
||||
for (const persona of personas) {
|
||||
if (selectedIds.has(persona.id)) {
|
||||
selected.push(persona);
|
||||
} else {
|
||||
unselected.push(persona);
|
||||
}
|
||||
}
|
||||
|
||||
return [...selected, ...unselected];
|
||||
}
|
||||
|
||||
131
desktop/src/features/agents/ui/teamImportPlan.test.mjs
Normal file
131
desktop/src/features/agents/ui/teamImportPlan.test.mjs
Normal file
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildTeamImportPlan } from "./teamImportPlan.ts";
|
||||
|
||||
function createPersona(
|
||||
id,
|
||||
displayName,
|
||||
systemPrompt = "Prompt",
|
||||
avatarUrl = null,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
systemPrompt,
|
||||
provider: null,
|
||||
model: null,
|
||||
namePool: [],
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createTeam(personaIds) {
|
||||
return {
|
||||
id: "team-1",
|
||||
name: "Alpha Team",
|
||||
description: "Original description",
|
||||
personaIds,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
test("buildTeamImportPlan categorizes updated, added, and missing members", () => {
|
||||
const personas = [
|
||||
createPersona("persona-1", "Alice", "Old prompt"),
|
||||
createPersona("persona-2", "Bob", "Bob prompt"),
|
||||
];
|
||||
const team = createTeam(["persona-1", "persona-2"]);
|
||||
const preview = {
|
||||
name: "Alpha Team v2",
|
||||
description: "Imported description",
|
||||
personas: [
|
||||
{
|
||||
display_name: "alice",
|
||||
system_prompt: "New prompt",
|
||||
avatar_url: null,
|
||||
},
|
||||
{
|
||||
display_name: "Cara",
|
||||
system_prompt: "Cara prompt",
|
||||
avatar_url: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildTeamImportPlan({ team, personas, preview });
|
||||
|
||||
assert.equal(plan.membersToUpdate.length, 1);
|
||||
assert.equal(plan.membersToUpdate[0]?.existing.id, "persona-1");
|
||||
assert.equal(plan.membersToUpdate[0]?.addedLines, 2);
|
||||
assert.equal(plan.membersToUpdate[0]?.removedLines, 2);
|
||||
assert.equal(plan.newMembers.length, 1);
|
||||
assert.equal(plan.newMembers[0]?.imported.display_name, "Cara");
|
||||
assert.equal(plan.newMembers[0]?.addedLines, 3);
|
||||
assert.equal(plan.missingMembers.length, 1);
|
||||
assert.equal(plan.missingMembers[0]?.existing.id, "persona-2");
|
||||
assert.equal(plan.missingMembers[0]?.removedLines, 3);
|
||||
assert.equal(plan.teamNameChanged, true);
|
||||
assert.equal(plan.teamDescriptionChanged, true);
|
||||
});
|
||||
|
||||
test("buildTeamImportPlan pairs duplicate names in stable order", () => {
|
||||
const personas = [
|
||||
createPersona("persona-1", "Sam", "First prompt"),
|
||||
createPersona("persona-2", "Sam", "Second prompt"),
|
||||
];
|
||||
const team = createTeam(["persona-1", "persona-2"]);
|
||||
const preview = {
|
||||
name: "Alpha Team",
|
||||
description: "Original description",
|
||||
personas: [
|
||||
{
|
||||
display_name: "Sam",
|
||||
system_prompt: "First prompt updated",
|
||||
avatar_url: null,
|
||||
},
|
||||
{
|
||||
display_name: "Sam",
|
||||
system_prompt: "Second prompt updated",
|
||||
avatar_url: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildTeamImportPlan({ team, personas, preview });
|
||||
|
||||
assert.equal(plan.matchedMembers.length, 2);
|
||||
assert.equal(plan.matchedMembers[0]?.existing.id, "persona-1");
|
||||
assert.equal(plan.matchedMembers[1]?.existing.id, "persona-2");
|
||||
assert.equal(plan.matchedMembers[0]?.addedLines, 1);
|
||||
assert.equal(plan.matchedMembers[0]?.removedLines, 1);
|
||||
assert.equal(plan.matchedMembers[1]?.addedLines, 1);
|
||||
assert.equal(plan.matchedMembers[1]?.removedLines, 1);
|
||||
assert.equal(plan.missingMembers.length, 0);
|
||||
assert.equal(plan.newMembers.length, 0);
|
||||
});
|
||||
|
||||
test("buildTeamImportPlan reports unresolved persona ids from stale team membership", () => {
|
||||
const personas = [createPersona("persona-1", "Alice")];
|
||||
const team = createTeam(["persona-1", "persona-missing"]);
|
||||
const preview = {
|
||||
name: "Alpha Team",
|
||||
description: "Original description",
|
||||
personas: [
|
||||
{
|
||||
display_name: "Alice",
|
||||
system_prompt: "Prompt",
|
||||
avatar_url: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildTeamImportPlan({ team, personas, preview });
|
||||
|
||||
assert.deepEqual(plan.unresolvedPersonaIds, ["persona-missing"]);
|
||||
});
|
||||
245
desktop/src/features/agents/ui/teamImportPlan.ts
Normal file
245
desktop/src/features/agents/ui/teamImportPlan.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { ParsedTeamPreview } from "@/shared/api/tauriTeams";
|
||||
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
|
||||
|
||||
type ImportedTeamPersona = ParsedTeamPreview["personas"][number];
|
||||
|
||||
type LineChangeCounts = {
|
||||
addedLines: number;
|
||||
removedLines: number;
|
||||
};
|
||||
|
||||
export type TeamImportMatchedMember = {
|
||||
existing: AgentPersona;
|
||||
imported: ImportedTeamPersona;
|
||||
importedIndex: number;
|
||||
hasChanges: boolean;
|
||||
addedLines: number;
|
||||
removedLines: number;
|
||||
};
|
||||
|
||||
export type TeamImportNewMember = {
|
||||
imported: ImportedTeamPersona;
|
||||
importedIndex: number;
|
||||
addedLines: number;
|
||||
};
|
||||
|
||||
export type TeamImportMissingMember = {
|
||||
existing: AgentPersona;
|
||||
removedLines: number;
|
||||
};
|
||||
|
||||
export type TeamImportPlan = {
|
||||
matchedMembers: TeamImportMatchedMember[];
|
||||
membersToUpdate: TeamImportMatchedMember[];
|
||||
newMembers: TeamImportNewMember[];
|
||||
missingMembers: TeamImportMissingMember[];
|
||||
unresolvedPersonaIds: string[];
|
||||
teamNameChanged: boolean;
|
||||
teamDescriptionChanged: boolean;
|
||||
};
|
||||
|
||||
type BuildTeamImportPlanInput = {
|
||||
team: AgentTeam;
|
||||
personas: AgentPersona[];
|
||||
preview: ParsedTeamPreview;
|
||||
};
|
||||
|
||||
function normalizeName(value: string): string {
|
||||
return value.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value: string | null | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeAvatar(value: string | null | undefined): string | null {
|
||||
const trimmed = normalizeOptionalText(value);
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizePromptLines(prompt: string): string[] {
|
||||
const normalized = prompt.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n").map((line) => line.trimEnd());
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function existingPersonaSnapshotLines(persona: AgentPersona): string[] {
|
||||
return [
|
||||
`display_name:${normalizeOptionalText(persona.displayName)}`,
|
||||
`avatar_url:${normalizeAvatar(persona.avatarUrl) ?? ""}`,
|
||||
...normalizePromptLines(persona.systemPrompt).map(
|
||||
(line) => `prompt:${line}`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function importedPersonaSnapshotLines(persona: ImportedTeamPersona): string[] {
|
||||
return [
|
||||
`display_name:${normalizeOptionalText(persona.display_name)}`,
|
||||
`avatar_url:${normalizeAvatar(persona.avatar_url) ?? ""}`,
|
||||
...normalizePromptLines(persona.system_prompt).map(
|
||||
(line) => `prompt:${line}`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function countLineChanges(
|
||||
previousLines: string[],
|
||||
nextLines: string[],
|
||||
): LineChangeCounts {
|
||||
const previousLength = previousLines.length;
|
||||
const nextLength = nextLines.length;
|
||||
|
||||
if (previousLength === 0) {
|
||||
return {
|
||||
addedLines: nextLength,
|
||||
removedLines: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (nextLength === 0) {
|
||||
return {
|
||||
addedLines: 0,
|
||||
removedLines: previousLength,
|
||||
};
|
||||
}
|
||||
|
||||
const lcs = Array.from({ length: previousLength + 1 }, () =>
|
||||
Array<number>(nextLength + 1).fill(0),
|
||||
);
|
||||
|
||||
for (let i = previousLength - 1; i >= 0; i -= 1) {
|
||||
for (let j = nextLength - 1; j >= 0; j -= 1) {
|
||||
if (previousLines[i] === nextLines[j]) {
|
||||
lcs[i][j] = lcs[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
let addedLines = 0;
|
||||
let removedLines = 0;
|
||||
|
||||
while (i < previousLength && j < nextLength) {
|
||||
if (previousLines[i] === nextLines[j]) {
|
||||
i += 1;
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
removedLines += 1;
|
||||
i += 1;
|
||||
} else {
|
||||
addedLines += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
removedLines += previousLength - i;
|
||||
addedLines += nextLength - j;
|
||||
|
||||
return {
|
||||
addedLines,
|
||||
removedLines,
|
||||
};
|
||||
}
|
||||
|
||||
function getPersonaLineChangeCounts(
|
||||
existing: AgentPersona,
|
||||
imported: ImportedTeamPersona,
|
||||
): LineChangeCounts {
|
||||
return countLineChanges(
|
||||
existingPersonaSnapshotLines(existing),
|
||||
importedPersonaSnapshotLines(imported),
|
||||
);
|
||||
}
|
||||
|
||||
function getImportedPersonaLineCount(persona: ImportedTeamPersona): number {
|
||||
return importedPersonaSnapshotLines(persona).length;
|
||||
}
|
||||
|
||||
function getExistingPersonaLineCount(persona: AgentPersona): number {
|
||||
return existingPersonaSnapshotLines(persona).length;
|
||||
}
|
||||
|
||||
export function buildTeamImportPlan({
|
||||
team,
|
||||
personas,
|
||||
preview,
|
||||
}: BuildTeamImportPlanInput): TeamImportPlan {
|
||||
const personaById = new Map(personas.map((persona) => [persona.id, persona]));
|
||||
const matchedImportedIndexes = new Set<number>();
|
||||
const matchedMembers: TeamImportMatchedMember[] = [];
|
||||
const missingMembers: { existing: AgentPersona }[] = [];
|
||||
const unresolvedPersonaIds: string[] = [];
|
||||
|
||||
for (const personaId of team.personaIds) {
|
||||
const existing = personaById.get(personaId);
|
||||
if (!existing) {
|
||||
unresolvedPersonaIds.push(personaId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingName = normalizeName(existing.displayName);
|
||||
const importedIndex = preview.personas.findIndex(
|
||||
(imported, index) =>
|
||||
!matchedImportedIndexes.has(index) &&
|
||||
normalizeName(imported.display_name) === existingName,
|
||||
);
|
||||
|
||||
if (importedIndex === -1) {
|
||||
missingMembers.push({ existing });
|
||||
continue;
|
||||
}
|
||||
|
||||
matchedImportedIndexes.add(importedIndex);
|
||||
const imported = preview.personas[importedIndex];
|
||||
const { addedLines, removedLines } = getPersonaLineChangeCounts(
|
||||
existing,
|
||||
imported,
|
||||
);
|
||||
matchedMembers.push({
|
||||
existing,
|
||||
imported,
|
||||
importedIndex,
|
||||
hasChanges: addedLines > 0 || removedLines > 0,
|
||||
addedLines,
|
||||
removedLines,
|
||||
});
|
||||
}
|
||||
|
||||
const newMembers: TeamImportNewMember[] = [];
|
||||
preview.personas.forEach((imported, index) => {
|
||||
if (!matchedImportedIndexes.has(index)) {
|
||||
newMembers.push({
|
||||
imported,
|
||||
importedIndex: index,
|
||||
addedLines: getImportedPersonaLineCount(imported),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const membersToUpdate = matchedMembers.filter((member) => member.hasChanges);
|
||||
|
||||
return {
|
||||
matchedMembers,
|
||||
membersToUpdate,
|
||||
newMembers,
|
||||
missingMembers: missingMembers.map((member) => ({
|
||||
...member,
|
||||
removedLines: getExistingPersonaLineCount(member.existing),
|
||||
})),
|
||||
unresolvedPersonaIds,
|
||||
teamNameChanged: normalizeOptionalText(team.name) !== preview.name,
|
||||
teamDescriptionChanged:
|
||||
normalizeOptionalText(team.description) !==
|
||||
normalizeOptionalText(preview.description),
|
||||
};
|
||||
}
|
||||
100
desktop/src/features/agents/ui/teamImportUpdateState.test.mjs
Normal file
100
desktop/src/features/agents/ui/teamImportUpdateState.test.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getAddMemberSecondaryText,
|
||||
getMissingMemberSecondaryText,
|
||||
hasAnyImportChanges,
|
||||
} from "./teamImportUpdateState.ts";
|
||||
|
||||
function createPlan(overrides = {}) {
|
||||
return {
|
||||
matchedMembers: [],
|
||||
membersToUpdate: [],
|
||||
newMembers: [],
|
||||
missingMembers: [],
|
||||
unresolvedPersonaIds: [],
|
||||
teamNameChanged: false,
|
||||
teamDescriptionChanged: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("hasAnyImportChanges returns false when all diffs are zero and no member changes", () => {
|
||||
assert.equal(
|
||||
hasAnyImportChanges(createPlan(), {
|
||||
addedLines: 0,
|
||||
removedLines: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("hasAnyImportChanges returns true when team info has line changes", () => {
|
||||
assert.equal(
|
||||
hasAnyImportChanges(createPlan(), {
|
||||
addedLines: 1,
|
||||
removedLines: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasAnyImportChanges(createPlan(), {
|
||||
addedLines: 0,
|
||||
removedLines: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("hasAnyImportChanges returns true for updated/new/missing members", () => {
|
||||
assert.equal(
|
||||
hasAnyImportChanges(
|
||||
createPlan({
|
||||
membersToUpdate: [{}],
|
||||
}),
|
||||
{ addedLines: 0, removedLines: 0 },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasAnyImportChanges(
|
||||
createPlan({
|
||||
newMembers: [{}],
|
||||
}),
|
||||
{ addedLines: 0, removedLines: 0 },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasAnyImportChanges(
|
||||
createPlan({
|
||||
missingMembers: [{}],
|
||||
}),
|
||||
{ addedLines: 0, removedLines: 0 },
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAddMemberSecondaryText uses explicit unselected message", () => {
|
||||
assert.equal(
|
||||
getAddMemberSecondaryText(true, "Imported prompt preview"),
|
||||
"Imported prompt preview",
|
||||
);
|
||||
assert.equal(
|
||||
getAddMemberSecondaryText(false, "Imported prompt preview"),
|
||||
"Will not be added to this team",
|
||||
);
|
||||
});
|
||||
|
||||
test("getMissingMemberSecondaryText uses current info when unselected", () => {
|
||||
assert.equal(
|
||||
getMissingMemberSecondaryText(true, "Current prompt preview"),
|
||||
"Will be removed from this team",
|
||||
);
|
||||
assert.equal(
|
||||
getMissingMemberSecondaryText(false, "Current prompt preview"),
|
||||
"Current prompt preview",
|
||||
);
|
||||
});
|
||||
33
desktop/src/features/agents/ui/teamImportUpdateState.ts
Normal file
33
desktop/src/features/agents/ui/teamImportUpdateState.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { TeamImportPlan } from "./teamImportPlan";
|
||||
|
||||
export type LineChangeCounts = {
|
||||
addedLines: number;
|
||||
removedLines: number;
|
||||
};
|
||||
|
||||
export function hasAnyImportChanges(
|
||||
plan: TeamImportPlan | null,
|
||||
teamLineChanges: LineChangeCounts,
|
||||
): boolean {
|
||||
return (
|
||||
teamLineChanges.addedLines > 0 ||
|
||||
teamLineChanges.removedLines > 0 ||
|
||||
(plan?.membersToUpdate.length ?? 0) > 0 ||
|
||||
(plan?.newMembers.length ?? 0) > 0 ||
|
||||
(plan?.missingMembers.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function getAddMemberSecondaryText(
|
||||
shouldAdd: boolean,
|
||||
importedPromptPreview: string,
|
||||
): string {
|
||||
return shouldAdd ? importedPromptPreview : "Will not be added to this team";
|
||||
}
|
||||
|
||||
export function getMissingMemberSecondaryText(
|
||||
shouldRemove: boolean,
|
||||
currentPromptPreview: string,
|
||||
): string {
|
||||
return shouldRemove ? "Will be removed from this team" : currentPromptPreview;
|
||||
}
|
||||
172
desktop/src/features/agents/ui/usePersonaImportActions.ts
Normal file
172
desktop/src/features/agents/ui/usePersonaImportActions.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { personasQueryKey } from "@/features/agents/hooks";
|
||||
import {
|
||||
parsePersonaFiles,
|
||||
updatePersona as updatePersonaApi,
|
||||
type ParsedPersonaPreview,
|
||||
} from "@/shared/api/tauriPersonas";
|
||||
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";
|
||||
import { buildPersonaImportPlan } from "./personaImportPlan";
|
||||
import {
|
||||
editPersonaDialogState,
|
||||
type PersonaDialogState,
|
||||
} from "./personaDialogState";
|
||||
|
||||
type FeedbackCallbacks = {
|
||||
clearPersonaFeedback: () => void;
|
||||
setPersonaNoticeMessage: (message: string | null) => void;
|
||||
setPersonaErrorMessage: (message: string | null) => void;
|
||||
setPersonaDialogState: (state: PersonaDialogState | null) => void;
|
||||
};
|
||||
|
||||
export function usePersonaImportActions(
|
||||
libraryPersonas: AgentPersona[],
|
||||
feedback: FeedbackCallbacks,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const [personaImportTarget, setPersonaImportTarget] =
|
||||
React.useState<AgentPersona | null>(null);
|
||||
const [personaImportTargetPreview, setPersonaImportTargetPreview] =
|
||||
React.useState<{ preview: ParsedPersonaPreview; fileName: string } | null>(
|
||||
null,
|
||||
);
|
||||
const [isApplyingPersonaImportUpdate, setIsApplyingPersonaImportUpdate] =
|
||||
React.useState(false);
|
||||
|
||||
async function handleEditDialogImportUpdateFile(
|
||||
personaId: string,
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
) {
|
||||
feedback.clearPersonaFeedback();
|
||||
|
||||
const persona = libraryPersonas.find(
|
||||
(candidate) => candidate.id === personaId,
|
||||
);
|
||||
if (!persona) {
|
||||
const message = "Persona not found. Refresh and try again.";
|
||||
feedback.setPersonaErrorMessage(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await parsePersonaFiles(fileBytes, fileName);
|
||||
if (result.personas.length === 0) {
|
||||
const message = "No valid personas found in file.";
|
||||
feedback.setPersonaErrorMessage(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
const preview = result.personas[0];
|
||||
setPersonaImportTarget(persona);
|
||||
setPersonaImportTargetPreview({ preview, fileName });
|
||||
feedback.setPersonaDialogState(null);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to parse persona file.";
|
||||
feedback.setPersonaErrorMessage(message);
|
||||
throw err instanceof Error ? err : new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeImportUpdateDialog() {
|
||||
setPersonaImportTarget(null);
|
||||
setPersonaImportTargetPreview(null);
|
||||
setIsApplyingPersonaImportUpdate(false);
|
||||
}
|
||||
|
||||
function clearImportUpdateAndReturnToEdit() {
|
||||
if (!personaImportTarget) {
|
||||
closeImportUpdateDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const persona = personaImportTarget;
|
||||
closeImportUpdateDialog();
|
||||
feedback.clearPersonaFeedback();
|
||||
feedback.setPersonaDialogState(editPersonaDialogState(persona));
|
||||
}
|
||||
|
||||
async function handleImportUpdateApply({
|
||||
selectedFields,
|
||||
}: {
|
||||
selectedFields: string[];
|
||||
}) {
|
||||
if (!personaImportTarget || !personaImportTargetPreview) {
|
||||
throw new Error("No persona import update is currently open.");
|
||||
}
|
||||
|
||||
feedback.clearPersonaFeedback();
|
||||
setIsApplyingPersonaImportUpdate(true);
|
||||
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: personaImportTarget,
|
||||
preview: personaImportTargetPreview.preview,
|
||||
});
|
||||
|
||||
const selectedFieldSet = new Set(selectedFields);
|
||||
const preview = personaImportTargetPreview.preview;
|
||||
const existing = personaImportTarget;
|
||||
|
||||
try {
|
||||
const updateInput: UpdatePersonaInput = {
|
||||
id: existing.id,
|
||||
displayName: selectedFieldSet.has("displayName")
|
||||
? preview.displayName
|
||||
: existing.displayName,
|
||||
systemPrompt: selectedFieldSet.has("systemPrompt")
|
||||
? preview.systemPrompt
|
||||
: existing.systemPrompt,
|
||||
avatarUrl: selectedFieldSet.has("avatarUrl")
|
||||
? (preview.avatarDataUrl ?? undefined)
|
||||
: (existing.avatarUrl ?? undefined),
|
||||
provider: selectedFieldSet.has("provider")
|
||||
? (preview.provider ?? undefined)
|
||||
: (existing.provider ?? undefined),
|
||||
model: selectedFieldSet.has("model")
|
||||
? (preview.model ?? undefined)
|
||||
: (existing.model ?? undefined),
|
||||
namePool: selectedFieldSet.has("namePool")
|
||||
? preview.namePool.length > 0
|
||||
? preview.namePool
|
||||
: undefined
|
||||
: existing.namePool.length > 0
|
||||
? [...existing.namePool]
|
||||
: undefined,
|
||||
};
|
||||
|
||||
await updatePersonaApi(updateInput);
|
||||
|
||||
const updatedFieldCount = plan.fields.filter((field) =>
|
||||
selectedFieldSet.has(field.field),
|
||||
).length;
|
||||
|
||||
feedback.setPersonaNoticeMessage(
|
||||
`Updated "${updateInput.displayName}" from import. ${updatedFieldCount} field${updatedFieldCount === 1 ? "" : "s"} updated.`,
|
||||
);
|
||||
|
||||
closeImportUpdateDialog();
|
||||
await queryClient.invalidateQueries({ queryKey: personasQueryKey });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to apply imported persona update.";
|
||||
feedback.setPersonaErrorMessage(message);
|
||||
throw error instanceof Error ? error : new Error(message);
|
||||
} finally {
|
||||
setIsApplyingPersonaImportUpdate(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
personaImportTarget,
|
||||
personaImportTargetPreview,
|
||||
isApplyingPersonaImportUpdate,
|
||||
handleEditDialogImportUpdateFile,
|
||||
handleImportUpdateApply,
|
||||
closeImportUpdateDialog,
|
||||
clearImportUpdateAndReturnToEdit,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
managedAgentsQueryKey,
|
||||
personasQueryKey,
|
||||
teamsQueryKey,
|
||||
useCreateTeamMutation,
|
||||
@@ -16,12 +17,19 @@ import {
|
||||
exportTeamToJson,
|
||||
parseTeamFile,
|
||||
} from "@/shared/api/tauriTeams";
|
||||
import {
|
||||
createPersona,
|
||||
deletePersona,
|
||||
updatePersona,
|
||||
} from "@/shared/api/tauriPersonas";
|
||||
import type {
|
||||
AgentPersona,
|
||||
AgentTeam,
|
||||
Channel,
|
||||
CreateTeamInput,
|
||||
UpdateTeamInput,
|
||||
} from "@/shared/api/types";
|
||||
import { buildTeamImportPlan } from "./teamImportPlan";
|
||||
|
||||
type TeamDialogState = {
|
||||
description: string;
|
||||
@@ -40,6 +48,15 @@ type RefetchCallbacks = {
|
||||
refetchRelayAgents: () => void;
|
||||
};
|
||||
|
||||
type TeamImportUpdateApplyInput = {
|
||||
personas: AgentPersona[];
|
||||
updateTeamInfo: boolean;
|
||||
selectedUpdatedPersonaIds: string[];
|
||||
selectedNewMemberIndexes: number[];
|
||||
missingPersonaIdsToRemove: string[];
|
||||
deleteRemovedAgents: boolean;
|
||||
};
|
||||
|
||||
export function useTeamActions(
|
||||
actions: ActionMessages,
|
||||
refetch: RefetchCallbacks,
|
||||
@@ -65,6 +82,14 @@ export function useTeamActions(
|
||||
preview: ParsedTeamPreview;
|
||||
fileName: string;
|
||||
} | null>(null);
|
||||
const [teamImportTarget, setTeamImportTarget] =
|
||||
React.useState<AgentTeam | null>(null);
|
||||
const [teamImportTargetPreview, setTeamImportTargetPreview] = React.useState<{
|
||||
preview: ParsedTeamPreview;
|
||||
fileName: string;
|
||||
} | null>(null);
|
||||
const [isApplyingTeamImportUpdate, setIsApplyingTeamImportUpdate] =
|
||||
React.useState(false);
|
||||
|
||||
const teams = teamsQuery.data ?? [];
|
||||
|
||||
@@ -169,19 +194,212 @@ export function useTeamActions(
|
||||
});
|
||||
}
|
||||
|
||||
function handleImportFile(fileBytes: number[], fileName: string) {
|
||||
async function handleImportFile(fileBytes: number[], fileName: string) {
|
||||
actions.setActionNoticeMessage(null);
|
||||
actions.setActionErrorMessage(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const preview = await parseTeamFile(fileBytes, fileName);
|
||||
setTeamImportPreview({ preview, fileName });
|
||||
} catch (err) {
|
||||
actions.setActionErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to parse team file.",
|
||||
try {
|
||||
const preview = await parseTeamFile(fileBytes, fileName);
|
||||
setTeamImportPreview({ preview, fileName });
|
||||
} catch (err) {
|
||||
actions.setActionErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to parse team file.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditDialogImportUpdateFile(
|
||||
teamId: string,
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
) {
|
||||
actions.setActionNoticeMessage(null);
|
||||
actions.setActionErrorMessage(null);
|
||||
|
||||
const team = teams.find((candidate) => candidate.id === teamId);
|
||||
if (!team) {
|
||||
const message = "Team not found. Refresh and try again.";
|
||||
actions.setActionErrorMessage(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await parseTeamFile(fileBytes, fileName);
|
||||
setTeamImportTarget(team);
|
||||
setTeamImportTargetPreview({ preview, fileName });
|
||||
setTeamDialogState(null);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to parse team file.";
|
||||
actions.setActionErrorMessage(message);
|
||||
throw err instanceof Error ? err : new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeImportUpdateDialog() {
|
||||
setTeamImportTarget(null);
|
||||
setTeamImportTargetPreview(null);
|
||||
setIsApplyingTeamImportUpdate(false);
|
||||
}
|
||||
|
||||
function clearImportUpdateAndReturnToEdit() {
|
||||
if (!teamImportTarget) {
|
||||
closeImportUpdateDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const team = teamImportTarget;
|
||||
closeImportUpdateDialog();
|
||||
openEditDialog(team);
|
||||
}
|
||||
|
||||
async function handleTeamImportUpdateApply({
|
||||
personas,
|
||||
updateTeamInfo,
|
||||
selectedUpdatedPersonaIds,
|
||||
selectedNewMemberIndexes,
|
||||
missingPersonaIdsToRemove,
|
||||
deleteRemovedAgents,
|
||||
}: TeamImportUpdateApplyInput) {
|
||||
if (!teamImportTarget || !teamImportTargetPreview) {
|
||||
throw new Error("No team import update is currently open.");
|
||||
}
|
||||
|
||||
actions.setActionNoticeMessage(null);
|
||||
actions.setActionErrorMessage(null);
|
||||
setIsApplyingTeamImportUpdate(true);
|
||||
|
||||
const plan = buildTeamImportPlan({
|
||||
team: teamImportTarget,
|
||||
personas,
|
||||
preview: teamImportTargetPreview.preview,
|
||||
});
|
||||
const selectedUpdatedPersonaIdSet = new Set(selectedUpdatedPersonaIds);
|
||||
const selectedNewMemberIndexSet = new Set(selectedNewMemberIndexes);
|
||||
const removePersonaIdSet = new Set(missingPersonaIdsToRemove);
|
||||
|
||||
try {
|
||||
let updatedMembersCount = 0;
|
||||
for (const member of plan.membersToUpdate) {
|
||||
if (!selectedUpdatedPersonaIdSet.has(member.existing.id)) {
|
||||
continue;
|
||||
}
|
||||
await updatePersona({
|
||||
id: member.existing.id,
|
||||
displayName: member.imported.display_name,
|
||||
systemPrompt: member.imported.system_prompt,
|
||||
avatarUrl: member.imported.avatar_url ?? undefined,
|
||||
provider: member.existing.provider ?? undefined,
|
||||
model: member.existing.model ?? undefined,
|
||||
namePool: [...member.existing.namePool],
|
||||
});
|
||||
updatedMembersCount += 1;
|
||||
}
|
||||
|
||||
const createdPersonaIdsByImportedIndex = new Map<number, string>();
|
||||
for (const member of plan.newMembers) {
|
||||
if (!selectedNewMemberIndexSet.has(member.importedIndex)) {
|
||||
continue;
|
||||
}
|
||||
const created = await createPersona({
|
||||
displayName: member.imported.display_name,
|
||||
systemPrompt: member.imported.system_prompt,
|
||||
avatarUrl: member.imported.avatar_url ?? undefined,
|
||||
});
|
||||
createdPersonaIdsByImportedIndex.set(member.importedIndex, created.id);
|
||||
}
|
||||
|
||||
const matchedPersonaIdsByImportedIndex = new Map<number, string>();
|
||||
for (const member of plan.matchedMembers) {
|
||||
matchedPersonaIdsByImportedIndex.set(
|
||||
member.importedIndex,
|
||||
member.existing.id,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
const nextPersonaIds: string[] = [];
|
||||
for (
|
||||
let importedIndex = 0;
|
||||
importedIndex < teamImportTargetPreview.preview.personas.length;
|
||||
importedIndex += 1
|
||||
) {
|
||||
const matchedId = matchedPersonaIdsByImportedIndex.get(importedIndex);
|
||||
if (matchedId) {
|
||||
nextPersonaIds.push(matchedId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdId = createdPersonaIdsByImportedIndex.get(importedIndex);
|
||||
if (createdId) {
|
||||
nextPersonaIds.push(createdId);
|
||||
}
|
||||
}
|
||||
|
||||
const removedMembers = plan.missingMembers.filter((member) =>
|
||||
removePersonaIdSet.has(member.existing.id),
|
||||
);
|
||||
const keptMissingMembers = plan.missingMembers.filter(
|
||||
(member) => !removePersonaIdSet.has(member.existing.id),
|
||||
);
|
||||
for (const member of keptMissingMembers) {
|
||||
nextPersonaIds.push(member.existing.id);
|
||||
}
|
||||
|
||||
const nextTeamName = updateTeamInfo
|
||||
? teamImportTargetPreview.preview.name
|
||||
: teamImportTarget.name;
|
||||
const nextTeamDescription = updateTeamInfo
|
||||
? (teamImportTargetPreview.preview.description ?? undefined)
|
||||
: (teamImportTarget.description ?? undefined);
|
||||
|
||||
await updateTeamMutation.mutateAsync({
|
||||
id: teamImportTarget.id,
|
||||
name: nextTeamName,
|
||||
description: nextTeamDescription,
|
||||
personaIds: nextPersonaIds,
|
||||
});
|
||||
|
||||
let deletedAgentsCount = 0;
|
||||
const deleteFailures: string[] = [];
|
||||
const addedMembersCount = createdPersonaIdsByImportedIndex.size;
|
||||
if (deleteRemovedAgents && removedMembers.length > 0) {
|
||||
for (const member of removedMembers) {
|
||||
try {
|
||||
await deletePersona(member.existing.id);
|
||||
deletedAgentsCount += 1;
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
deleteFailures.push(`${member.existing.displayName}: ${reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actions.setActionNoticeMessage(
|
||||
`Updated "${nextTeamName}" from import. ${updatedMembersCount} member${updatedMembersCount === 1 ? "" : "s"} updated, ${addedMembersCount} added, ${removedMembers.length} removed from the team${deleteRemovedAgents ? `, and ${deletedAgentsCount} removed from My Agents` : ""}.`,
|
||||
);
|
||||
|
||||
if (deleteFailures.length > 0) {
|
||||
actions.setActionErrorMessage(
|
||||
`Team updated, but ${deleteFailures.length} agent${deleteFailures.length === 1 ? "" : "s"} could not be removed: ${deleteFailures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
closeImportUpdateDialog();
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: teamsQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
|
||||
]);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to apply imported team update.";
|
||||
actions.setActionErrorMessage(message);
|
||||
throw error instanceof Error ? error : new Error(message);
|
||||
} finally {
|
||||
setIsApplyingTeamImportUpdate(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTeamImportComplete(
|
||||
@@ -220,12 +438,26 @@ export function useTeamActions(
|
||||
})();
|
||||
}
|
||||
|
||||
async function handleDeleteRemovedPersonas(personaIds: string[]) {
|
||||
for (const id of personaIds) {
|
||||
try {
|
||||
await deletePersona(id);
|
||||
} catch {
|
||||
// Best-effort: persona may already be deleted or in use elsewhere.
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
|
||||
]);
|
||||
}
|
||||
|
||||
function openEditDialog(team: AgentTeam) {
|
||||
actions.setActionNoticeMessage(null);
|
||||
actions.setActionErrorMessage(null);
|
||||
setTeamDialogState({
|
||||
title: `Edit ${team.name}`,
|
||||
description: "Update this team's name, description, or personas.",
|
||||
title: "Edit team",
|
||||
description: "",
|
||||
submitLabel: "Save changes",
|
||||
initialValues: {
|
||||
id: team.id,
|
||||
@@ -251,12 +483,20 @@ export function useTeamActions(
|
||||
setTeamToAddToChannel,
|
||||
teamImportPreview,
|
||||
setTeamImportPreview,
|
||||
teamImportTarget,
|
||||
teamImportTargetPreview,
|
||||
isApplyingTeamImportUpdate,
|
||||
handleTeamSubmit,
|
||||
handleDeleteRemovedPersonas,
|
||||
handleDeleteTeam,
|
||||
handleTeamDeployed,
|
||||
handleExportTeam,
|
||||
handleImportFile,
|
||||
handleEditDialogImportUpdateFile,
|
||||
handleTeamImportComplete,
|
||||
handleTeamImportUpdateApply,
|
||||
closeImportUpdateDialog,
|
||||
clearImportUpdateAndReturnToEdit,
|
||||
openCreateDialog,
|
||||
openDuplicateDialog,
|
||||
openEditDialog,
|
||||
|
||||
Reference in New Issue
Block a user