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:
Taylor Ho
2026-04-10 07:17:19 -10:00
committed by GitHub
parent 79566516ba
commit 41d343bb35
25 changed files with 3412 additions and 176 deletions

View File

@@ -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

View File

@@ -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
}
/>
</>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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()}
/>
</>
);
}

View 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)}
/>
</>
);
}

View File

@@ -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);
});

View 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";
}

View File

@@ -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",

View File

@@ -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,

View 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,
);
});

View 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;
}

View File

@@ -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,
);
});

View 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;
}

View File

@@ -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);
});

View 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";
}

View File

@@ -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",
],
);
});

View File

@@ -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];
}

View 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"]);
});

View 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),
};
}

View 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",
);
});

View 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;
}

View 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,
};
}

View File

@@ -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,