mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
fix(desktop): resolve agent profiles through one archive-aware selector (#5706)
Agent profiles resolve through one shared selector (`pickProfileAgent`) at every entry point — the persona card, the profile panel, and library grouping. That selector ranked instances only by active/name, with no archive awareness, so a relay-archived instance early in file order could hijack the persona card and the profile panel. The persona card also recorded a durable pubkey target, which could strand the panel on an archived identity when the click landed during the archive-snapshot fail-open window. The profile panel's Runtime → Instances roster had the same blind spot: it rendered every persona instance raw, so archived instances appeared mixed in with live ones as if active. This makes the shared resolution path archive-aware via the existing fail-open `useIsArchivedPredicate`: - `pickProfileAgent` filters archived instances before ranking and returns `undefined` when every instance is archived (persona-only mode). - `buildUnifiedGroups` drops archived agents from the standalone `Custom agents` and `Unknown agents` buckets; matched persona groups keep their full list and rely on the selector's persona-only fallback. - `useCanonicalManagedAgentProfile` resolves through a pure `resolveCanonicalManagedAgent` helper that applies the target-provenance rules: a deliberately requested archived pubkey stays exact (so its archive controller can unarchive it, even when a live sibling exists), `preserveRequestedInstance` still pins a Runtime → Instances selection, and non-archived historical navigation keeps its canonicalization. - The persona card's main click records a persona target that re-resolves every render, so it self-corrects to a live sibling after hydration. Deliberate instance navigation and the runtime-error affordance keep their explicit-pubkey path. - The Runtime → Instances roster (`ProfileInstancesSection`) buckets instances off the same predicate via `bucketPersonaInstances`: live rows render as before, and archived rows move under a labeled `Archived` subsection. The instance count reflects both buckets, and archived rows keep their explicit-pubkey click so unarchive stays UI-reachable (the deliberate-navigation path above). The predicate is fail-open (treats every identity as live while the relay archive snapshot loads) and self-exempt, so a cold start never hides an identity and a user is never folded from their own client. While the snapshot is loading, every instance renders in the live list — nothing hidden, nothing labeled. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -6,20 +6,67 @@ import {
|
||||
pickProfileAgent,
|
||||
} from "./pickProfileAgent.ts";
|
||||
|
||||
const NONE_ARCHIVED = () => false;
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
name: "Instance",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("the shared profile target prefers the active persona instance", () => {
|
||||
const stopped = {
|
||||
const stopped = agent({
|
||||
name: "Earlier instance",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "stopped",
|
||||
};
|
||||
const running = {
|
||||
});
|
||||
const running = agent({
|
||||
name: "Current instance",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "running",
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(pickProfileAgent([stopped, running]), running);
|
||||
assert.equal(pickProfileAgent([running, stopped]), running);
|
||||
assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running);
|
||||
assert.equal(pickProfileAgent([running, stopped], NONE_ARCHIVED), running);
|
||||
});
|
||||
|
||||
test("an archived instance early in file order cannot hijack the target", () => {
|
||||
const archived = agent({
|
||||
name: "Archived instance",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "running",
|
||||
});
|
||||
const live = agent({
|
||||
name: "Live instance",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "stopped",
|
||||
});
|
||||
const isArchived = (pubkey) => pubkey === archived.pubkey;
|
||||
|
||||
// Archived is active AND first — without the filter it would win the sort.
|
||||
assert.equal(pickProfileAgent([archived, live], isArchived), live);
|
||||
assert.equal(pickProfileAgent([live, archived], isArchived), live);
|
||||
});
|
||||
|
||||
test("all instances archived yields undefined for persona-only mode", () => {
|
||||
const first = agent({ pubkey: "a".repeat(64) });
|
||||
const second = agent({ pubkey: "b".repeat(64) });
|
||||
|
||||
assert.equal(
|
||||
pickProfileAgent([first, second], () => true),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("a fail-open predicate keeps every instance eligible while loading", () => {
|
||||
const stopped = agent({ pubkey: "a".repeat(64), status: "stopped" });
|
||||
const running = agent({ pubkey: "b".repeat(64), status: "running" });
|
||||
|
||||
// Fail-open (all false) during the archive-snapshot window: normal ranking.
|
||||
assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running);
|
||||
});
|
||||
|
||||
test("a direct-opened active instance is never redirected to a sibling", () => {
|
||||
@@ -36,7 +83,10 @@ test("a direct-opened active instance is never redirected to a sibling", () => {
|
||||
status: "running",
|
||||
};
|
||||
|
||||
assert.equal(pickDirectProfileAgent(clicked, [sibling, clicked]), clicked);
|
||||
assert.equal(
|
||||
pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED),
|
||||
clicked,
|
||||
);
|
||||
});
|
||||
|
||||
test("a direct-opened inactive instance redirects to the active sibling", () => {
|
||||
@@ -52,7 +102,7 @@ test("a direct-opened inactive instance redirects to the active sibling", () =>
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
pickDirectProfileAgent(historical, [historical, current]),
|
||||
pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED),
|
||||
current,
|
||||
);
|
||||
});
|
||||
@@ -70,8 +120,8 @@ test("a direct-opened inactive instance with no active sibling stays put", () =>
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
pickDirectProfileAgent(clicked, [clicked, otherStopped]),
|
||||
pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED),
|
||||
clicked,
|
||||
);
|
||||
assert.equal(pickDirectProfileAgent(clicked, []), clicked);
|
||||
assert.equal(pickDirectProfileAgent(clicked, [], NONE_ARCHIVED), clicked);
|
||||
});
|
||||
|
||||
@@ -7,14 +7,26 @@ import type { ManagedAgent } from "@/shared/api/types";
|
||||
* A persona can have several historical agent instances. Keeping this rule in
|
||||
* one place prevents an avatar click on an older message from opening a
|
||||
* different detail surface than the card in the Agents library.
|
||||
*
|
||||
* Relay-archived instances are never eligible, so an archived record early in
|
||||
* file order can't hijack the persona target. Returns `undefined` when every
|
||||
* instance is archived — the card then renders in persona-only mode. The
|
||||
* `isArchived` predicate is fail-open (returns `false` while the relay archive
|
||||
* snapshot loads), so a cold start never briefly picks nothing.
|
||||
*/
|
||||
export function pickProfileAgent(agents: readonly ManagedAgent[]) {
|
||||
return [...agents].sort((left, right) => {
|
||||
const activeDiff =
|
||||
Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left));
|
||||
if (activeDiff !== 0) return activeDiff;
|
||||
return left.name.localeCompare(right.name);
|
||||
})[0];
|
||||
export function pickProfileAgent(
|
||||
agents: readonly ManagedAgent[],
|
||||
isArchived: (pubkey: string) => boolean,
|
||||
) {
|
||||
return [...agents]
|
||||
.filter((agent) => !isArchived(agent.pubkey))
|
||||
.sort((left, right) => {
|
||||
const activeDiff =
|
||||
Number(isManagedAgentActive(right)) -
|
||||
Number(isManagedAgentActive(left));
|
||||
if (activeDiff !== 0) return activeDiff;
|
||||
return left.name.localeCompare(right.name);
|
||||
})[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,13 +38,15 @@ export function pickProfileAgent(agents: readonly ManagedAgent[]) {
|
||||
* "tighten access" save widen the wrong agent. But when the clicked instance
|
||||
* is inactive and the persona has an active instance elsewhere (an avatar on
|
||||
* an old message from a retired instance), redirect to the active one so the
|
||||
* panel matches the Agents library.
|
||||
* panel matches the Agents library. The `isArchived` predicate keeps that
|
||||
* redirect from ever landing on an archived sibling.
|
||||
*/
|
||||
export function pickDirectProfileAgent(
|
||||
directAgent: ManagedAgent,
|
||||
personaInstances: readonly ManagedAgent[],
|
||||
isArchived: (pubkey: string) => boolean,
|
||||
) {
|
||||
if (isManagedAgentActive(directAgent)) return directAgent;
|
||||
const canonical = pickProfileAgent(personaInstances);
|
||||
const canonical = pickProfileAgent(personaInstances, isArchived);
|
||||
return canonical && isManagedAgentActive(canonical) ? canonical : directAgent;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModel
|
||||
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent";
|
||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||
import { useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
|
||||
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
|
||||
@@ -94,9 +95,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
onDeletePersona,
|
||||
} = props;
|
||||
|
||||
const isArchived = useIsArchivedPredicate();
|
||||
const { groups, ungrouped, unknown } = React.useMemo(
|
||||
() => buildUnifiedGroups(personas, agents),
|
||||
[personas, agents],
|
||||
() => buildUnifiedGroups(personas, agents, isArchived),
|
||||
[personas, agents, isArchived],
|
||||
);
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
function toggle(key: string) {
|
||||
@@ -129,7 +131,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
onClick={onOpenCatalog}
|
||||
/>
|
||||
{groups.map((group) => {
|
||||
const profileAgent = pickProfileAgent(group.agents);
|
||||
const profileAgent = pickProfileAgent(group.agents, isArchived);
|
||||
return (
|
||||
<AgentPersonaCard
|
||||
actions={(effectiveAvatarUrl, isEffectiveAvatarLoading) => (
|
||||
@@ -265,7 +267,6 @@ function AgentPersonaCard({
|
||||
const friendlyError = agent
|
||||
? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy
|
||||
: null;
|
||||
const opensRuntimeTab = Boolean(agent && friendlyError && !isActive);
|
||||
|
||||
return (
|
||||
<AgentIdentityCard
|
||||
@@ -313,13 +314,15 @@ function AgentPersonaCard({
|
||||
label={title}
|
||||
modelLabel={modelLabel}
|
||||
onClick={() => {
|
||||
if (agent) {
|
||||
onOpenAgentProfile(
|
||||
agent.pubkey,
|
||||
opensRuntimeTab ? { tab: "runtime" } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The card's main click always opens the PERSONA target, never an
|
||||
// explicit pubkey. A pubkey target is durable in the panel, so a pick
|
||||
// made during the archive-snapshot fail-open window would strand the
|
||||
// panel on an archived identity after hydration (Carl's cold-hydration
|
||||
// race). A persona target re-resolves every render through the shared
|
||||
// archive-aware selector, so it self-corrects to a live sibling — or
|
||||
// persona-only mode when every instance is archived. Deliberate
|
||||
// instance navigation and the runtime-error affordance keep their
|
||||
// explicit-pubkey path via the avatar control below.
|
||||
onOpenPersonaProfile(persona);
|
||||
}}
|
||||
statusBadge={
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Rule 1 regression: the persona card's MAIN click records a PERSONA target,
|
||||
* never an explicit pubkey — even during the archive-snapshot fail-open window,
|
||||
* when pickProfileAgent transiently selects an archived sibling.
|
||||
*
|
||||
* Why a mounted render test rather than a pure resolver test:
|
||||
* resolveCanonicalManagedAgent (unit-tested separately) proves a persona
|
||||
* target self-corrects to the live sibling after hydration — but it assumes
|
||||
* the card emits a persona target. The defect being closed is the card
|
||||
* emitting a durable *pubkey* target that survives hydration. Only mounting
|
||||
* the real card and firing its main click catches a mutation that reverts
|
||||
* onClick back to onOpenAgentProfile(agent.pubkey). AgentPersonaCard is
|
||||
* module-local, so the whole section is mounted.
|
||||
*
|
||||
* Fail-open is reproduced faithfully: the list_archived_identities IPC call
|
||||
* never settles, so useIsArchivedPredicate returns all-live at click time and
|
||||
* pickProfileAgent selects the archived-first sibling — exactly the transient
|
||||
* window the durable pubkey target used to strand the panel on.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
// Track every client so afterEach can drop cached queries. A query left pending
|
||||
// (the fail-open archive snapshot) plus react-query's default gcTime schedules
|
||||
// timers that outlive the test and stall the shared `pnpm test` process.
|
||||
const clients = [];
|
||||
|
||||
let act;
|
||||
let cleanup;
|
||||
let fireEvent;
|
||||
let render;
|
||||
let screen;
|
||||
let createElement;
|
||||
let QueryClient;
|
||||
let QueryClientProvider;
|
||||
let UnifiedAgentsSection;
|
||||
|
||||
const ipcHandlers = new Map();
|
||||
|
||||
const SELF_PK = "c".repeat(64);
|
||||
const ARCHIVED_PK = "a".repeat(64);
|
||||
const LIVE_PK = "b".repeat(64);
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
pubkey: LIVE_PK,
|
||||
name: "Instance",
|
||||
personaId: "persona-1",
|
||||
status: "stopped",
|
||||
model: null,
|
||||
modelSource: "global",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
needsRestart: false,
|
||||
personaOrphaned: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function persona(overrides = {}) {
|
||||
return {
|
||||
id: "persona-1",
|
||||
displayName: "Fizz Prime",
|
||||
avatarUrl: null,
|
||||
model: null,
|
||||
isBuiltIn: false,
|
||||
sourceTeam: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseProps(overrides = {}) {
|
||||
return {
|
||||
defaultModel: "gpt-x",
|
||||
actionErrorMessage: null,
|
||||
actionNoticeMessage: null,
|
||||
agents: [],
|
||||
agentsError: null,
|
||||
isActionPending: false,
|
||||
isAgentsLoading: false,
|
||||
restartingAgentPubkey: null,
|
||||
startingAgentPubkey: null,
|
||||
startingPersonaIds: new Set(),
|
||||
onOpenAgentProfile: () => {},
|
||||
onOpenPersonaProfile: () => {},
|
||||
onRestartAgent: () => {},
|
||||
onStartAgent: () => {},
|
||||
onStartPersona: () => {},
|
||||
personas: [],
|
||||
personasError: null,
|
||||
personaFeedbackErrorMessage: null,
|
||||
personaFeedbackNoticeMessage: null,
|
||||
isPersonasLoading: false,
|
||||
isPersonasPending: false,
|
||||
onOpenCatalog: () => {},
|
||||
onDuplicatePersona: () => {},
|
||||
onEditPersona: () => {},
|
||||
onSharePersona: () => {},
|
||||
onDeactivatePersona: () => {},
|
||||
onDeletePersona: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSection(props) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
clients.push(client);
|
||||
return render(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(UnifiedAgentsSection, props),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
window: dom.window,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
writable: true,
|
||||
});
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: true,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
dom.window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd, args) => {
|
||||
const handler = ipcHandlers.get(cmd);
|
||||
if (handler) return handler(args);
|
||||
return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`));
|
||||
},
|
||||
transformCallback: () => Math.random(),
|
||||
};
|
||||
|
||||
({ act, cleanup, fireEvent, render, screen } = await import(
|
||||
"@testing-library/react"
|
||||
));
|
||||
({ createElement } = await import("react"));
|
||||
({ QueryClient, QueryClientProvider } = await import(
|
||||
"@tanstack/react-query"
|
||||
));
|
||||
({ UnifiedAgentsSection } = await import("./UnifiedAgentsSection.tsx"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
for (const client of clients.splice(0)) {
|
||||
client.cancelQueries();
|
||||
client.clear();
|
||||
}
|
||||
ipcHandlers.clear();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
function installFailOpenIpc() {
|
||||
ipcHandlers.set("get_identity", () =>
|
||||
Promise.resolve({ pubkey: SELF_PK, display_name: "Me" }),
|
||||
);
|
||||
// Never resolves: the archive snapshot stays loading, so the predicate is
|
||||
// fail-open (treats every identity as live) for the whole test.
|
||||
ipcHandlers.set("list_archived_identities", () => new Promise(() => {}));
|
||||
ipcHandlers.set("get_user_profile", () =>
|
||||
Promise.resolve({
|
||||
pubkey: LIVE_PK,
|
||||
display_name: null,
|
||||
avatar_url: null,
|
||||
about: null,
|
||||
nip05_handle: null,
|
||||
owner_pubkey: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("persona card main click records a persona target, never an explicit pubkey", async () => {
|
||||
installFailOpenIpc();
|
||||
|
||||
let recordedPersona;
|
||||
const onOpenAgentProfile = () => {
|
||||
throw new Error("card main click must not open an explicit pubkey target");
|
||||
};
|
||||
const onOpenPersonaProfile = (persona) => {
|
||||
recordedPersona = persona;
|
||||
};
|
||||
|
||||
// Archived sibling sorts first by name, so under fail-open pickProfileAgent
|
||||
// selects it — the card displays the archived identity at click time. A
|
||||
// durable pubkey target would strand the panel there after hydration.
|
||||
const agents = [
|
||||
agent({ pubkey: ARCHIVED_PK, name: "Archived Sibling" }),
|
||||
agent({ pubkey: LIVE_PK, name: "Zed Sibling" }),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
renderSection(
|
||||
baseProps({
|
||||
agents,
|
||||
personas: [persona()],
|
||||
onOpenAgentProfile,
|
||||
onOpenPersonaProfile,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Fizz Prime agent profile" }),
|
||||
);
|
||||
|
||||
assert.ok(recordedPersona, "the click must record a persona target");
|
||||
assert.equal(recordedPersona.id, "persona-1");
|
||||
});
|
||||
|
||||
test("persona card main click records a persona target even for a stopped errored agent", async () => {
|
||||
installFailOpenIpc();
|
||||
|
||||
let recordedPersona;
|
||||
await act(async () => {
|
||||
renderSection(
|
||||
baseProps({
|
||||
agents: [
|
||||
agent({
|
||||
pubkey: LIVE_PK,
|
||||
name: "Errored",
|
||||
status: "stopped",
|
||||
lastError: "boom",
|
||||
}),
|
||||
],
|
||||
personas: [persona()],
|
||||
onOpenAgentProfile: () => {
|
||||
throw new Error("main click must not open an explicit pubkey target");
|
||||
},
|
||||
onOpenPersonaProfile: (persona) => {
|
||||
recordedPersona = persona;
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Fizz Prime agent profile" }),
|
||||
);
|
||||
|
||||
assert.equal(recordedPersona?.id, "persona-1");
|
||||
});
|
||||
|
||||
test("errored avatar affordance still opens the explicit pubkey on the runtime tab", async () => {
|
||||
installFailOpenIpc();
|
||||
|
||||
const opened = [];
|
||||
await act(async () => {
|
||||
renderSection(
|
||||
baseProps({
|
||||
agents: [
|
||||
agent({
|
||||
pubkey: LIVE_PK,
|
||||
name: "Errored",
|
||||
status: "stopped",
|
||||
lastError: "boom",
|
||||
}),
|
||||
],
|
||||
personas: [persona()],
|
||||
onOpenAgentProfile: (pubkey, options) => {
|
||||
opened.push({ pubkey, options });
|
||||
},
|
||||
onOpenPersonaProfile: () => {
|
||||
throw new Error("the error affordance must open the explicit pubkey");
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// The error badge is the deliberate explicit-pubkey path preserved for
|
||||
// manage/diagnose access; it is the reserved instance/error navigation that
|
||||
// rule 1 keeps valid, unchanged by the main-click fix.
|
||||
fireEvent.click(screen.getByTestId(`agent-runtime-error-${LIVE_PK}`));
|
||||
|
||||
assert.deepEqual(opened, [{ pubkey: LIVE_PK, options: { tab: "runtime" } }]);
|
||||
});
|
||||
77
desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs
Normal file
77
desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildUnifiedGroups } from "./unifiedAgentGroups.ts";
|
||||
|
||||
const NONE_ARCHIVED = () => false;
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
name: "Agent",
|
||||
pubkey: "a".repeat(64),
|
||||
personaId: null,
|
||||
status: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function persona(overrides = {}) {
|
||||
return { id: "persona-1", displayName: "Persona", ...overrides };
|
||||
}
|
||||
|
||||
test("archived standalone custom agents are omitted while live peers remain", () => {
|
||||
const archived = agent({ pubkey: "a".repeat(64), personaId: null });
|
||||
const live = agent({ pubkey: "b".repeat(64), personaId: null });
|
||||
const isArchived = (pubkey) => pubkey === archived.pubkey;
|
||||
|
||||
const { ungrouped } = buildUnifiedGroups([], [archived, live], isArchived);
|
||||
|
||||
assert.deepEqual(
|
||||
ungrouped.map((agent) => agent.pubkey),
|
||||
[live.pubkey],
|
||||
);
|
||||
});
|
||||
|
||||
test("archived unknown-persona agents are omitted while live peers remain", () => {
|
||||
const archived = agent({ pubkey: "a".repeat(64), personaId: "orphan" });
|
||||
const live = agent({ pubkey: "b".repeat(64), personaId: "orphan" });
|
||||
const isArchived = (pubkey) => pubkey === archived.pubkey;
|
||||
|
||||
// No persona matches "orphan", so both land in the unknown bucket.
|
||||
const { unknown } = buildUnifiedGroups([], [archived, live], isArchived);
|
||||
|
||||
assert.deepEqual(
|
||||
unknown.map((agent) => agent.pubkey),
|
||||
[live.pubkey],
|
||||
);
|
||||
});
|
||||
|
||||
test("matched persona groups keep their full instance list including archived", () => {
|
||||
const archived = agent({ pubkey: "a".repeat(64), personaId: "persona-1" });
|
||||
const live = agent({ pubkey: "b".repeat(64), personaId: "persona-1" });
|
||||
const isArchived = (pubkey) => pubkey === archived.pubkey;
|
||||
|
||||
// The card resolves its own target via pickProfileAgent; the group keeps the
|
||||
// archived record so an all-archived persona still forms a card in
|
||||
// persona-only mode rather than vanishing from the library.
|
||||
const { groups } = buildUnifiedGroups(
|
||||
[persona()],
|
||||
[archived, live],
|
||||
isArchived,
|
||||
);
|
||||
|
||||
assert.equal(groups.length, 1);
|
||||
assert.deepEqual(
|
||||
groups[0].agents.map((agent) => agent.pubkey).sort(),
|
||||
[archived.pubkey, live.pubkey].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("a fail-open predicate keeps every standalone agent discoverable", () => {
|
||||
const first = agent({ pubkey: "a".repeat(64), personaId: null });
|
||||
const second = agent({ pubkey: "b".repeat(64), personaId: null });
|
||||
|
||||
const { ungrouped } = buildUnifiedGroups([], [first, second], NONE_ARCHIVED);
|
||||
|
||||
assert.equal(ungrouped.length, 2);
|
||||
});
|
||||
@@ -2,16 +2,28 @@ import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] };
|
||||
|
||||
/**
|
||||
* Group managed agents under their personas for the Agents library.
|
||||
*
|
||||
* Archived instances are dropped from the standalone `ungrouped` (custom
|
||||
* agents) and `unknown` buckets so a relay-archived identity never shows as a
|
||||
* clickable library card of its own. Matched persona groups keep their full
|
||||
* instance list — the persona card resolves its own target through
|
||||
* `pickProfileAgent`, which applies the same `isArchived` filter and falls back
|
||||
* to persona-only mode when every instance is archived. `isArchived` is
|
||||
* fail-open (returns `false` while the relay archive snapshot loads).
|
||||
*/
|
||||
export function buildUnifiedGroups(
|
||||
personas: AgentPersona[],
|
||||
agents: ManagedAgent[],
|
||||
isArchived: (pubkey: string) => boolean,
|
||||
) {
|
||||
const byPersonaId = new Map<string, ManagedAgent[]>();
|
||||
const ungrouped: ManagedAgent[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
if (!agent.personaId) {
|
||||
ungrouped.push(agent);
|
||||
if (!isArchived(agent.pubkey)) ungrouped.push(agent);
|
||||
} else {
|
||||
const list = byPersonaId.get(agent.personaId) ?? [];
|
||||
list.push(agent);
|
||||
@@ -27,7 +39,9 @@ export function buildUnifiedGroups(
|
||||
|
||||
const unknown: ManagedAgent[] = [];
|
||||
for (const [id, list] of byPersonaId) {
|
||||
if (!matched.has(id)) unknown.push(...list);
|
||||
if (!matched.has(id)) {
|
||||
unknown.push(...list.filter((agent) => !isArchived(agent.pubkey)));
|
||||
}
|
||||
}
|
||||
|
||||
return { groups, ungrouped, unknown };
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { bucketPersonaInstances } from "./useCanonicalManagedAgentProfile.ts";
|
||||
|
||||
const LIVE_PK = "b".repeat(64);
|
||||
const SECOND_LIVE_PK = "c".repeat(64);
|
||||
const ARCHIVED_PK = "a".repeat(64);
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
name: "Instance",
|
||||
pubkey: LIVE_PK,
|
||||
personaId: "persona-1",
|
||||
status: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("mixed roster splits archived from live, preserving order", () => {
|
||||
const archived = agent({ pubkey: ARCHIVED_PK });
|
||||
const live = agent({ pubkey: LIVE_PK });
|
||||
const secondLive = agent({ pubkey: SECOND_LIVE_PK });
|
||||
|
||||
const { live: liveBucket, archived: archivedBucket } = bucketPersonaInstances(
|
||||
[archived, live, secondLive],
|
||||
(pubkey) => pubkey === ARCHIVED_PK,
|
||||
);
|
||||
|
||||
assert.deepEqual(liveBucket, [live, secondLive]);
|
||||
assert.deepEqual(archivedBucket, [archived]);
|
||||
});
|
||||
|
||||
test("an all-archived persona puts every instance in the archived bucket", () => {
|
||||
const first = agent({ pubkey: ARCHIVED_PK });
|
||||
const second = agent({ pubkey: LIVE_PK });
|
||||
|
||||
const { live, archived } = bucketPersonaInstances(
|
||||
[first, second],
|
||||
() => true,
|
||||
);
|
||||
|
||||
assert.deepEqual(live, []);
|
||||
assert.deepEqual(archived, [first, second]);
|
||||
});
|
||||
|
||||
test("fail-open while loading keeps every instance live", () => {
|
||||
// The predicate returns `false` for all pubkeys until the archive snapshot
|
||||
// loads; nothing is bucketed as archived, so nothing is labeled or hidden.
|
||||
const archived = agent({ pubkey: ARCHIVED_PK });
|
||||
const live = agent({ pubkey: LIVE_PK });
|
||||
|
||||
const { live: liveBucket, archived: archivedBucket } = bucketPersonaInstances(
|
||||
[archived, live],
|
||||
() => false,
|
||||
);
|
||||
|
||||
assert.deepEqual(liveBucket, [archived, live]);
|
||||
assert.deepEqual(archivedBucket, []);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveCanonicalManagedAgent } from "./useCanonicalManagedAgentProfile.ts";
|
||||
|
||||
const LIVE_PK = "b".repeat(64);
|
||||
const ARCHIVED_PK = "a".repeat(64);
|
||||
const HISTORICAL_PK = "c".repeat(64);
|
||||
const NONE_ARCHIVED = () => false;
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
name: "Instance",
|
||||
pubkey: LIVE_PK,
|
||||
personaId: "persona-1",
|
||||
status: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("a persona target with a live sibling resolves to the live instance", () => {
|
||||
const archived = agent({ pubkey: ARCHIVED_PK, status: "running" });
|
||||
const live = agent({ pubkey: LIVE_PK, status: "stopped" });
|
||||
|
||||
const resolved = resolveCanonicalManagedAgent({
|
||||
directManagedAgent: undefined,
|
||||
isArchived: (pubkey) => pubkey === ARCHIVED_PK,
|
||||
personaInstances: [archived, live],
|
||||
preferDirectManagedAgent: false,
|
||||
preserveRequestedInstance: false,
|
||||
pubkey: undefined,
|
||||
});
|
||||
|
||||
assert.equal(resolved, live);
|
||||
});
|
||||
|
||||
test("a persona target with all instances archived resolves to undefined", () => {
|
||||
const first = agent({ pubkey: ARCHIVED_PK });
|
||||
const second = agent({ pubkey: HISTORICAL_PK });
|
||||
|
||||
const resolved = resolveCanonicalManagedAgent({
|
||||
directManagedAgent: undefined,
|
||||
isArchived: () => true,
|
||||
personaInstances: [first, second],
|
||||
preferDirectManagedAgent: false,
|
||||
preserveRequestedInstance: false,
|
||||
pubkey: undefined,
|
||||
});
|
||||
|
||||
assert.equal(resolved, undefined);
|
||||
});
|
||||
|
||||
test("an explicit archived pubkey stays exact even when a live sibling exists", () => {
|
||||
const archivedDirect = agent({ pubkey: ARCHIVED_PK });
|
||||
const live = agent({ pubkey: LIVE_PK, status: "running" });
|
||||
|
||||
const resolved = resolveCanonicalManagedAgent({
|
||||
directManagedAgent: archivedDirect,
|
||||
isArchived: (pubkey) => pubkey === ARCHIVED_PK,
|
||||
personaInstances: [archivedDirect, live],
|
||||
preferDirectManagedAgent: false,
|
||||
preserveRequestedInstance: false,
|
||||
pubkey: ARCHIVED_PK,
|
||||
});
|
||||
|
||||
// Without the exactness short-circuit the selector would drop the archived
|
||||
// record and return `live`, stranding the unarchive controller.
|
||||
assert.equal(resolved, archivedDirect);
|
||||
});
|
||||
|
||||
test("an explicit archived pubkey with no managed record resolves to undefined so the panel keeps the requested key", () => {
|
||||
// A historical archived pubkey with no current managed record: directManaged
|
||||
// is undefined, and the panel falls back to the requested pubkey verbatim.
|
||||
const resolved = resolveCanonicalManagedAgent({
|
||||
directManagedAgent: undefined,
|
||||
isArchived: (pubkey) => pubkey === HISTORICAL_PK,
|
||||
personaInstances: [],
|
||||
preferDirectManagedAgent: false,
|
||||
preserveRequestedInstance: false,
|
||||
pubkey: HISTORICAL_PK,
|
||||
});
|
||||
|
||||
assert.equal(resolved, undefined);
|
||||
});
|
||||
|
||||
test("a preserved requested instance pins the exact record over canonicalization", () => {
|
||||
const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" });
|
||||
const live = agent({ pubkey: LIVE_PK, status: "running" });
|
||||
|
||||
const resolved = resolveCanonicalManagedAgent({
|
||||
directManagedAgent: requested,
|
||||
isArchived: NONE_ARCHIVED,
|
||||
personaInstances: [requested, live],
|
||||
preferDirectManagedAgent: false,
|
||||
preserveRequestedInstance: true,
|
||||
pubkey: HISTORICAL_PK,
|
||||
});
|
||||
|
||||
assert.equal(resolved, requested);
|
||||
});
|
||||
|
||||
test("a non-archived historical pubkey canonicalizes to the live persona instance", () => {
|
||||
// Rule 5: #5788 canonicalization is retained for non-archived navigation.
|
||||
const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" });
|
||||
const live = agent({ pubkey: LIVE_PK, status: "running" });
|
||||
|
||||
const resolved = resolveCanonicalManagedAgent({
|
||||
directManagedAgent: requested,
|
||||
isArchived: NONE_ARCHIVED,
|
||||
personaInstances: [requested, live],
|
||||
preferDirectManagedAgent: false,
|
||||
preserveRequestedInstance: false,
|
||||
pubkey: HISTORICAL_PK,
|
||||
});
|
||||
|
||||
assert.equal(resolved, live);
|
||||
});
|
||||
|
||||
test("preferDirectManagedAgent keeps a directly opened active instance exact", () => {
|
||||
// The panel's own default: an access edit must target the clicked instance,
|
||||
// not an alphabetically-earlier active sibling.
|
||||
const sibling = agent({ name: "Alpha", pubkey: LIVE_PK, status: "running" });
|
||||
const clicked = agent({
|
||||
name: "Zulu",
|
||||
pubkey: HISTORICAL_PK,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const resolved = resolveCanonicalManagedAgent({
|
||||
directManagedAgent: clicked,
|
||||
isArchived: NONE_ARCHIVED,
|
||||
personaInstances: [sibling, clicked],
|
||||
preferDirectManagedAgent: true,
|
||||
preserveRequestedInstance: false,
|
||||
pubkey: HISTORICAL_PK,
|
||||
});
|
||||
|
||||
assert.equal(resolved, clicked);
|
||||
});
|
||||
|
||||
test("an explicit archived pubkey stays exact even with preferDirectManagedAgent", () => {
|
||||
// Rule 2 wins over the direct-preference redirect: a deliberately opened
|
||||
// archived instance must not be redirected away from its unarchive control.
|
||||
const archivedDirect = agent({ pubkey: ARCHIVED_PK, status: "stopped" });
|
||||
const live = agent({ pubkey: LIVE_PK, status: "running" });
|
||||
|
||||
const resolved = resolveCanonicalManagedAgent({
|
||||
directManagedAgent: archivedDirect,
|
||||
isArchived: (pubkey) => pubkey === ARCHIVED_PK,
|
||||
personaInstances: [archivedDirect, live],
|
||||
preferDirectManagedAgent: true,
|
||||
preserveRequestedInstance: false,
|
||||
pubkey: ARCHIVED_PK,
|
||||
});
|
||||
|
||||
assert.equal(resolved, archivedDirect);
|
||||
});
|
||||
@@ -4,12 +4,83 @@ import {
|
||||
pickDirectProfileAgent,
|
||||
pickProfileAgent,
|
||||
} from "@/features/agents/lib/pickProfileAgent";
|
||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||
import { useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
|
||||
import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
/**
|
||||
* Resolve the single managed instance a profile surface represents, honouring
|
||||
* the archive-aware target-provenance rules. Pure so the resolution matrix is
|
||||
* testable without mounting the panel; the hook supplies the live inputs.
|
||||
*
|
||||
* - `preserveRequestedInstance` + a direct match pins that exact record (an
|
||||
* explicit Runtime → Instances selection).
|
||||
* - A deliberately requested archived pubkey stays EXACT — exactness beats
|
||||
* canonicalization iff the requested pubkey is archived — so its archive
|
||||
* controller can unarchive that identity even when a live sibling exists.
|
||||
* Returns the managed record when one exists; otherwise `undefined`, so the
|
||||
* panel falls back to the requested pubkey verbatim (a historical archived
|
||||
* key with no current managed record still resolves to itself).
|
||||
* - `preferDirectManagedAgent` (the panel's own default) keeps a directly
|
||||
* opened active instance exact so an access edit targets it, only redirecting
|
||||
* an inactive click to a live sibling — see `pickDirectProfileAgent`.
|
||||
* - Otherwise persona-target and non-archived historical navigation resolve
|
||||
* through the shared archive-aware selector: all instances archived yields
|
||||
* `undefined` (persona-only mode), else the canonical live instance.
|
||||
*/
|
||||
export function resolveCanonicalManagedAgent(input: {
|
||||
directManagedAgent: ManagedAgent | undefined;
|
||||
isArchived: (pubkey: string) => boolean;
|
||||
personaInstances: readonly ManagedAgent[];
|
||||
preferDirectManagedAgent: boolean;
|
||||
preserveRequestedInstance: boolean;
|
||||
pubkey: string | undefined;
|
||||
}): ManagedAgent | undefined {
|
||||
const {
|
||||
directManagedAgent,
|
||||
isArchived,
|
||||
personaInstances,
|
||||
preferDirectManagedAgent,
|
||||
preserveRequestedInstance,
|
||||
pubkey,
|
||||
} = input;
|
||||
if (preserveRequestedInstance && directManagedAgent) {
|
||||
return directManagedAgent;
|
||||
}
|
||||
if (pubkey && isArchived(pubkey)) {
|
||||
return directManagedAgent;
|
||||
}
|
||||
if (preferDirectManagedAgent && directManagedAgent) {
|
||||
return pickDirectProfileAgent(
|
||||
directManagedAgent,
|
||||
personaInstances,
|
||||
isArchived,
|
||||
);
|
||||
}
|
||||
return pickProfileAgent(personaInstances, isArchived) ?? directManagedAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a persona's instances into live and archived buckets off the same
|
||||
* archive predicate the selector uses — one policy, no duplication. Fail-open
|
||||
* is inherited: while the archive snapshot loads `isArchived` returns `false`,
|
||||
* so every instance lands in `live` and nothing is labeled or hidden.
|
||||
*/
|
||||
export function bucketPersonaInstances(
|
||||
personaInstances: readonly ManagedAgent[],
|
||||
isArchived: (pubkey: string) => boolean,
|
||||
): { live: ManagedAgent[]; archived: ManagedAgent[] } {
|
||||
const live: ManagedAgent[] = [];
|
||||
const archived: ManagedAgent[] = [];
|
||||
for (const instance of personaInstances) {
|
||||
(isArchived(instance.pubkey) ? archived : live).push(instance);
|
||||
}
|
||||
return { live, archived };
|
||||
}
|
||||
|
||||
export function useCanonicalManagedAgentProfile(input: {
|
||||
currentPubkey: string | undefined;
|
||||
managedAgents: readonly ManagedAgent[] | undefined;
|
||||
@@ -53,20 +124,36 @@ export function useCanonicalManagedAgentProfile(input: {
|
||||
(agent) => agent.personaId === linkedPersonaId,
|
||||
);
|
||||
}, [directManagedAgent, linkedPersonaId, managedAgents]);
|
||||
const managedAgent = React.useMemo(() => {
|
||||
if (directManagedAgent) {
|
||||
if (preserveRequestedInstance) return directManagedAgent;
|
||||
if (preferDirectManagedAgent) {
|
||||
return pickDirectProfileAgent(directManagedAgent, personaInstances);
|
||||
}
|
||||
}
|
||||
return pickProfileAgent(personaInstances) ?? directManagedAgent;
|
||||
}, [
|
||||
directManagedAgent,
|
||||
personaInstances,
|
||||
preferDirectManagedAgent,
|
||||
preserveRequestedInstance,
|
||||
]);
|
||||
const isArchived = useIsArchivedPredicate();
|
||||
const managedAgent = React.useMemo(
|
||||
() =>
|
||||
resolveCanonicalManagedAgent({
|
||||
directManagedAgent,
|
||||
isArchived,
|
||||
personaInstances,
|
||||
preferDirectManagedAgent,
|
||||
preserveRequestedInstance,
|
||||
pubkey,
|
||||
}),
|
||||
[
|
||||
directManagedAgent,
|
||||
isArchived,
|
||||
personaInstances,
|
||||
preferDirectManagedAgent,
|
||||
preserveRequestedInstance,
|
||||
pubkey,
|
||||
],
|
||||
);
|
||||
// Split the roster for the Instances list off the same predicate the selector
|
||||
// uses — see `bucketPersonaInstances` for the fail-open semantics.
|
||||
const instanceBuckets = React.useMemo(
|
||||
() => bucketPersonaInstances(personaInstances, isArchived),
|
||||
[isArchived, personaInstances],
|
||||
);
|
||||
|
||||
return { linkedPersonaId, managedAgent, personaInstances };
|
||||
return {
|
||||
instanceBuckets,
|
||||
linkedPersonaId,
|
||||
managedAgent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Archived-instances subsection regression: the profile panel's Instances list
|
||||
* splits live and relay-archived siblings, rendering archived rows under a
|
||||
* clearly labeled "Archived" header so unarchive stays UI-reachable for
|
||||
* channel-less agents. The section appears whenever live OR archived instances
|
||||
* exist, self-omits when neither does, shows only the Archived subsection for
|
||||
* an all-archived persona, and archived rows keep the deliberate explicit-
|
||||
* pubkey click that feeds selector matrix rule 3.
|
||||
*
|
||||
* Mounts the shipping ProfileInstancesSection (owned by the Runtime tab) and
|
||||
* drives the real expand toggle rather than reimplementing it.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
let cleanup;
|
||||
let fireEvent;
|
||||
let render;
|
||||
let screen;
|
||||
let createElement;
|
||||
let ProfileInstancesSection;
|
||||
|
||||
const LIVE_PK = "b".repeat(64);
|
||||
const SECOND_LIVE_PK = "c".repeat(64);
|
||||
const ARCHIVED_PK = "a".repeat(64);
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
pubkey: LIVE_PK,
|
||||
name: "Instance",
|
||||
personaId: "persona-1",
|
||||
status: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseProps(overrides = {}) {
|
||||
return {
|
||||
currentPubkey: null,
|
||||
instances: [],
|
||||
archivedInstances: [],
|
||||
onOpenInstance: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSection(props) {
|
||||
return render(createElement(ProfileInstancesSection, baseProps(props)));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
window: dom.window,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
writable: true,
|
||||
});
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: true,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
|
||||
({ cleanup, fireEvent, render, screen } = await import(
|
||||
"@testing-library/react"
|
||||
));
|
||||
({ createElement } = await import("react"));
|
||||
({ ProfileInstancesSection } = await import("./ProfileInstancesSection.tsx"));
|
||||
});
|
||||
|
||||
afterEach(() => cleanup?.());
|
||||
after(() => dom.window.close());
|
||||
|
||||
test("test_archived_and_live_instances_render_archived_subsection", () => {
|
||||
renderSection({
|
||||
instances: [agent({ pubkey: LIVE_PK, name: "Live" })],
|
||||
archivedInstances: [agent({ pubkey: ARCHIVED_PK, name: "Archived one" })],
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("user-profile-instances"));
|
||||
|
||||
assert.equal(
|
||||
screen.getByTestId("user-profile-instances").textContent,
|
||||
"2 instances",
|
||||
);
|
||||
assert.ok(screen.getByTestId("user-profile-instances-archived-header"));
|
||||
assert.ok(screen.getByTestId(`user-profile-instance-${LIVE_PK}`));
|
||||
assert.ok(screen.getByTestId(`user-profile-instance-${ARCHIVED_PK}`));
|
||||
});
|
||||
|
||||
test("test_no_archived_instances_omits_archived_subsection", () => {
|
||||
renderSection({
|
||||
instances: [
|
||||
agent({ pubkey: LIVE_PK, name: "Live" }),
|
||||
agent({ pubkey: SECOND_LIVE_PK, name: "Live two" }),
|
||||
],
|
||||
archivedInstances: [],
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("user-profile-instances"));
|
||||
|
||||
assert.equal(screen.queryByTestId("user-profile-instances-archived"), null);
|
||||
assert.ok(screen.getByTestId(`user-profile-instance-${LIVE_PK}`));
|
||||
});
|
||||
|
||||
test("test_all_archived_persona_shows_only_archived_subsection", () => {
|
||||
renderSection({
|
||||
instances: [],
|
||||
archivedInstances: [agent({ pubkey: ARCHIVED_PK, name: "Archived only" })],
|
||||
});
|
||||
// Section renders even with an empty live list.
|
||||
fireEvent.click(screen.getByTestId("user-profile-instances"));
|
||||
|
||||
assert.equal(
|
||||
screen.getByTestId("user-profile-instances").textContent,
|
||||
"1 instance",
|
||||
);
|
||||
assert.ok(screen.getByTestId("user-profile-instances-archived-header"));
|
||||
assert.ok(screen.getByTestId(`user-profile-instance-${ARCHIVED_PK}`));
|
||||
});
|
||||
|
||||
test("test_no_instances_at_all_omits_instances_section", () => {
|
||||
renderSection({ instances: [], archivedInstances: [] });
|
||||
assert.equal(screen.queryByTestId("user-profile-instances-section"), null);
|
||||
});
|
||||
|
||||
test("test_archived_row_click_opens_that_explicit_pubkey", () => {
|
||||
const opened = [];
|
||||
renderSection({
|
||||
instances: [],
|
||||
archivedInstances: [agent({ pubkey: ARCHIVED_PK, name: "Archived only" })],
|
||||
onOpenInstance: (pubkey) => opened.push(pubkey),
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("user-profile-instances"));
|
||||
fireEvent.click(screen.getByTestId(`user-profile-instance-${ARCHIVED_PK}`));
|
||||
|
||||
// The archived row keeps the deliberate explicit-pubkey path (unarchive).
|
||||
assert.deepEqual(opened, [ARCHIVED_PK]);
|
||||
});
|
||||
121
desktop/src/features/profile/ui/ProfileInstancesSection.tsx
Normal file
121
desktop/src/features/profile/ui/ProfileInstancesSection.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as React from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { ProfileSectionGroup } from "@/features/profile/ui/UserProfilePanelFields";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
function ProfileInstanceRow({
|
||||
archived = false,
|
||||
currentPubkey,
|
||||
instance,
|
||||
onOpenInstance,
|
||||
}: {
|
||||
archived?: boolean;
|
||||
currentPubkey: string | null;
|
||||
instance: ManagedAgent;
|
||||
onOpenInstance: (pubkey: string) => void;
|
||||
}) {
|
||||
const isCurrent = instance.pubkey === currentPubkey;
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
data-testid={`user-profile-instance-${instance.pubkey}`}
|
||||
onClick={() => onOpenInstance(instance.pubkey)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{instance.name}
|
||||
</span>
|
||||
<span className="text-xs capitalize text-muted-foreground">
|
||||
{archived
|
||||
? "Archived"
|
||||
: isCurrent
|
||||
? "Current"
|
||||
: instance.status.replace("_", " ")}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The persona's managed-agent instances, split into live rows and a labeled
|
||||
* "Archived" subsection. Archived rows keep the explicit-pubkey click so
|
||||
* unarchive stays UI-reachable for channel-less agents — the deliberate-
|
||||
* navigation path (selector matrix rule 3) that lets a click land on the exact
|
||||
* archived identity. The count reflects both buckets; the section renders only
|
||||
* when at least one instance (live or archived) exists.
|
||||
*/
|
||||
export function ProfileInstancesSection({
|
||||
archivedInstances,
|
||||
currentPubkey,
|
||||
instances,
|
||||
onOpenInstance,
|
||||
}: {
|
||||
archivedInstances: ManagedAgent[];
|
||||
currentPubkey: string | null;
|
||||
instances: ManagedAgent[];
|
||||
onOpenInstance: (pubkey: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const totalCount = instances.length + archivedInstances.length;
|
||||
if (totalCount === 0) return null;
|
||||
const instanceCountLabel = `${totalCount} instance${totalCount === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<ProfileSectionGroup
|
||||
testId="user-profile-instances-section"
|
||||
title="Instances"
|
||||
>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
data-testid="user-profile-instances"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">
|
||||
{instanceCountLabel}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<>
|
||||
{instances.map((instance) => (
|
||||
<ProfileInstanceRow
|
||||
currentPubkey={currentPubkey}
|
||||
instance={instance}
|
||||
key={instance.pubkey}
|
||||
onOpenInstance={onOpenInstance}
|
||||
/>
|
||||
))}
|
||||
{archivedInstances.length > 0 ? (
|
||||
<div data-testid="user-profile-instances-archived">
|
||||
<p
|
||||
className="px-4 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
data-testid="user-profile-instances-archived-header"
|
||||
>
|
||||
Archived
|
||||
</p>
|
||||
{archivedInstances.map((instance) => (
|
||||
<ProfileInstanceRow
|
||||
archived
|
||||
currentPubkey={currentPubkey}
|
||||
instance={instance}
|
||||
key={instance.pubkey}
|
||||
onOpenInstance={onOpenInstance}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</ProfileSectionGroup>
|
||||
);
|
||||
}
|
||||
@@ -189,7 +189,7 @@ export function UserProfilePanel({
|
||||
);
|
||||
const personasQuery = usePersonasQuery();
|
||||
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
|
||||
const { linkedPersonaId, managedAgent, personaInstances } =
|
||||
const { instanceBuckets, linkedPersonaId, managedAgent } =
|
||||
useCanonicalManagedAgentProfile({
|
||||
currentPubkey,
|
||||
managedAgents: managedAgentsQuery.data,
|
||||
@@ -812,7 +812,7 @@ export function UserProfilePanel({
|
||||
isFollowing={isFollowing}
|
||||
isOwner={viewerIsOwner}
|
||||
isSelf={isSelf}
|
||||
instances={personaInstances}
|
||||
instanceBuckets={instanceBuckets}
|
||||
activityAgent={activityAgent}
|
||||
managedAgent={managedAgent}
|
||||
agentInfoFields={agentInfoFields}
|
||||
|
||||
@@ -87,7 +87,7 @@ export type ProfileSummaryViewProps = {
|
||||
isFollowing: boolean;
|
||||
isOwner: boolean | undefined;
|
||||
isSelf: boolean;
|
||||
instances: ManagedAgent[];
|
||||
instanceBuckets: { live: ManagedAgent[]; archived: ManagedAgent[] };
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
agentInfoFields: ProfileField[];
|
||||
archiveActions: IdentityArchiveActions;
|
||||
@@ -163,7 +163,7 @@ export function ProfileSummaryView({
|
||||
isFollowing,
|
||||
isOwner,
|
||||
isSelf,
|
||||
instances,
|
||||
instanceBuckets,
|
||||
managedAgent,
|
||||
agentInfoFields,
|
||||
archiveActions,
|
||||
@@ -233,7 +233,8 @@ export function ProfileSummaryView({
|
||||
(managedAgent !== undefined ||
|
||||
runtimeConfigurationFields.length > 0 ||
|
||||
runtimeSettingsFields.length > 0 ||
|
||||
instances.length > 0 ||
|
||||
instanceBuckets.live.length > 0 ||
|
||||
instanceBuckets.archived.length > 0 ||
|
||||
diagnosticsFields.length > 0 ||
|
||||
canOpenAgentLogs);
|
||||
const showDiagnosticsIngress =
|
||||
@@ -535,7 +536,8 @@ export function ProfileSummaryView({
|
||||
diagnosticsFields={diagnosticsFields}
|
||||
diagnosticsSummary={diagnosticsTrailing}
|
||||
configurationFields={runtimeFields}
|
||||
instances={instances}
|
||||
instances={instanceBuckets.live}
|
||||
archivedInstances={instanceBuckets.archived}
|
||||
modelSettings={
|
||||
isOwner === true && managedAgent !== undefined ? (
|
||||
<AgentConfigPanel
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useProfileActivityFeedScope,
|
||||
} from "@/features/profile/lib/profileActivityFeedScope";
|
||||
import { UserProfileAgentManagementRows } from "@/features/profile/ui/UserProfileAgentManagementRows";
|
||||
import { ProfileInstancesSection } from "@/features/profile/ui/ProfileInstancesSection";
|
||||
import {
|
||||
type ProfileField,
|
||||
ProfileFieldRows,
|
||||
@@ -319,66 +320,6 @@ export function ProfileInfoTabContent({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileInstancesSection({
|
||||
currentPubkey,
|
||||
instances,
|
||||
onOpenInstance,
|
||||
}: {
|
||||
currentPubkey: string | null;
|
||||
instances: ManagedAgent[];
|
||||
onOpenInstance: (pubkey: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const instanceCountLabel = `${instances.length} instance${instances.length === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<ProfileSectionGroup
|
||||
testId="user-profile-instances-section"
|
||||
title="Instances"
|
||||
>
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
data-testid="user-profile-instances"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">
|
||||
{instanceCountLabel}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{expanded
|
||||
? instances.map((instance) => {
|
||||
const isCurrent = instance.pubkey === currentPubkey;
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
data-testid={`user-profile-instance-${instance.pubkey}`}
|
||||
key={instance.pubkey}
|
||||
onClick={() => onOpenInstance(instance.pubkey)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{instance.name}
|
||||
</span>
|
||||
<span className="text-xs capitalize text-muted-foreground">
|
||||
{isCurrent ? "Current" : instance.status.replace("_", " ")}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ProfileSectionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileLiveActivityEmbed({
|
||||
activeTurns,
|
||||
activityAgent,
|
||||
@@ -765,6 +706,7 @@ function ArchiveStatusTooltip() {
|
||||
|
||||
export function ProfileRuntimeTabContent({
|
||||
autoRestartEnabled = false,
|
||||
archivedInstances,
|
||||
currentPubkey,
|
||||
diagnosticsFields,
|
||||
diagnosticsSummary,
|
||||
@@ -782,6 +724,7 @@ export function ProfileRuntimeTabContent({
|
||||
}: {
|
||||
/** Whether the per-agent auto-restart toggle is ON. */
|
||||
autoRestartEnabled?: boolean;
|
||||
archivedInstances: ManagedAgent[];
|
||||
currentPubkey: string | null;
|
||||
diagnosticsFields: ProfileField[];
|
||||
diagnosticsSummary: React.ReactNode;
|
||||
@@ -822,7 +765,7 @@ export function ProfileRuntimeTabContent({
|
||||
startOnLaunchField !== undefined ||
|
||||
showDiagnosticsIngress;
|
||||
const hasConfigurationRows = remainingConfigurationFields.length > 0;
|
||||
const hasInstances = instances.length > 0;
|
||||
const hasInstances = instances.length > 0 || archivedInstances.length > 0;
|
||||
|
||||
if (
|
||||
statusDiagnosticsFields.length === 0 &&
|
||||
@@ -935,6 +878,7 @@ export function ProfileRuntimeTabContent({
|
||||
{modelSettings}
|
||||
{hasInstances ? (
|
||||
<ProfileInstancesSection
|
||||
archivedInstances={archivedInstances}
|
||||
currentPubkey={currentPubkey}
|
||||
instances={instances}
|
||||
onOpenInstance={onOpenInstance}
|
||||
|
||||
Reference in New Issue
Block a user