feat(chat): direct chats and groups between users in the pet's window

Server mode: a rail beside the pet's chat lists the pet, then the user's
direct chats (one per pair) and groups with unread counts, and + for a new
chat. A room shows its messages (group senders named), loads older pages,
and sends text, pasted / dropped / attached files (<= 10 x 25 MB; images
inline). A group's creator renames it and adds or removes people; anyone may
leave. A blue dot on the pet counts unread messages.

Only a room's members read or write it, an admin included (roomFor → 404).
Files are stored under server-made names in <data dir>/chat/<room>/ and
served to members only under a sandbox CSP + nosniff. chat_message /
chat_room frames reach exactly the ids in `to` (scope.exactAudience).
New tables chat_rooms / chat_members / chat_messages / chat_files (additive).
This commit is contained in:
2026-11-03 09:33:33 +07:00
parent 85edc88a6d
commit 21a042dc2b
19 changed files with 2527 additions and 66 deletions
+846
View File
@@ -0,0 +1,846 @@
/**
* @file ChatHub.tsx
* @description Chat between users inside the pet's window (server mode,
* `/api/chat`, state from `useChat.ts`). `ChatRail` is the narrow column
* beside the window: the pet itself first (its AI chat, `PetChat.tsx`), then
* the user's direct chats and groups, newest first, each with its unread
* count, and **+** for a new chat. `ChatRoomView` is one room, the same size as
* the pet's chat: messages (a group shows who wrote each), older pages on
* demand, text with links, pasted / dropped / attached files (images shown
* inline, anything else a download, all served only to the room's members),
* and for a group its settings — the creator renames it and adds or removes
* people; anyone may leave. `NewChat` picks people: one person opens the
* direct chat with them, more (or a name) makes a group. Only a room's members
* ever see it; an admin has no view of other people's chats.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
ArrowUp,
FileText,
LogOut,
Paperclip,
Plus,
Settings2,
Users,
X,
} from "lucide-react";
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
import { formatBytes, formatTime } from "../../lib/format";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { UserAvatar } from "../users/UserAvatar";
import { useAuth } from "../AuthGate";
import type { ChatState } from "./useChat";
const MAX_FILES = 10;
const MAX_FILE_BYTES = 25 * 1024 * 1024;
/** Shown name of a person (display name, else login). */
const personName = (p: Pick<ChatPerson, "username" | "displayName"> | undefined) =>
p ? p.displayName || p.username : "?";
/** Everyone the view may need a name for: the people list plus the viewer. */
function useDirectory(chat: ChatState) {
const { state } = useAuth();
const self = state.mode === "server" ? state.user : null;
return useMemo(() => {
const m = new Map<number, ChatPerson>(chat.people.map((p) => [p.id, p]));
if (self)
m.set(self.id, {
id: self.id,
username: self.username,
displayName: self.displayName ?? null,
avatarAt: self.avatarAt ?? null,
});
return m;
}, [chat.people, self]);
}
/** The other person of a direct chat. */
const otherOf = (room: ChatRoom, me: number | null) =>
room.members.find((id) => id !== me) ?? room.members[0] ?? -1;
function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerson>) {
if (room.kind === "dm") return personName(dir.get(otherOf(room, me)));
if (room.name) return room.name;
return room.members
.filter((id) => id !== me)
.map((id) => personName(dir.get(id)))
.join(", ");
}
function RoomIcon({
room,
me,
dir,
className,
}: {
room: ChatRoom;
me: number | null;
dir: Map<number, ChatPerson>;
className: string;
}) {
if (room.kind === "dm") {
const p = dir.get(otherOf(room, me));
return (
<UserAvatar
name={personName(p)}
userId={p?.id}
avatarAt={p?.avatarAt}
className={className}
/>
);
}
return (
<span
className={`flex items-center justify-center rounded-full bg-surface-4 text-fg-secondary ${className}`}
>
<Users size={16} aria-hidden />
</span>
);
}
const Badge = ({ n }: { n: number }) =>
n > 0 ? (
<span className="absolute -right-0.5 -top-0.5 min-w-[1.1rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-[1.1rem] text-white">
{n > 99 ? "99+" : n}
</span>
) : null;
/** The column beside the window: the pet, the rooms, and New chat. */
export function ChatRail({
chat,
active,
onSelect,
petName,
}: {
chat: ChatState;
/** `"pet"`, `"new"` or a room id. */
active: "pet" | "new" | number;
onSelect: (v: "pet" | "new" | number) => void;
petName: string;
}) {
const { t } = useTranslation("settings");
const dir = useDirectory(chat);
const ring = (on: boolean) =>
on ? "ring-2 ring-accent ring-offset-2 ring-offset-surface-2" : "opacity-80 hover:opacity-100";
return (
<nav
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-14 flex-col items-center gap-2 overflow-y-auto rounded-3xl border border-border-light py-3 shadow-2xl"
aria-label={t("sagi.people.rail")}
>
<button
type="button"
onClick={() => onSelect("pet")}
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-4 text-lg transition ${ring(active === "pet")}`}
title={petName}
aria-label={petName}
aria-pressed={active === "pet"}
>
<span aria-hidden>🐾</span>
</button>
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
{chat.rooms.map((r) => {
const title = roomTitle(r, chat.me, dir);
return (
<button
key={r.id}
type="button"
onClick={() => onSelect(r.id)}
className={`relative shrink-0 rounded-full transition ${ring(active === r.id)}`}
title={title}
aria-label={r.unread ? t("sagi.people.unreadIn", { name: title, n: r.unread }) : title}
aria-pressed={active === r.id}
>
<RoomIcon room={r} me={chat.me} dir={dir} className="h-9 w-9 text-[11px]" />
<Badge n={r.unread} />
</button>
);
})}
<button
type="button"
onClick={() => onSelect("new")}
className={`mt-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-dashed border-border text-fg-muted transition hover:text-fg-primary ${active === "new" ? "ring-2 ring-accent" : ""}`}
title={t("sagi.people.new")}
aria-label={t("sagi.people.new")}
>
<Plus size={16} />
</button>
</nav>
);
}
function Shell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
role="dialog"
aria-label={label}
>
{children}
</div>
);
}
function HeadButton({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
onClick={onClick}
aria-label={label}
title={label}
>
{children}
</button>
);
}
/** People picker with checkboxes. */
function PeoplePicker({
people,
picked,
onToggle,
}: {
people: ChatPerson[];
picked: Set<number>;
onToggle: (id: number) => void;
}) {
const { t } = useTranslation("settings");
const [q, setQ] = useState("");
const shown = people.filter((p) =>
`${p.displayName ?? ""} ${p.username}`.toLowerCase().includes(q.trim().toLowerCase())
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<input
className="input text-sm"
placeholder={t("sagi.people.search")}
aria-label={t("sagi.people.search")}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{shown.map((p) => (
<li key={p.id}>
<label className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-4">
<input type="checkbox" checked={picked.has(p.id)} onChange={() => onToggle(p.id)} />
<UserAvatar
name={personName(p)}
userId={p.id}
avatarAt={p.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{personName(p)}
</span>
</label>
</li>
))}
{!shown.length && (
<li className="px-2 py-4 text-center text-xs text-fg-muted">{t("sagi.people.none")}</li>
)}
</ul>
</div>
);
}
/** Start a chat: one person → the direct chat; more, or a name → a group. */
export function NewChat({
chat,
onOpen,
onClose,
}: {
chat: ChatState;
onOpen: (roomId: number) => void;
onClose: () => void;
}) {
const { t } = useTranslation("settings");
const [picked, setPicked] = useState<Set<number>>(new Set());
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const group = picked.size > 1 || name.trim() !== "";
const toggle = (id: number) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const start = async () => {
if (!picked.size) return;
setBusy(true);
try {
const { room } = group
? await api.chat.createGroup(name.trim(), [...picked])
: await api.chat.openDm([...picked][0]!);
chat.upsert(room);
onOpen(room.id);
} catch (e) {
toast.error(e);
} finally {
setBusy(false);
}
};
return (
<Shell label={t("sagi.people.new")}>
<div className="pet-chat-head flex items-center gap-1 px-3 py-2.5">
<div className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
{t("sagi.people.new")}
</div>
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
<X size={16} />
</HeadButton>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2">
<p className="text-[11px] text-fg-muted">{t("sagi.people.newHint")}</p>
<PeoplePicker people={chat.people} picked={picked} onToggle={toggle} />
{picked.size > 1 && (
<input
className="input text-sm"
placeholder={t("sagi.people.groupName")}
aria-label={t("sagi.people.groupName")}
maxLength={80}
value={name}
onChange={(e) => setName(e.target.value)}
/>
)}
</div>
<div className="pet-chat-foot px-3 py-2.5">
<button
type="button"
className="btn-primary w-full justify-center"
disabled={!picked.size || busy}
onClick={() => void start()}
>
{group ? t("sagi.people.createGroup", { n: picked.size }) : t("sagi.people.startDm")}
</button>
</div>
</Shell>
);
}
const URL_RE = /(https?:\/\/[^\s<>"']+)/g;
/** Text with its http(s) links clickable (a new tab). */
function Linkified({ text }: { text: string }) {
const parts = text.split(URL_RE);
return (
<>
{parts.map((p, i) =>
i % 2 === 1 ? (
<a key={i} href={p} target="_blank" rel="noopener noreferrer" className="underline">
{p}
</a>
) : (
<span key={i}>{p}</span>
)
)}
</>
);
}
function SystemLine({ m, dir }: { m: ChatMessage; dir: Map<number, ChatPerson> }) {
const { t } = useTranslation("settings");
const who = personName(m.userId != null ? dir.get(m.userId) : undefined);
const [kind, ids] = m.body.split(":");
const names = (ids ?? "")
.split(",")
.filter(Boolean)
.map((id) => personName(dir.get(Number(id))))
.join(", ");
const text =
kind === "added"
? t("sagi.people.sysAdded", { who, names })
: kind === "removed"
? t("sagi.people.sysRemoved", { who, names })
: kind === "left"
? t("sagi.people.sysLeft", { who })
: kind === "renamed"
? t("sagi.people.sysRenamed", { who })
: t("sagi.people.sysCreated", { who });
return <p className="py-1 text-center text-[11px] text-fg-muted">{text}</p>;
}
function Files({ m, own }: { m: ChatMessage; own: boolean }) {
return (
<div className="mt-1 flex flex-col gap-1">
{m.files.map((f) => {
const url = api.chat.fileUrl(m.roomId, f.id);
return f.type.startsWith("image/") && /png|jpeg|gif|webp/.test(f.type) ? (
<a key={f.id} href={url} target="_blank" rel="noopener noreferrer">
<img
src={url}
alt={f.name}
loading="lazy"
className="max-h-48 max-w-full rounded-xl object-contain"
/>
</a>
) : (
<a
key={f.id}
href={url}
download={f.name}
className={`flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs ${own ? "bg-white/15" : "bg-surface-4"}`}
>
<FileText size={14} className="shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">{f.name}</span>
<span className="shrink-0 opacity-70">{formatBytes(f.size)}</span>
</a>
);
})}
</div>
);
}
/** A group's settings: name, members, Leave. */
function GroupSettings({
chat,
room,
dir,
onBack,
onLeft,
}: {
chat: ChatState;
room: ChatRoom;
dir: Map<number, ChatPerson>;
onBack: () => void;
onLeft: () => void;
}) {
const { t } = useTranslation("settings");
const owner = room.createdBy === chat.me;
const [name, setName] = useState(room.name);
const [adding, setAdding] = useState<Set<number>>(new Set());
const save = async (patch: { name?: string; add?: number[]; remove?: number[] }) => {
try {
const r = await api.chat.updateGroup(room.id, patch);
chat.upsert(r.room);
setAdding(new Set());
} catch (e) {
toast.error(e);
}
};
const leave = async () => {
const ok = await confirmDialog({
title: t("sagi.people.leaveTitle"),
message: t("sagi.people.leaveBody", { name: roomTitle(room, chat.me, dir) }),
confirmLabel: t("sagi.people.leave"),
});
if (!ok) return;
try {
await api.chat.leave(room.id);
await chat.reload();
onLeft();
} catch (e) {
toast.error(e);
}
};
const outside = chat.people.filter((p) => !room.members.includes(p.id));
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 self-start text-xs text-fg-muted hover:text-fg-primary"
>
<ArrowLeft size={13} /> {t("sagi.people.back")}
</button>
{owner && (
<form
className="flex gap-1.5"
onSubmit={(e) => {
e.preventDefault();
void save({ name });
}}
>
<input
className="input flex-1 text-sm"
aria-label={t("sagi.people.groupName")}
placeholder={t("sagi.people.groupName")}
maxLength={80}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit" className="btn-secondary" disabled={name === room.name}>
{t("sagi.people.rename")}
</button>
</form>
)}
<div>
<p className="mb-1 text-[11px] uppercase tracking-wider text-fg-muted">
{t("sagi.people.members", { n: room.members.length })}
</p>
<ul className="space-y-0.5">
{room.members.map((id) => {
const p = dir.get(id);
return (
<li key={id} className="flex items-center gap-2 rounded-lg px-2 py-1">
<UserAvatar
name={personName(p)}
userId={id}
avatarAt={p?.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{personName(p)}
{id === room.createdBy && (
<span className="ml-1 text-[10px] text-fg-muted">
{t("sagi.people.creator")}
</span>
)}
</span>
{owner && id !== chat.me && (
<button
type="button"
className="rounded-full p-1 text-fg-muted hover:text-status-danger"
aria-label={t("sagi.people.remove", { name: personName(p) })}
title={t("sagi.people.remove", { name: personName(p) })}
onClick={() => void save({ remove: [id] })}
>
<X size={13} />
</button>
)}
</li>
);
})}
</ul>
</div>
{owner && outside.length > 0 && (
<div className="flex min-h-[10rem] flex-col gap-1.5">
<p className="text-[11px] uppercase tracking-wider text-fg-muted">
{t("sagi.people.add")}
</p>
<PeoplePicker
people={outside}
picked={adding}
onToggle={(id) =>
setAdding((prev) => {
const n = new Set(prev);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
})
}
/>
<button
type="button"
className="btn-secondary justify-center"
disabled={!adding.size}
onClick={() => void save({ add: [...adding] })}
>
{t("sagi.people.addN", { n: adding.size })}
</button>
</div>
)}
<button
type="button"
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-status-danger hover:bg-status-danger/10"
onClick={() => void leave()}
>
<LogOut size={14} /> {t("sagi.people.leave")}
</button>
</div>
);
}
/** One direct chat or group. */
export function ChatRoomView({
chat,
room,
onClose,
onLeft,
}: {
chat: ChatState;
room: ChatRoom;
onClose: () => void;
onLeft: () => void;
}) {
const { t } = useTranslation("settings");
const dir = useDirectory(chat);
const title = roomTitle(room, chat.me, dir);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [hasMore, setHasMore] = useState(false);
const [draft, setDraft] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [sending, setSending] = useState(false);
const [settings, setSettings] = useState(false);
const [drag, setDrag] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const stick = useRef(true);
const { markRead } = chat;
const read = useCallback(
(upTo: number) => {
markRead(room.id, upTo);
api.chat.read(room.id, upTo).catch(() => undefined);
},
[room.id, markRead]
);
// The room's newest page on open, then each new message as it arrives.
useEffect(() => {
let live = true;
setMessages([]);
setSettings(false);
stick.current = true;
api.chat
.messages(room.id)
.then((r) => {
if (!live) return;
setMessages(r.messages);
setHasMore(r.hasMore);
const last = r.messages[r.messages.length - 1];
if (last) read(last.id);
})
.catch((e) => toast.error(e));
chat.onMessage((m) => {
if (m.roomId !== room.id) return;
setMessages((prev) => (prev.some((x) => x.id === m.id) ? prev : [...prev, m]));
if (document.visibilityState === "visible") read(m.id);
});
return () => {
live = false;
chat.onMessage(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room.id]);
useEffect(() => {
const el = listRef.current;
if (el && stick.current) el.scrollTop = el.scrollHeight;
}, [messages]);
const older = async () => {
const first = messages[0];
if (!first) return;
try {
const r = await api.chat.messages(room.id, first.id);
stick.current = false;
setMessages((prev) => [...r.messages, ...prev]);
setHasMore(r.hasMore);
} catch (e) {
toast.error(e);
}
};
const addFiles = (list: FileList | File[] | null) => {
if (!list) return;
const picked = [...list];
const big = picked.find((f) => f.size > MAX_FILE_BYTES);
if (big) toast.error(t("sagi.people.tooBig", { name: big.name }));
setFiles((prev) =>
[...prev, ...picked.filter((f) => f.size <= MAX_FILE_BYTES)].slice(0, MAX_FILES)
);
};
const submit = async (e?: FormEvent) => {
e?.preventDefault();
if ((!draft.trim() && !files.length) || sending) return;
setSending(true);
try {
const { message } = await api.chat.post(room.id, draft, files);
stick.current = true;
setMessages((prev) => (prev.some((x) => x.id === message.id) ? prev : [...prev, message]));
setDraft("");
setFiles([]);
} catch (err) {
toast.error(err);
} finally {
setSending(false);
}
};
return (
<Shell label={t("sagi.people.roomLabel", { name: title })}>
<div className="pet-chat-head flex items-center gap-2 px-2.5 py-2">
<RoomIcon room={room} me={chat.me} dir={dir} className="h-7 w-7 text-[10px]" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-fg-primary" title={title}>
{title}
</div>
{room.kind === "group" && (
<div className="text-[10px] uppercase tracking-wider text-fg-muted">
{t("sagi.people.members", { n: room.members.length })}
</div>
)}
</div>
{room.kind === "group" && (
<HeadButton label={t("sagi.people.settings")} onClick={() => setSettings((v) => !v)}>
<Settings2 size={15} />
</HeadButton>
)}
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
<X size={16} />
</HeadButton>
</div>
{settings && room.kind === "group" ? (
<GroupSettings
chat={chat}
room={room}
dir={dir}
onBack={() => setSettings(false)}
onLeft={onLeft}
/>
) : (
<>
<div
ref={listRef}
className={`flex-1 space-y-1.5 overflow-y-auto px-3 py-3 ${drag ? "ring-2 ring-inset ring-accent" : ""}`}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
onDragOver={(e) => {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
setDrag(true);
}
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault();
setDrag(false);
addFiles(e.dataTransfer.files);
}}
>
{hasMore && (
<button
type="button"
className="mx-auto block text-[11px] text-fg-muted hover:text-fg-primary"
onClick={() => void older()}
>
{t("sagi.people.older")}
</button>
)}
{!messages.length && (
<p className="py-10 text-center text-xs text-fg-muted">
{t("sagi.people.empty", { name: title })}
</p>
)}
{messages.map((m, i) => {
if (m.kind === "system") return <SystemLine key={m.id} m={m} dir={dir} />;
const own = m.userId === chat.me;
const prev = messages[i - 1];
const showName =
!own &&
room.kind === "group" &&
(!prev || prev.userId !== m.userId || prev.kind === "system");
return (
<div key={m.id} className={`flex flex-col ${own ? "items-end" : "items-start"}`}>
{showName && (
<span className="mb-0.5 px-1 text-[10px] text-fg-muted">
{personName(m.userId != null ? dir.get(m.userId) : undefined)}
</span>
)}
<div
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm ${
own
? "rounded-br-md bg-accent-solid text-white"
: "pet-chat-reply rounded-bl-md text-fg-primary"
}`}
title={formatTime(m.createdAt)}
>
{m.body && <Linkified text={m.body} />}
{m.files.length > 0 && <Files m={m} own={own} />}
</div>
</div>
);
})}
</div>
<div className="pet-chat-foot px-2.5 py-2.5">
{files.length > 0 && (
<ul className="mb-1.5 flex flex-wrap gap-1">
{files.map((f, i) => (
<li
key={`${f.name}-${i}`}
className="flex max-w-full items-center gap-1 rounded-full bg-surface-4 py-0.5 pl-2 pr-1 text-[11px] text-fg-secondary"
>
<span className="max-w-[10rem] truncate">{f.name}</span>
<button
type="button"
className="rounded-full p-0.5 hover:text-status-danger"
aria-label={t("sagi.people.unattach", { name: f.name })}
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
>
<X size={11} />
</button>
</li>
))}
</ul>
)}
<form onSubmit={submit} className="flex items-end gap-1.5">
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
addFiles(e.target.files);
e.target.value = "";
}}
/>
<button
type="button"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
aria-label={t("sagi.people.attach")}
title={t("sagi.people.attach")}
onClick={() => fileRef.current?.click()}
>
<Paperclip size={16} />
</button>
<textarea
rows={1}
autoFocus
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.people.placeholder", { name: title })}
aria-label={t("sagi.people.placeholder", { name: title })}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPaste={(e) => {
if (e.clipboardData.files.length) {
e.preventDefault();
addFiles(e.clipboardData.files);
}
}}
onKeyDown={(e) => {
if (isSubmitEnter(e) && !e.shiftKey) {
e.preventDefault();
void submit();
} else if (e.key === "Escape") {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClose();
}
}}
/>
<button
type="submit"
disabled={(!draft.trim() && !files.length) || sending}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
aria-label={t("sagi.panel.send")}
>
<ArrowUp size={16} />
</button>
</form>
</div>
</>
)}
</Shell>
);
}
+74 -40
View File
@@ -7,7 +7,10 @@
* shares the single WebSocket.
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
* itself is answered there, anything else goes to the AI.
* itself is answered there, anything else goes to the AI. In server mode a
* rail beside it (`ChatHub.tsx`, `useChat.ts`) switches to direct chats and
* groups with other users, and a blue dot on the pet counts their unread
* messages.
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
@@ -100,6 +103,8 @@ import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu";
import { Heart } from "lucide-react";
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
import { ChatRail, ChatRoomView, NewChat } from "./ChatHub";
import { useChat } from "./useChat";
// Stroking: this many left/right turns of the pointer over the pet, within
// STROKE_MS and with no button held, is a pat; then a pause before the next.
@@ -238,6 +243,14 @@ function SagiBody() {
const navigate = useNavigate();
const href = useSettingsHref();
const brain = useSagiBrain();
// Server mode: chat with other users beside the pet's own (ChatHub.tsx).
const people = useChat();
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
// A room that went away (left, removed): back to the pet.
useEffect(() => {
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
}, [pane, room, people.rooms.length]);
const place = useSagiPosition(size);
// Keep the pet and its size in sync with Settings / other tabs (enabled is
@@ -416,47 +429,63 @@ function SagiBody() {
re-attaches the same session. */}
{open && (
<SagiFlyout anchor={anchor} beside>
<PetChat
name={name}
title={
<PetNameButton
<div className="flex items-start gap-2">
{people.enabled && (
<ChatRail chat={people} active={pane} onSelect={setPane} petName={name} />
)}
{people.enabled && pane === "new" ? (
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
) : people.enabled && room ? (
<ChatRoomView
chat={people}
room={room}
onClose={() => setOpen(false)}
onLeft={() => setPane("pet")}
/>
) : (
<PetChat
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
title={
<PetNameButton
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
/>
}
headerActions={
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={brain.pat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
}
info={
<SagiInfo
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setOpen(false);
}}
onBusy={onBusy}
/>
}
headerActions={
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={brain.pat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
}
info={
<SagiInfo
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setOpen(false);
}}
onBusy={onBusy}
/>
)}
</div>
</SagiFlyout>
)}
@@ -550,6 +579,11 @@ function SagiBody() {
</span>
</span>
)}
{people.unread > 0 && (
<span className="sagi-chat-dot" aria-hidden>
{people.unread > 9 ? "9+" : people.unread}
</span>
)}
{brain.status.errorCount > 0 ? (
<span className="sagi-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
@@ -0,0 +1,142 @@
/**
* @file ChatHub.test.tsx
* @description The pet window's chat between users (`ChatHub.tsx`): the rail
* lists the pet then the rooms with their unread counts; New chat opens a
* direct chat for one person and makes a group for several; a room reads its
* messages (a group names who wrote each), marks them read, appends one that
* arrives live, and sends text with the attached files.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ChatMessage, ChatRoom } from "../../../lib/api";
import type { ChatState } from "../useChat";
const api = vi.hoisted(() => ({
chat: {
openDm: vi.fn(),
createGroup: vi.fn(),
messages: vi.fn(),
post: vi.fn(),
read: vi.fn(),
updateGroup: vi.fn(),
leave: vi.fn(),
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
},
users: { avatarUrl: () => null },
me: { avatarUrl: () => null },
}));
vi.mock("../../../lib/api", async (orig) => ({
...(await orig<Record<string, unknown>>()),
api,
}));
vi.mock("../../AuthGate", () => ({
useAuth: () => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
}),
}));
import { ChatRail, ChatRoomView, NewChat } from "../ChatHub";
const msg = (id: number, userId: number, body: string): ChatMessage => ({
id,
roomId: 7,
userId,
kind: "text",
body,
createdAt: "2026-11-03T02:00:00Z",
files: [],
});
const group: ChatRoom = {
id: 7,
kind: "group",
name: "Team",
createdBy: 1,
createdAt: "2026-11-03T01:00:00Z",
lastMessageAt: null,
members: [1, 2, 3],
lastMessage: null,
lastReadId: 0,
unread: 2,
};
const dm: ChatRoom = { ...group, id: 8, kind: "dm", name: "", members: [1, 2], unread: 0 };
function state(over: Partial<ChatState> = {}): ChatState {
return {
enabled: true,
me: 1,
rooms: [group, dm],
people: [
{ id: 2, username: "an", displayName: "An", avatarAt: null },
{ id: 3, username: "binh", displayName: null, avatarAt: null },
],
unread: 2,
reload: vi.fn(async () => {}),
upsert: vi.fn(),
onMessage: vi.fn(),
markRead: vi.fn(),
...over,
};
}
beforeEach(() => vi.clearAllMocks());
describe("ChatHub", () => {
it("lists the pet, then each room with its unread count", () => {
const onSelect = vi.fn();
render(<ChatRail chat={state()} active="pet" onSelect={onSelect} petName="Sagi" />);
expect(screen.getByRole("button", { name: "Sagi" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Team, 2 unread" }));
expect(onSelect).toHaveBeenCalledWith(7);
// A direct chat is named after the other person.
expect(screen.getByRole("button", { name: "An" })).toBeTruthy();
});
it("opens a direct chat for one person and a group for several", async () => {
const chat = state();
const onOpen = vi.fn();
api.chat.openDm.mockResolvedValue({ room: dm });
render(<NewChat chat={chat} onOpen={onOpen} onClose={() => {}} />);
fireEvent.click(screen.getByRole("checkbox", { name: /An/ }));
fireEvent.click(screen.getByRole("button", { name: "Start chat" }));
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(8));
expect(api.chat.openDm).toHaveBeenCalledWith(2);
api.chat.createGroup.mockResolvedValue({ room: group });
fireEvent.click(screen.getByRole("checkbox", { name: /binh/ }));
fireEvent.change(screen.getByLabelText("Group name (optional)"), { target: { value: "Team" } });
fireEvent.click(screen.getByRole("button", { name: "Create group (2 people)" }));
await waitFor(() => expect(api.chat.createGroup).toHaveBeenCalledWith("Team", [2, 3]));
});
it("reads the room, names group senders, marks it read and sends with files", async () => {
const chat = state();
api.chat.messages.mockResolvedValue({
messages: [msg(1, 2, "chào"), msg(2, 1, "hi")],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
render(<ChatRoomView chat={chat} room={group} onClose={() => {}} onLeft={() => {}} />);
expect(await screen.findByText("chào")).toBeTruthy();
expect(screen.getByText("An")).toBeTruthy(); // sender of a group message
expect(api.chat.read).toHaveBeenCalledWith(7, 2);
expect(chat.markRead).toHaveBeenCalledWith(7, 2);
// A message arriving live is appended.
const calls = vi.mocked(chat.onMessage).mock.calls;
const push = calls[calls.length - 1]![0]!;
push(msg(3, 3, "mới"));
expect(await screen.findByText("mới")).toBeTruthy();
const file = new File(["x"], "a.png", { type: "image/png" });
const box = screen.getByLabelText("Message Team");
fireEvent.paste(box, { clipboardData: { files: [file] } });
expect(screen.getByText("a.png")).toBeTruthy();
fireEvent.change(box, { target: { value: "xem nè" } });
api.chat.post.mockResolvedValue({ message: { ...msg(4, 1, "xem nè") } });
fireEvent.click(screen.getByRole("button", { name: /send/i }));
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
expect(await screen.findByText("xem nè")).toBeTruthy();
});
});
+21 -1
View File
@@ -6,7 +6,8 @@
* infinite animation makes the browser draw a new frame 60 times a second for
* as long as the page is open (the drop-shadow filter re-rendered each time),
* which made the whole desktop stutter. All motion is disabled under
* [data-reduced="1"] and prefers-reduced-motion.
* [data-reduced="1"] and prefers-reduced-motion. The blue dot counts unread
* chat messages from other users (server mode).
* Transform origins are the delicate part - see the comments on .sagi-avatar
* and .sagi-eye-blink for which reference box each one resolves against.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -406,6 +407,25 @@
outline: 2px solid #818cf8;
outline-offset: 3px;
}
/* Unread chat messages from other users: top-left, opposite the error dot. */
.sagi-chat-dot {
position: absolute;
top: 2px;
left: 2px;
min-width: 14px;
height: 14px;
padding: 0 2px;
border-radius: 9999px;
background: #3b82f6;
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #0c0c14;
}
.sagi-error-dot {
position: absolute;
top: 2px;
+119
View File
@@ -0,0 +1,119 @@
/**
* @file useChat.ts
* @description The pet window's chat between users (server mode only): the
* signed-in user's rooms (`GET /api/chat/rooms`), the people they can start a
* chat with (`GET /api/chat/people`, read once), and the total unread count
* for the badge on the pet. A `chat_message` frame updates its room's newest
* message and unread count in place and is handed to the open room's view
* (`onMessage`); a `chat_room` frame (made, renamed, members changed, left)
* re-reads the list. Off in local mode — one person has no one to chat with.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { useAuth } from "../AuthGate";
import type { WSMessage } from "../../lib/types";
export type ChatState = {
/** Server mode with a login: the chat exists. */
enabled: boolean;
me: number | null;
rooms: ChatRoom[];
people: ChatPerson[];
unread: number;
reload: () => Promise<void>;
/** A room changed here (opened, renamed, read): put it in the list now. */
upsert: (room: ChatRoom) => void;
/** The open room's view, told of each new message for it. */
onMessage: (fn: ((m: ChatMessage) => void) | null) => void;
/** Mark a room read locally (the view also tells the server). */
markRead: (roomId: number, upTo: number) => void;
};
export function useChat(): ChatState {
const { state } = useAuth();
const me = state.mode === "server" && "user" in state && state.user ? state.user.id : null;
const enabled = me != null;
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [people, setPeople] = useState<ChatPerson[]>([]);
const listener = useRef<((m: ChatMessage) => void) | null>(null);
const reload = useCallback(async () => {
if (!enabled) return;
try {
setRooms((await api.chat.rooms()).rooms);
} catch {
// The pet's chat with the AI still works; the list retries on the next frame.
}
}, [enabled]);
useEffect(() => {
if (!enabled) return;
void reload();
api.chat
.people()
.then((r) => setPeople(r.people))
.catch(() => undefined);
}, [enabled, reload]);
useEffect(() => {
if (!enabled) return;
const off = eventBus.subscribe((msg: WSMessage) => {
if (msg.type === "chat_room") {
void reload();
return;
}
if (msg.type !== "chat_message") return;
const m = (msg.data as { message?: ChatMessage }).message;
if (!m) return;
let known = false;
setRooms((prev) => {
const next = prev.map((r) => {
if (r.id !== m.roomId) return r;
known = true;
const own = m.userId === me;
return {
...r,
lastMessage: m,
lastMessageAt: m.createdAt,
unread: own ? 0 : r.unread + 1,
lastReadId: own ? m.id : r.lastReadId,
};
});
return next.sort((a, b) =>
(b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)
);
});
// A room this list has not seen yet (added a moment ago): read it all.
if (!known) void reload();
listener.current?.(m);
});
// Reconnected: frames may have been missed.
const offConn = eventBus.onConnection((up) => {
if (up) void reload();
});
return () => {
off();
offConn();
};
}, [enabled, me, reload]);
const upsert = useCallback((room: ChatRoom) => {
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
}, []);
const onMessage = useCallback((fn: ((m: ChatMessage) => void) | null) => {
listener.current = fn;
}, []);
const markRead = useCallback((roomId: number, upTo: number) => {
setRooms((prev) =>
prev.map((r) =>
r.id === roomId ? { ...r, unread: 0, lastReadId: Math.max(r.lastReadId, upTo) } : r
)
);
}, []);
const unread = useMemo(() => rooms.reduce((n, r) => n + r.unread, 0), [rooms]);
return { enabled, me, rooms, people, unread, reload, upsert, onMessage, markRead };
}
+80 -9
View File
@@ -77,13 +77,21 @@
"run_done_other": "{{count}} runs finished 🐾"
},
"greet": {
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
"morning": [
"good morning! ☀️",
"morning! coffee first? ☕",
"rise and shine 🌤️"
],
"afternoon": [
"good afternoon! 🐾",
"hey, welcome back 😺",
"afternoon! ready when you are ✨"
],
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
"evening": [
"good evening! 🌆",
"evening shift? I'm in 😼",
"hey there, night owl-to-be 🌙"
],
"night": [
"burning the midnight oil? 🌙",
"late one, huh? I'll keep watch 🦉",
@@ -106,7 +114,11 @@
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
"hello": "hi there! 😺 {{summary}}.",
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
"thanks": [
"anytime! 💜",
"happy to help 😸",
"you got it 🐾"
],
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
"errorsNone": "no errors - all clean 🐾",
@@ -163,17 +175,41 @@
"can't reach the server 📡",
"no signal - taking a nap 💤"
],
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
"worried": [
"that didn't look right 😟",
"keeping an eye out 👀",
"hmm, something's off 🫣"
],
"stuck": [
"a session's been quiet a while… 🤔",
"is something stuck? ⏳",
"still chewing on it… 😾"
],
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
"happy": [
"great run! 😻",
"love a tidy finish ✨",
"purrfect 💜"
],
"thinking": [
"hmm, let me look… 🤔",
"sniffing around… 🐾",
"one sec, checking 🔍"
],
"watching": [
"on the prowl 👀",
"watching your sessions 😼",
"eyes peeled 🐾"
],
"sleeping": [
"zzz… 💤",
"wake me if something happens 😴",
"curled up, all calm 🐈"
],
"idle": [
"all quiet 😺",
"ready when you are 🐾",
"just vibing ✨"
]
},
"chat": {
"label": "Chat with {{name}}",
@@ -191,6 +227,41 @@
"starting": "Starting {{tool}}…",
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
},
"people": {
"rail": "Chats",
"new": "New chat",
"newHint": "Pick one person for a direct chat, or several for a group.",
"search": "Search people",
"none": "No one found",
"groupName": "Group name (optional)",
"createGroup": "Create group ({{n}} people)",
"startDm": "Start chat",
"close": "Close",
"unreadIn": "{{name}}, {{n}} unread",
"roomLabel": "Chat with {{name}}",
"members": "{{n}} members",
"settings": "Group settings",
"back": "Back to the chat",
"rename": "Rename",
"creator": "· created the group",
"remove": "Remove {{name}}",
"add": "Add people",
"addN": "Add {{n}}",
"leave": "Leave group",
"leaveTitle": "Leave this group?",
"leaveBody": "You will no longer see {{name}}. Someone in it can add you again.",
"older": "Load older messages",
"empty": "No messages yet. Say hello to {{name}}.",
"placeholder": "Message {{name}}",
"attach": "Attach files",
"unattach": "Remove {{name}}",
"tooBig": "{{name}} is over 25 MB",
"sysCreated": "{{who}} created the group",
"sysRenamed": "{{who}} renamed the group",
"sysLeft": "{{who}} left",
"sysAdded": "{{who}} added {{names}}",
"sysRemoved": "{{who}} removed {{names}}"
}
},
"pricing": {
+95 -12
View File
@@ -77,10 +77,26 @@
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
},
"greet": {
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
"morning": [
"chào buổi sáng! ☀️",
"sáng rồi! cà phê chưa? ☕",
"ngày mới tốt lành nha 🌤️"
],
"afternoon": [
"chào buổi chiều! 🐾",
"bạn quay lại rồi 😺",
"chiều nay làm gì đây? ✨"
],
"evening": [
"chào buổi tối! 🌆",
"ca tối hả? có mình đây 😼",
"tối rồi, cố lên nhé 🌙"
],
"night": [
"khuya rồi mà vẫn làm à? 🌙",
"để mình canh giúp cho 🦉",
"nhớ nghỉ ngơi nha 💤"
],
"summary_one": "{{count}} phiên đang chạy",
"summary_other": "{{count}} phiên đang chạy",
"summaryWaiting": "{{count}} phiên chờ bạn",
@@ -98,7 +114,11 @@
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
"hello": "chào bạn! 😺 {{summary}}.",
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
"thanks": [
"có gì đâu! 💜",
"rất vui được giúp 😸",
"chuyện nhỏ 🐾"
],
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
@@ -155,13 +175,41 @@
"không gọi được máy chủ 📡",
"mất sóng - đi ngủ chút 💤"
],
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
"worried": [
"có vẻ không ổn 😟",
"mình đang để mắt đây 👀",
"hừm, có gì đó lạ lạ 🫣"
],
"stuck": [
"một phiên im lặng lâu rồi… 🤔",
"có gì bị kẹt không? ⏳",
"vẫn đang nhai… 😾"
],
"happy": [
"tuyệt vời! 😻",
"kết thúc gọn ghê ✨",
"hoàn hảo 💜"
],
"thinking": [
"để mình xem… 🤔",
"đang đánh hơi… 🐾",
"chờ chút, đang kiểm tra 🔍"
],
"watching": [
"đang tuần tra 👀",
"đang canh các phiên của bạn 😼",
"mắt mở to 🐾"
],
"sleeping": [
"zzz… 💤",
"có gì thì gọi mình dậy nhé 😴",
"cuộn tròn, mọi thứ yên ổn 🐈"
],
"idle": [
"yên ắng quá 😺",
"sẵn sàng khi bạn cần 🐾",
"chill thôi ✨"
]
},
"chat": {
"label": "Trò chuyện với {{name}}",
@@ -179,6 +227,41 @@
"starting": "Đang khởi động {{tool}}…",
"notStarted": "Đang chờ phiên khởi động — nếu nó hỏi gì, hãy mở trong workspace.",
"notSent": "Không gửi được tới terminal của phiên — thử lại, hoặc mở trong workspace."
},
"people": {
"rail": "Trò chuyện",
"new": "Chat mới",
"newHint": "Chọn 1 người để chat riêng, hoặc nhiều người để tạo nhóm.",
"search": "Tìm người",
"none": "Không tìm thấy ai",
"groupName": "Tên nhóm (không bắt buộc)",
"createGroup": "Tạo nhóm ({{n}} người)",
"startDm": "Bắt đầu chat",
"close": "Đóng",
"unreadIn": "{{name}}, {{n}} tin chưa đọc",
"roomLabel": "Chat với {{name}}",
"members": "{{n}} thành viên",
"settings": "Cài đặt nhóm",
"back": "Quay lại cuộc chat",
"rename": "Đổi tên",
"creator": "· người tạo nhóm",
"remove": "Xoá {{name}} khỏi nhóm",
"add": "Thêm người",
"addN": "Thêm {{n}} người",
"leave": "Rời nhóm",
"leaveTitle": "Rời nhóm này?",
"leaveBody": "Bạn sẽ không còn thấy {{name}}. Người trong nhóm có thể thêm bạn lại.",
"older": "Xem tin cũ hơn",
"empty": "Chưa có tin nhắn. Hãy chào {{name}}.",
"placeholder": "Nhắn cho {{name}}",
"attach": "Đính kèm file",
"unattach": "Bỏ {{name}}",
"tooBig": "{{name}} lớn hơn 25 MB",
"sysCreated": "{{who}} đã tạo nhóm",
"sysRenamed": "{{who}} đã đổi tên nhóm",
"sysLeft": "{{who}} đã rời nhóm",
"sysAdded": "{{who}} đã thêm {{names}}",
"sysRemoved": "{{who}} đã xoá {{names}}"
}
},
"pricing": {
+81
View File
@@ -547,6 +547,38 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** Someone to chat with (`GET /api/chat/people`). */
export type ChatPerson = {
id: number;
username: string;
displayName: string | null;
avatarAt: string | null;
};
/** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */
export type ChatFile = { id: string; name: string; type: string; size: number };
/** One chat message; `system` bodies: created, renamed, left, added:<ids>, removed:<ids>. */
export type ChatMessage = {
id: number;
roomId: number;
userId: number | null;
kind: "text" | "system";
body: string;
createdAt: string;
files: ChatFile[];
};
/** A direct chat or a group, as the caller sees it. */
export type ChatRoom = {
id: number;
kind: "dm" | "group";
name: string;
createdBy: number | null;
createdAt: string;
lastMessageAt: string | null;
members: number[];
lastMessage: ChatMessage | null;
lastReadId: number;
unread: number;
};
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
disabled: boolean;
@@ -1567,6 +1599,55 @@ export const api = {
request<HostHistory>(`/host/history?range=${range}`),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
/** `/api/chat` — chat between users in server mode (the pet's window). */
chat: {
people: () => request<{ people: ChatPerson[] }>("/chat/people"),
rooms: () => request<{ rooms: ChatRoom[] }>("/chat/rooms"),
/** The one direct chat with `userId`, made on first use. */
openDm: (userId: number) =>
request<{ room: ChatRoom }>("/chat/rooms", {
method: "POST",
body: JSON.stringify({ kind: "dm", userId }),
}),
createGroup: (name: string, userIds: number[]) =>
request<{ room: ChatRoom }>("/chat/rooms", {
method: "POST",
body: JSON.stringify({ kind: "group", name, userIds }),
}),
/** The group's creator only. */
updateGroup: (id: number, patch: { name?: string; add?: number[]; remove?: number[] }) =>
request<{ room: ChatRoom }>(`/chat/rooms/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
leave: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}/leave`, { method: "POST" }),
messages: (id: number, before?: number) =>
request<{ messages: ChatMessage[]; hasMore: boolean }>(
`/chat/rooms/${id}/messages${before ? `?before=${before}` : ""}`
),
/** Text and files (multipart when there are files). */
post: (id: number, body: string, files: File[] = []) => {
if (!files.length)
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
const form = new FormData();
form.append("body", body);
for (const f of files) form.append("files", f, f.name);
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
method: "POST",
body: form,
});
},
read: (id: number, upTo: number) =>
request<{ lastReadId: number }>(`/chat/rooms/${id}/read`, {
method: "POST",
body: JSON.stringify({ upTo }),
}),
fileUrl: (roomId: number, fileId: string) =>
`${BASE}/chat/rooms/${roomId}/files/${encodeURIComponent(fileId)}`,
},
users: {
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
+7 -2
View File
@@ -1917,10 +1917,13 @@ export interface WSMessage {
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
* remote_source.status → RemoteSourceStatusPayload;
* remote_data.updated → RemoteDataUpdatedPayload;
* lane_update → { lane?: Lane; removed?: number }. */
* lane_update → { lane?: Lane; removed?: number };
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to }. */
type:
| "session_created"
| "session_updated"
| "chat_message"
| "chat_room"
| "agent_created"
| "agent_updated"
| "new_event"
@@ -1967,7 +1970,9 @@ export interface WSMessage {
| { lane?: Lane; removed?: number }
| LaneHookOutputPayload
| LaneRuntimePayload
| LaneHookResultPayload;
| LaneHookResultPayload
/** chat_message / chat_room (lib/api.ts ChatMessage). */
| { roomId: number; message?: unknown; to?: number[] };
/** ISO timestamp the server broadcast this message (not necessarily the
* same instant the underlying event occurred). */
timestamp: string;