mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 10:59:06 +02:00
Replace the floating chat FAB/window with a first-class Chat tab under Inbox, laid out as an IM-style two-pane surface (thread list + conversation). Highlights: - New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable session selection; web + desktop routing wired up. Removes the old chat-fab / chat-window / resize-handles / context-items paths. - IM thread list: agent avatar + last-message preview + IM timestamp, red unread *count* badge (read-cursor model), presence-gated typing vs waiting. Rename lives only in the conversation header ⋯ menu (not the list hover). - Per-session conversation header (rename / view agent / delete), agent-aware empty state (avatar + name + description + starter prompts), and a deterministic clean-title derivation from the first message. - Server: read-cursor unread model (migration 145) and per-user pinned agents (migration 146, dedicated chat_pinned_agent table + handler/queries). New-agent welcome chat auto-enqueues a real agent run (LLM intro, no static template). - Design: fade the global --border token; borderless list headers on Chat/Inbox, kept (faded) on the conversation header. Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt. Co-authored-by: multica-agent <github@multica.ai>
178 lines
5.9 KiB
TypeScript
178 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { 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 } 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,
|
|
}: {
|
|
session: ChatSession;
|
|
agent: Agent | null;
|
|
}) {
|
|
const { t } = useT("chat");
|
|
const wsPaths = useWorkspacePaths();
|
|
const { push } = useNavigation();
|
|
const updateSession = useUpdateChatSession();
|
|
const deleteSession = useDeleteChatSession();
|
|
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
|
|
|
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);
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
|
{agent ? (
|
|
<ActorAvatar actorType="agent" actorId={agent.id} size={30} 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 />
|
|
<DropdownMenuItem variant="destructive" onClick={() => setConfirmDelete(true)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
{t(($) => $.header.delete)}
|
|
</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>
|
|
);
|
|
}
|