Skip to content
1 change: 1 addition & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,7 @@ export function AppShell() {
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
relayUrl={communitiesHook.activeCommunity?.relayUrl}
/>
<SendFeedbackController
onOpenChange={setIsSendFeedbackOpen}
Expand Down
27 changes: 27 additions & 0 deletions desktop/src/app/AppShellOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ const ChannelManagementSheet = React.lazy(async () => {
return { default: module.ChannelManagementSheet };
});

const MembersSidebar = React.lazy(async () => {
const module = await import("@/features/channels/ui/MembersSidebar");
return { default: module.MembersSidebar };
});

export type BrowseDialogType = "stream" | "forum" | null;

type AppShellOverlaysProps = {
Expand All @@ -30,6 +35,7 @@ type AppShellOverlaysProps = {
onChannelManagementOpenChange: (open: boolean) => void;
onDeleteActiveChannel: () => void;
onSelectChannel: (channelId: string) => void;
relayUrl?: string;
};

export function AppShellOverlays({
Expand All @@ -45,7 +51,11 @@ export function AppShellOverlays({
onChannelManagementOpenChange,
onDeleteActiveChannel,
onSelectChannel,
relayUrl,
}: AppShellOverlaysProps) {
const [membersChannel, setMembersChannel] = React.useState<Channel | null>(
null,
);
const [visibleBrowseDialogType, setVisibleBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const { cancelDeferredModalOpen, openNextFrame: openModalNextFrame } =
Expand Down Expand Up @@ -89,11 +99,28 @@ export function AppShellOverlays({
channel={activeChannel}
currentPubkey={currentPubkey}
onDeleted={onDeleteActiveChannel}
onOpenMembers={() => setMembersChannel(activeChannel)}
onOpenChange={onChannelManagementOpenChange}
open={true}
/>
</React.Suspense>
) : null}

{membersChannel ? (
<React.Suspense fallback={null}>
<MembersSidebar
channel={membersChannel}
currentPubkey={currentPubkey}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setMembersChannel(null);
}
}}
open={true}
relayUrl={relayUrl}
/>
</React.Suspense>
) : null}
</>
);
}
103 changes: 84 additions & 19 deletions desktop/src/features/agent-memory/ui/MemorySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Skeleton } from "@/shared/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";

const MEMORY_LIST_PREVIEW_LIMIT = 3;
type MemorySectionVariant = "cards" | "grouped";

const MEMORY_TRUNCATED_TOOLTIP =
"This list may be incomplete — the relay returned the maximum number of memories.";
Expand Down Expand Up @@ -41,15 +42,17 @@ const MEMORY_DANGLING_REF_TOOLTIP =
*/
export function MemorySection({
agentPubkey,
variant = "cards",
viewerIsOwner,
}: {
agentPubkey: string;
variant?: MemorySectionVariant;
viewerIsOwner: boolean;
}): React.ReactElement | null {
// Hide entirely for non-owners.
if (!viewerIsOwner) return null;

return <MemorySectionForOwner agentPubkey={agentPubkey} />;
return <MemorySectionForOwner agentPubkey={agentPubkey} variant={variant} />;
}

export function MemoryRefreshButton({
Expand Down Expand Up @@ -92,7 +95,13 @@ export function MemoryRefreshButton({
);
}

function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) {
function MemorySectionForOwner({
agentPubkey,
variant,
}: {
agentPubkey: string;
variant: MemorySectionVariant;
}) {
const { query, graph } = useAgentMemoryGraph(agentPubkey);

// Order matters here. We want:
Expand All @@ -107,13 +116,14 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) {

return (
<section data-testid="agent-memory-section">
{showInitialSkeleton ? <MemorySkeleton /> : null}
{showInitialSkeleton ? <MemorySkeleton variant={variant} /> : null}

{showInitialError ? (
<MemoryErrorState
error={query.error}
onRetry={() => query.refetch()}
retrying={query.isFetching}
variant={variant}
/>
) : null}

Expand All @@ -123,10 +133,17 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) {
still have prior data on screen. Distinct from the initial
error state above. */}
{query.isError && !query.isFetching ? (
<MemoryStaleErrorBanner onRetry={() => query.refetch()} />
<MemoryStaleErrorBanner
onRetry={() => query.refetch()}
variant={variant}
/>
) : null}

<MemoryGraphView graph={graph} truncated={query.data.truncated} />
<MemoryGraphView
graph={graph}
truncated={query.data.truncated}
variant={variant}
/>
</>
) : null}
</section>
Expand All @@ -135,11 +152,11 @@ function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) {

// ── Subviews ────────────────────────────────────────────────────────────────

function MemorySkeleton() {
function MemorySkeleton({ variant }: { variant: MemorySectionVariant }) {
return (
<div
aria-label="Loading memory"
className="space-y-2"
className={cn("space-y-2", variant === "grouped" && "p-4")}
data-testid="agent-memory-skeleton"
role="status"
>
Expand All @@ -154,16 +171,21 @@ function MemoryErrorState({
error,
onRetry,
retrying,
variant,
}: {
error: unknown;
onRetry: () => void;
retrying: boolean;
variant: MemorySectionVariant;
}) {
const message =
error instanceof Error ? error.message : String(error ?? "unknown error");
return (
<div
className="flex flex-col gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs"
className={cn(
"flex flex-col gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs",
variant === "grouped" && "m-3",
)}
data-testid="agent-memory-error"
role="alert"
>
Expand All @@ -189,10 +211,19 @@ function MemoryErrorState({
);
}

function MemoryStaleErrorBanner({ onRetry }: { onRetry: () => void }) {
function MemoryStaleErrorBanner({
onRetry,
variant,
}: {
onRetry: () => void;
variant: MemorySectionVariant;
}) {
return (
<div
className="mb-2 flex items-center gap-2 rounded-md border border-warning/30 bg-warning/5 px-2 py-1.5 text-xs"
className={cn(
"mb-2 flex items-center gap-2 rounded-md border border-warning/30 bg-warning/5 px-2 py-1.5 text-xs",
variant === "grouped" && "mx-3 mt-3",
)}
data-testid="agent-memory-stale-error"
>
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
Expand All @@ -211,9 +242,11 @@ function MemoryStaleErrorBanner({ onRetry }: { onRetry: () => void }) {
function MemoryGraphView({
graph,
truncated,
variant,
}: {
graph: NonNullable<ReturnType<typeof useAgentMemoryGraph>["graph"]>;
truncated: boolean;
variant: MemorySectionVariant;
}) {
const { rootedTree, orphans, dangling } = graph;
const [showAllEntries, setShowAllEntries] = React.useState(false);
Expand Down Expand Up @@ -250,23 +283,32 @@ function MemoryGraphView({
: entries.slice(0, MEMORY_LIST_PREVIEW_LIMIT);

return (
<div className="space-y-3">
<div className={variant === "grouped" ? undefined : "space-y-3"}>
{!core && memories.length > 0 ? (
<p
className="text-xs italic text-muted-foreground"
className={cn(
"text-xs italic text-muted-foreground",
variant === "grouped" && "px-4 py-3",
)}
data-testid="agent-memory-no-core"
>
No <code className="font-mono text-2xs">core</code> memory yet — agent
identity is unrooted.
</p>
) : null}

<div className="space-y-2" data-testid="agent-memory-list">
<div
className={cn(
variant === "grouped" ? "divide-y divide-border/55" : "space-y-2",
)}
data-testid="agent-memory-list"
>
{visibleEntries.map((entry) => (
<MemoryEntryAccordion
danglingSlugs={danglingSlugs}
entry={entry}
key={entry.eventId}
variant={variant}
/>
))}
</div>
Expand All @@ -276,14 +318,22 @@ function MemoryGraphView({
count={entries.length}
onClick={() => setShowAllEntries(true)}
truncated={truncated}
variant={variant}
/>
) : null}

{truncated && !hasMoreEntries ? <MemoryTruncatedHint /> : null}
{truncated && !hasMoreEntries ? (
<MemoryTruncatedHint variant={variant} />
) : null}

{hasMoreEntries && showAllEntries ? (
<button
className="flex w-full justify-center rounded-2xl bg-muted/40 px-4 py-3 text-sm font-medium text-foreground transition-colors hover:bg-muted/50"
className={cn(
"flex w-full justify-center px-4 py-3 text-sm font-medium text-foreground transition-colors hover:bg-muted/50",
variant === "grouped"
? "border-t border-border/55"
: "rounded-2xl bg-muted/40",
)}
data-testid="agent-memory-show-less"
onClick={() => setShowAllEntries(false)}
type="button"
Expand All @@ -299,14 +349,21 @@ function MemoryShowMoreButton({
count,
onClick,
truncated,
variant,
}: {
count: number;
onClick: () => void;
truncated: boolean;
variant: MemorySectionVariant;
}) {
const button = (
<button
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-muted/40 px-4 py-3 text-sm font-medium text-foreground transition-colors hover:bg-muted/50"
className={cn(
"flex w-full items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-foreground transition-colors hover:bg-muted/50",
variant === "grouped"
? "border-t border-border/55"
: "rounded-2xl bg-muted/40",
)}
data-testid={
truncated ? "agent-memory-truncated" : "agent-memory-show-more"
}
Expand All @@ -329,12 +386,15 @@ function MemoryShowMoreButton({
);
}

function MemoryTruncatedHint() {
function MemoryTruncatedHint({ variant }: { variant: MemorySectionVariant }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex justify-center rounded-2xl border border-warning/30 bg-warning/5 px-4 py-2"
className={cn(
"flex justify-center border-warning/30 bg-warning/5 px-4 py-2",
variant === "grouped" ? "border-t" : "rounded-2xl border",
)}
data-testid="agent-memory-truncated"
>
<AlertTriangle className="h-4 w-4 text-warning" />
Expand Down Expand Up @@ -466,9 +526,11 @@ function elementExceedsLines(element: HTMLElement, lines: number): boolean {
function MemoryEntryAccordion({
danglingSlugs,
entry,
variant,
}: {
danglingSlugs: ReadonlySet<string>;
entry: EngramEntry;
variant: MemorySectionVariant;
}) {
const [open, setOpen] = React.useState(false);
const [showCaret, setShowCaret] = React.useState(false);
Expand Down Expand Up @@ -545,7 +607,10 @@ function MemoryEntryAccordion({

return (
<article
className="overflow-hidden rounded-2xl bg-muted/40"
className={cn(
"overflow-hidden",
variant === "cards" && "rounded-2xl bg-muted/40",
)}
ref={articleRef}
>
{canExpand ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) {
return "Stop";
}

return agent.status === "stopped" ? "Restart Agent" : "Start Agent";
return "Start agent";
}

export function resolveManagedAgentChannelId(
Expand Down
Loading