Files
multica/packages/views/chat/components/chat-session-header.tsx
Naiyuan Qing f4de0948a2 refactor(ui): unify ActorAvatar size tiers + round all avatars & cropper (MUL-4277, MUL-4184) (#5133)
* refactor(ui): converge ActorAvatar size to semantic tiers (MUL-4277)

Replace the free-form numeric `size` on ActorAvatar with a constrained
`AvatarSize` union (xs/sm/md/lg/xl/2xl) so avatar dimensions are chosen by
role instead of ad-hoc pixels. This eliminates the magic-number drift where
the same role rendered at different sizes across pages.

- Add `@multica/ui/lib/avatar-size` (AvatarSize union + AVATAR_SIZE_PX map +
  default tier).
- Base `ActorAvatar` (packages/ui) and business `ActorAvatar`/`AgentStatusDot`
  (packages/views) now take `AvatarSize`; internal font/icon math and the
  presence-dot threshold read px from the map.
- Migrate all web/desktop call sites (packages/ui + packages/views) from
  numeric sizes to tiers using the role table
  (12,14->xs 16,18,20->sm 22,24,28->md 30,32,34->lg 40,44->xl 56,64->2xl).
- Token-ise the derived consumers `AgentAvatarStack` and
  `IssueAgentActivityIndicator` (px looked up internally for overlap/+N math).

Out of scope (per plan): ui/avatar.tsx primitive, account-tab/AvatarPicker,
mobile, and component-name disambiguation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): unify all avatars and the upload cropper to round (MUL-4277, MUL-4184)

main's avatar-shape decision rendered non-human actors (agent, squad,
system) and the workspace logo as rounded squares, and the upload cropper
mirrored that with a square crop window. Per the updated decision
(avatars_and_cropper_round_required), every avatar and the crop UI are now
circular; the square path is removed rather than left as dead config.

- Base ActorAvatar: always rounded-full (drop the isHuman/rounded-md split).
- avatar-crop-dialog: remove the AvatarCropShape/square path; crop window is
  always cropShape="round".
- avatar-upload-control: drop VARIANT_SHAPE; the control is always round and
  no longer threads a shape to the dialog (variant still drives the fallback).
- Strip rounded-md/rounded-none square overrides from agent/squad/member
  ActorAvatar call sites; round the read-only agent/squad static wrappers.
- WorkspaceAvatar: round the org logo so it matches the (now round) workspace
  upload/crop and the shared avatar shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): make round avatar shape a hard invariant (MUL-4277)

Close the two remaining square squad-avatar paths flagged in review and
prevent call sites from re-squaring the avatar:

- base ActorAvatar: keep `rounded-full` as the last class in cn() so a
  call-site `className` can no longer override the circle.
- SquadHeaderAvatar: drop `className="rounded"` (was overriding the base
  circle into a small rounded square).
- SquadsPage no-avatar fallback: route through the shared ActorAvatarBase
  (`isSquad size="lg"`) instead of a hand-written rounded-md tile, so the
  fallback matches the image path — one shape source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(views): round the agent/squad avatar loading skeletons (MUL-4277)

The avatar placeholder skeletons on the agent/squad list, detail, and
profile-card loading states were still rounded squares (rounded-md/lg) from
the pre-round era, so the avatar visibly popped from square to circle on
load — inconsistent with the round avatars and with the member/inbox/issue
skeletons that already use rounded-full.

Round all five: agents-page, agent-detail-page, squads-page,
squad-detail-page, squad-profile-card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 08:31:40 +08:00

210 lines
7.1 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Archive, ArchiveRestore, MoreHorizontal, Pencil, Trash2, UserRound } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@multica/ui/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { useWorkspacePaths } from "@multica/core/paths";
import {
useUpdateChatSession,
useDeleteChatSession,
useSetChatSessionArchived,
} from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import type { Agent, ChatSession } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { useNavigation } from "../../navigation";
import { useT } from "../../i18n";
/**
* Per-session header for the conversation pane: agent avatar + editable chat
* title + agent subtitle, with a ⋯ menu (rename / view agent profile / delete).
* The avatar's hover card is the lightweight "view profile" affordance; the
* menu item navigates to the full agent page.
*/
export function ChatSessionHeader({
session,
agent,
onArchive,
}: {
session: ChatSession;
agent: Agent | null;
// Archiving the open conversation must move the pane off it (advance to the
// next chat on desktop, back to the list on mobile), so the parent owns it —
// see ChatPage.handleArchive. Falls back to a plain status flip if unwired.
onArchive?: (session: ChatSession) => void;
}) {
const { t } = useT("chat");
const wsPaths = useWorkspacePaths();
const { push } = useNavigation();
const updateSession = useUpdateChatSession();
const deleteSession = useDeleteChatSession();
const setArchived = useSetChatSessionArchived();
const setActiveSession = useChatStore((s) => s.setActiveSession);
const isArchived = session.status === "archived";
const [editing, setEditing] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [draft, setDraft] = useState(session.title ?? "");
const inputRef = useRef<HTMLInputElement>(null);
const title = session.title?.trim() || t(($) => $.window.untitled);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
const startRename = () => {
setDraft(session.title ?? "");
setEditing(true);
};
const commitRename = () => {
setEditing(false);
const trimmed = draft.trim();
if (!trimmed || trimmed === session.title) return;
updateSession.mutate({ sessionId: session.id, title: trimmed });
};
const viewProfile = () => {
if (agent) push(wsPaths.agentDetail(agent.id));
};
const doDelete = () => {
setConfirmDelete(false);
setActiveSession(null);
deleteSession.mutate(session.id);
};
const doArchive = () =>
onArchive
? onArchive(session)
: setArchived.mutate({ sessionId: session.id, archived: true });
const doUnarchive = () => setArchived.mutate({ sessionId: session.id, archived: false });
return (
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
{agent ? (
<ActorAvatar actorType="agent" actorId={agent.id} size="lg" enableHoverCard showStatusDot />
) : (
<span className="size-[30px] shrink-0" />
)}
<div className="min-w-0 flex-1">
{editing ? (
<input
ref={inputRef}
value={draft}
maxLength={200}
aria-label={t(($) => $.header.rename)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRename();
} else if (e.key === "Escape") {
e.preventDefault();
setEditing(false);
}
}}
className="w-full rounded-sm bg-background px-1 py-0.5 text-sm font-semibold outline-none ring-1 ring-border focus-visible:ring-brand"
/>
) : (
<button
type="button"
onClick={startRename}
title={t(($) => $.header.rename)}
className="block max-w-full truncate text-left text-sm font-semibold text-foreground outline-none hover:text-foreground/80 focus-visible:text-foreground/80"
>
{title}
</button>
)}
{agent && (
<div className="truncate text-xs text-muted-foreground">
{agent.name}
{agent.description ? ` · ${agent.description}` : ""}
</div>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" className="text-muted-foreground" />}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem onClick={startRename}>
<Pencil className="h-4 w-4" />
{t(($) => $.header.rename)}
</DropdownMenuItem>
{agent && (
<DropdownMenuItem onClick={viewProfile}>
<UserRound className="h-4 w-4" />
{t(($) => $.header.view_profile)}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isArchived ? (
<>
<DropdownMenuItem onClick={doUnarchive}>
<ArchiveRestore className="h-4 w-4" />
{t(($) => $.header.unarchive)}
</DropdownMenuItem>
{/* Hard delete is offered only once a chat is archived. */}
<DropdownMenuItem variant="destructive" onClick={() => setConfirmDelete(true)}>
<Trash2 className="h-4 w-4" />
{t(($) => $.header.delete)}
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={doArchive}>
<Archive className="h-4 w-4" />
{t(($) => $.header.archive)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.session_history.delete_dialog.title)}</AlertDialogTitle>
<AlertDialogDescription>{title}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t(($) => $.session_history.delete_dialog.cancel)}</AlertDialogCancel>
<AlertDialogAction
onClick={doDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t(($) => $.session_history.delete_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}