mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
tokens.css defined colours, radii and font families but not a single --text-* step, so font sizes had no baseline to align to and grew wherever they were needed: 51 distinct sizes across web + desktop, 370 written as arbitrary values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px). text-xs and text-sm carried nearly all UI text while the range between them — 11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not come from having more sizes; past a handful, each extra size makes the hierarchy blurrier. Add ten role-named steps, each with its own line-height so leading cannot fragment the way size did, and move every product-UI call site onto them. Steps are named for what the text is for, not for a t-shirt size, because that is what keeps the scale from drifting again. Six steps deliberately keep the exact size/line-height pairs of the Tailwind defaults they replace, so the ~1,900-call-site rename moves nothing on screen. The visible changes are confined to former arbitrary values snapping to a step: 8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30 (text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small buttons and toggles. Half-pixel sizes are gone. This supersedes #6108, which was reverted by #6116 because the sidebar group labels rendered at the inherited 16px. The cause was not the scale but cn(): `text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a table listing only the default sizes, so it filed every role step under text-colour and dropped whichever of `text-caption` / `text-sidebar-foreground/70` came first. Registering the steps as a font-size class group restores the real conflict groups — size beats size, colour beats colour, the two coexist — and a test pins the list against the scale, since the failure is silent in source. Hand-written CSS is covered too. The transcript kept a 12.5px body long after every Tailwind call site was on the scale, so the "no half-pixel sizes" claim was true of the classes and false of the product; the editor's prose, code and mermaid ramps had the same blind spot, and seven of their eight values already equalled a step exactly. All now reference var(--text-*). The guard test reads raw `font-size:` declarations as well as class names, exempting only the 16px iOS input-zoom workaround in base.css and the landing pages' marketing ramp. apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system) keep Tailwind's default scale and are untouched. Landing display type (rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative emoji / serif-hero sizes. Verified on a running local stack: pinned sidebar rows and group labels measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit of every rendered font size across the product surfaces finds nothing off the scale; the only exceptions are avatar initials and emoji, which actor-avatar.tsx sizes proportionally to the avatar diameter by design. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
210 lines
7.1 KiB
TypeScript
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-body 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-body font-semibold text-foreground outline-none hover:text-foreground/80 focus-visible:text-foreground/80"
|
|
>
|
|
{title}
|
|
</button>
|
|
)}
|
|
{agent && (
|
|
<div className="truncate text-caption 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>
|
|
);
|
|
}
|