AgentHeader.tsx7.7 KBView on GitHub 'use client';
import { EditableText } from '@/modules/conversations/components/EditableText';
import { ShareTrigger } from '@/modules/sharing/components/ShareTrigger';
import { useRenameAgent } from '@/modules/agents/hooks/use-rename-agent';
import { agentDisplayName } from '@/modules/agents/utils/agent-name';
import { AgentAvatar } from '@/components/icons/agent-avatar';
import type { AgentDetail } from '@/modules/agents/types';
import { ViewAsBadge } from '@/modules/sharing/view-as';
import { AgentSharePanel } from './AgentSharePanel';
import { AgentActionMenu } from './AgentActionMenu';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* The agent workspace header — deliberately the same shape as ConversationHeader:
* one row, icon-badge + title on the left, a ⋯ menu on the right, `py-1` inside the
* layout's `pt-1.5` so the title's text top lands at the same 10px as the
* conversation title and the chat title.
*
* The differences from a conversation are only WHAT the badge shows (the agent's
* avatar instead of a company logo) and WHAT the title is. Everything the eye uses
* to place itself — spacing, weight, the menu's position — is identical, because
* the two are the same kind of screen. That extends to the title being EDITABLE in
* place, through the same `EditableText` a conversation's name uses: the name of the
* thing you are looking at is renamed where you are looking at it, not in a form two
* clicks away.
*/
export function AgentHeader({
agent,
targetUserId,
readOnly = false,
onBack,
className,
}: {
agent: AgentDetail;
/** An org admin/owner viewing a teammate's agent — see `AgentView`'s prop of the same name. */
targetUserId?: string;
/**
* A view-as PERSPECTIVE is active, so every affordance that writes is hidden.
*
* Not the same thing as `targetUserId`. An admin who deliberately opened a teammate's
* agent may act on it — that is the org-admin path, gated server-side. Looking through
* somebody's eyes is the other thing, and the rule for it is written on
* `useViewAs`: hidden, not disabled, because a greyed-out Delete still says "this is a
* thing you do here" and the answer while viewing as someone is that it is not.
*/
readOnly?: boolean;
/** Inline back button for narrow layouts, where the side gutter collapses. */
onBack?: () => void;
className?: string;
}) {
return (
<div className={cn('flex flex-col gap-2', className)}>
<div className="flex items-center justify-between gap-3 py-1">
<div className="flex min-w-0 items-center gap-2">
{onBack && (
<button
type="button"
onClick={onBack}
aria-label="Back"
className="hidden h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground @max-3xl:flex"
>
<ArrowLeft className="h-4 w-4" />
</button>
)}
{/* The agent's picture, and nothing else. No badge chrome and no "Agent"
label: the company badge earns its pill because a company is a SECOND
name beside the conversation's, whereas here the agent's name IS the
title. A pill reading "Agent" next to the agent's name says nothing the
row does not already say. The avatar carries its own surface — a tinted
circle behind it would be a second frame around one object. */}
<AgentAvatar
agentId={agent.agentId}
avatar={agent.avatar}
mood="idle"
className="h-5 w-5 shrink-0"
/>
<AgentTitle agent={agent} targetUserId={targetUserId} readOnly={readOnly} />
{!agent.enabled && (
<Badge variant="destructive" className="shrink-0">
Disabled
</Badge>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{/* Left of Share, because it is the answer to the question Share asked. A
perspective you forget you are in turns every empty list below into a bug
report, so it is stated where the eye already goes for this agent's
controls rather than in a bar that moves the whole page down. */}
<ViewAsBadge />
{/* The share control the whole design turns on, in the title row of an agent —
the SECOND mount of the one control, the first being a document's title row.
Literally the same component: an agent used to get a bespoke joined pill
with an eye button welded to it, so the one screen where sharing is most
likely to be a new act was the screen where it looked like a different one.
"View as" moved inside the panel, onto the person it is about.
An agent with no document behind it has no audience to describe, so it gets
no control rather than one that opens onto nothing. */}
{agent.documentId ? (
<ShareTrigger
documentId={agent.documentId}
panel={<AgentSharePanel agentId={agent.agentId} />}
/>
) : null}
{/* Everything that was a loose chip or button — scope, folder, share,
duplicate, run — lives in here. The conversation header puts exactly
one control on this side; so does this one. */}
{readOnly ? null : <AgentActionMenu agent={agent} targetUserId={targetUserId} />}
</div>
</div>
</div>
);
}
/**
* The title, edited in place.
*
* DISPLAYED and EDITED as the pretty form, SAVED as a slug. An agent's name is a lookup
* key — the chat harness registers it under that string and a playbook addresses it by it
* — so `aop.updateSubagentHeader` slugifies whatever is typed, exactly as `createAgentDoc`
* does at birth. Seeding the field with `agentDisplayName` rather than the raw value is
* what makes that round-trip invisible: you edit "Daily sales coaching", not
* "daily-sales-coaching", and typing "Deal Brain" gives back "Deal brain" rather than
* silently replacing the title with a filename the moment you click it.
*
* An agent with no document behind it has no frontmatter to patch, so it renders as static
* text — a caret in a field that cannot save is worse than no caret.
*/
function AgentTitle({
agent,
targetUserId,
readOnly = false,
}: {
agent: AgentDetail;
targetUserId?: string;
readOnly?: boolean;
}) {
const { rename, canRename } = useRenameAgent({
agentId: agent.agentId,
documentId: agent.documentId,
targetUserId,
});
const title = agentDisplayName(agent.name);
// The same rule as the ⋯ menu: a caret in a field you may not save is the disabled
// Delete all over again, and renaming somebody's agent from inside their perspective
// is not something a look-through should be able to do.
if (readOnly || !canRename) return <h1 className="truncate text-xl font-semibold">{title}</h1>;
return (
// `contents`, so the FIELD is the flex item and not a box around it. `EditableText`
// sizes its input with `w-full`, which resolves against the flex container's width —
// through a wrapper it would resolve against a box that is itself sized to the input,
// and the title would collapse to the browser's default ~20-character input on click.
// The heading stays a heading: this is exactly the case `display: contents` is for.
<h1 className="contents text-xl font-semibold">
<EditableText
value={title}
onSave={rename}
ariaLabel="Agent name"
placeholder="Untitled agent"
className="truncate text-xl font-semibold"
/>
</h1>
);
}