feat(chat): a new message shows on the pet; chat panels scroll one way only

A message from someone else in a room not on screen raises a card on the
pet (sender · group, the text or its files; click opens the room, gone after
10 s), plays the notification sound when notifications are on, and while
the tab is hidden shows a local system notification — never through the
server's push relay, which would reach other users' devices.

Group settings scrolled both ways (the name field and Rename overflowed, the
add-people list scrolled inside the panel): one vertical scroll now, the
field shrinks, the list grows in place. Every chat list hides horizontal
overflow and a long word or link wraps.
This commit is contained in:
2026-11-03 09:43:09 +07:00
parent 1dd2521183
commit 0d3e92cfda
8 changed files with 306 additions and 23 deletions
+61 -13
View File
@@ -9,7 +9,8 @@
* 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
* people; anyone may leave. `ChatNotice` is the card a new message raises on
* the pet while its window is closed. `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>
@@ -112,6 +113,47 @@ const Badge = ({ n }: { n: number }) =>
</span>
) : null;
/** A new message from someone, on the pet while its window is closed: opens the room. */
export function ChatNotice({
notice,
onOpen,
onDismiss,
}: {
notice: NonNullable<ChatState["notice"]>;
onOpen: (roomId: number) => void;
onDismiss: () => void;
}) {
const { t } = useTranslation("settings");
return (
<div
className="pet-chat flex w-72 items-start gap-2 rounded-2xl border border-border-light px-3 py-2.5 shadow-2xl animate-slide-up"
role="status"
>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => onOpen(notice.roomId)}
aria-label={t("sagi.people.openFrom", { name: notice.title })}
>
<span className="block truncate text-xs font-semibold text-fg-primary">
💬 {notice.title}
</span>
<span className="mt-0.5 line-clamp-2 block break-words text-xs text-fg-secondary [overflow-wrap:anywhere]">
{notice.text}
</span>
</button>
<button
type="button"
className="shrink-0 rounded-full p-1 text-fg-muted hover:text-fg-primary"
onClick={onDismiss}
aria-label={t("sagi.people.close")}
>
<X size={13} />
</button>
</div>
);
}
/** The column beside the window: the pet, the rooms, and New chat. */
export function ChatRail({
chat,
@@ -131,7 +173,7 @@ export function ChatRail({
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"
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-14 flex-col items-center gap-2 overflow-y-auto overflow-x-hidden rounded-3xl border border-border-light py-3 shadow-2xl"
aria-label={t("sagi.people.rail")}
>
<button
@@ -214,10 +256,13 @@ function PeoplePicker({
people,
picked,
onToggle,
inline = false,
}: {
people: ChatPerson[];
picked: Set<number>;
onToggle: (id: number) => void;
/** Inside a panel that scrolls itself: the list grows, no scroll of its own. */
inline?: boolean;
}) {
const { t } = useTranslation("settings");
const [q, setQ] = useState("");
@@ -225,15 +270,17 @@ function PeoplePicker({
`${p.displayName ?? ""} ${p.username}`.toLowerCase().includes(q.trim().toLowerCase())
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className={`flex flex-col gap-2 ${inline ? "" : "min-h-0 flex-1"}`}>
<input
className="input text-sm"
className="input w-full min-w-0 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">
<ul
className={`space-y-0.5 overflow-x-hidden ${inline ? "" : "min-h-0 flex-1 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">
@@ -310,7 +357,7 @@ export function NewChat({
<PeoplePicker people={chat.people} picked={picked} onToggle={toggle} />
{picked.size > 1 && (
<input
className="input text-sm"
className="input w-full min-w-0 text-sm"
placeholder={t("sagi.people.groupName")}
aria-label={t("sagi.people.groupName")}
maxLength={80}
@@ -450,7 +497,7 @@ function GroupSettings({
};
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">
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden px-3 py-3">
<button
type="button"
onClick={onBack}
@@ -467,14 +514,14 @@ function GroupSettings({
}}
>
<input
className="input flex-1 text-sm"
className="input min-w-0 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}>
<button type="submit" className="btn-secondary shrink-0" disabled={name === room.name}>
{t("sagi.people.rename")}
</button>
</form>
@@ -519,11 +566,12 @@ function GroupSettings({
</ul>
</div>
{owner && outside.length > 0 && (
<div className="flex min-h-[10rem] flex-col gap-1.5">
<div className="flex flex-col gap-1.5">
<p className="text-[11px] uppercase tracking-wider text-fg-muted">
{t("sagi.people.add")}
</p>
<PeoplePicker
inline
people={outside}
picked={adding}
onToggle={(id) =>
@@ -547,7 +595,7 @@ function GroupSettings({
)}
<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"
className="flex shrink-0 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")}
@@ -700,7 +748,7 @@ export function ChatRoomView({
<>
<div
ref={listRef}
className={`flex-1 space-y-1.5 overflow-y-auto px-3 py-3 ${drag ? "ring-2 ring-inset ring-accent" : ""}`}
className={`min-h-0 flex-1 space-y-1.5 overflow-y-auto overflow-x-hidden 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;
@@ -748,7 +796,7 @@ export function ChatRoomView({
</span>
)}
<div
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm ${
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm [overflow-wrap:anywhere] ${
own
? "rounded-br-md bg-accent-solid text-white"
: "pet-chat-reply rounded-bl-md text-fg-primary"
+21 -2
View File
@@ -103,7 +103,7 @@ 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 { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
import { useChat } from "./useChat";
// Stroking: this many left/right turns of the pointer over the pet, within
@@ -247,6 +247,11 @@ function SagiBody() {
const people = useChat();
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
// The room on screen: no notice for its messages.
const { setActive } = people;
useEffect(() => {
setActive(open && typeof pane === "number" ? pane : null);
}, [open, pane, setActive]);
// A room that went away (left, removed): back to the pet.
useEffect(() => {
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
@@ -503,7 +508,21 @@ function SagiBody() {
</SagiFlyout>
)}
{!open && !menu && brain.bubble && (
{!open && !menu && people.notice && (
<SagiFlyout anchor={anchor}>
<ChatNotice
notice={people.notice}
onDismiss={people.dismissNotice}
onOpen={(roomId) => {
people.dismissNotice();
setPane(roomId);
setOpen(true);
}}
/>
</SagiFlyout>
)}
{!open && !menu && !people.notice && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble
bubble={brain.bubble}
@@ -4,7 +4,8 @@
* 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.
* arrives live, and sends text with the attached files; the group settings
* scroll only one way; a new message raises a notice that opens its room.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -37,7 +38,7 @@ vi.mock("../../AuthGate", () => ({
}),
}));
import { ChatRail, ChatRoomView, NewChat } from "../ChatHub";
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "../ChatHub";
const msg = (id: number, userId: number, body: string): ChatMessage => ({
id,
@@ -76,6 +77,9 @@ function state(over: Partial<ChatState> = {}): ChatState {
upsert: vi.fn(),
onMessage: vi.fn(),
markRead: vi.fn(),
setActive: vi.fn(),
notice: null,
dismissNotice: vi.fn(),
...over,
};
}
@@ -139,4 +143,32 @@ describe("ChatHub", () => {
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
expect(await screen.findByText("xem nè")).toBeTruthy();
});
it("keeps the group settings to one vertical scroll, the name field shrinking", async () => {
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
render(<ChatRoomView chat={state()} room={group} onClose={() => {}} onLeft={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
const name = screen.getAllByLabelText("Group name (optional)")[0]!;
expect(name.className).toContain("min-w-0");
const panel = name.closest(".overflow-y-auto")!;
expect(panel.className).toContain("overflow-x-hidden");
// The add-people list grows inside that panel instead of scrolling itself.
const lists = panel.querySelectorAll("ul.overflow-y-auto");
expect(lists.length).toBe(0);
});
it("shows a new message as a card that opens its room", () => {
const onOpen = vi.fn();
const onDismiss = vi.fn();
render(
<ChatNotice
notice={{ key: 1, roomId: 7, title: "An · Team", text: "họp lúc 2h nhé" }}
onOpen={onOpen}
onDismiss={onDismiss}
/>
);
expect(screen.getByText("họp lúc 2h nhé")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Open the chat with An · Team" }));
expect(onOpen).toHaveBeenCalledWith(7);
});
});
@@ -261,7 +261,9 @@ describe("PetMenu", () => {
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
);
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
});
@@ -313,7 +315,9 @@ describe("PetSettings", () => {
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
render(<PetSettings />);
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
);
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
@@ -0,0 +1,98 @@
/**
* @file useChat.test.tsx
* @description `useChat`'s live side: a message from someone else raises a
* notice (sender, room) and bumps the room's unread count, plays the sound
* only with notifications on, and stays quiet for the room on screen and for
* the user's own messages; nothing runs in local mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { eventBus } from "../../../lib/eventBus";
import type { ChatMessage } from "../../../lib/api";
const auth = vi.hoisted(() => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } } as unknown,
}));
const api = vi.hoisted(() => ({
chat: { rooms: vi.fn(), people: vi.fn() },
}));
const sound = vi.hoisted(() => ({ playSound: vi.fn() }));
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
vi.mock("../../../lib/notify", () => sound);
import { useChat } from "../useChat";
const room = {
id: 7,
kind: "group" as const,
name: "Team",
createdBy: 1,
createdAt: "2026-11-03T01:00:00Z",
lastMessageAt: null,
members: [1, 2],
lastMessage: null,
lastReadId: 0,
unread: 0,
};
const msg = (id: number, userId: number): ChatMessage => ({
id,
roomId: 7,
userId,
kind: "text",
body: `tin ${id}`,
createdAt: `2026-11-03T02:00:0${id}Z`,
files: [],
});
const frame = (m: ChatMessage) =>
eventBus.publish({ type: "chat_message", data: { roomId: 7, message: m }, timestamp: "t" });
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
auth.state = { mode: "server", user: { id: 1, username: "me", role: "developer" } };
api.chat.rooms.mockResolvedValue({ rooms: [room] });
api.chat.people.mockResolvedValue({
people: [{ id: 2, username: "an", displayName: "An", avatarAt: null }],
});
});
describe("useChat", () => {
it("raises a notice for someone else's message in a room not on screen", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.people.length).toBe(1));
act(() => frame(msg(1, 2)));
expect(result.current.notice).toMatchObject({ roomId: 7, title: "An · Team", text: "tin 1" });
expect(result.current.unread).toBe(1);
expect(sound.playSound).not.toHaveBeenCalled(); // notifications off by default
});
it("plays the sound when notifications are on", async () => {
localStorage.setItem(
"agent-monitor-notifications",
JSON.stringify({ enabled: true, sound: "pop" })
);
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() => frame(msg(2, 2)));
expect(sound.playSound).toHaveBeenCalledWith("pop");
});
it("stays quiet for the room on screen and for your own messages", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() => result.current.setActive(7));
act(() => frame(msg(3, 2)));
act(() => frame(msg(4, 1)));
expect(result.current.notice).toBeNull();
});
it("does nothing in local mode", () => {
auth.state = { mode: "local" };
const { result } = renderHook(() => useChat());
expect(result.current.enabled).toBe(false);
expect(api.chat.rooms).not.toHaveBeenCalled();
});
});
+82 -2
View File
@@ -6,7 +6,12 @@
* 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.
* re-reads the list. A message from someone else in a room that is not open
* on screen raises a `notice` (the pet shows it as a card that opens the
* room), plays the user's notification sound when notifications are on, and —
* while the tab is hidden and the browser allows it — shows a local system
* notification (never through the server's push relay, which would reach
* other users' devices). Off in local mode — one person has no one to chat with.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,8 +19,13 @@ 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 { loadNotifPrefs } from "../../lib/notifPrefs";
import { playSound } from "../../lib/notify";
import type { WSMessage } from "../../lib/types";
/** A new message to show on the pet. */
export type ChatNotice = { key: number; roomId: number; title: string; text: string };
export type ChatState = {
/** Server mode with a login: the chat exists. */
enabled: boolean;
@@ -30,8 +40,36 @@ export type ChatState = {
onMessage: (fn: ((m: ChatMessage) => void) | null) => void;
/** Mark a room read locally (the view also tells the server). */
markRead: (roomId: number, upTo: number) => void;
/** The room on screen now (null: none, or the window closed) — no notice for it. */
setActive: (roomId: number | null) => void;
notice: ChatNotice | null;
dismissNotice: () => void;
};
/** What a notice says of a message: its text, else its files. */
function preview(m: ChatMessage): string {
const text = m.body.trim().replace(/\s+/g, " ");
if (text) return text.length > 140 ? `${text.slice(0, 139)}…` : text;
return m.files.map((f) => `📎 ${f.name}`).join(", ");
}
/** A system notification from this tab only (the service worker's, else the page's). */
async function localNotification(title: string, body: string) {
try {
if (!("Notification" in window) || Notification.permission !== "granted") return;
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) {
await reg.showNotification(title, { body, icon: "/favicon.svg", tag: `chat-${title}` });
return;
}
}
new Notification(title, { body, icon: "/favicon.svg" });
} catch {
// A browser that refuses: the card on the pet still shows it.
}
}
export function useChat(): ChatState {
const { state } = useAuth();
const me = state.mode === "server" && "user" in state && state.user ? state.user.id : null;
@@ -39,6 +77,11 @@ export function useChat(): ChatState {
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [people, setPeople] = useState<ChatPerson[]>([]);
const listener = useRef<((m: ChatMessage) => void) | null>(null);
const active = useRef<number | null>(null);
const [notice, setNotice] = useState<ChatNotice | null>(null);
// Names for a notice, read inside the socket handler.
const lookup = useRef({ rooms, people });
lookup.current = { rooms, people };
const reload = useCallback(async () => {
if (!enabled) return;
@@ -89,6 +132,18 @@ export function useChat(): ChatState {
// A room this list has not seen yet (added a moment ago): read it all.
if (!known) void reload();
listener.current?.(m);
if (m.userId === me || m.kind !== "text") return;
const hidden = document.visibilityState !== "visible";
if (active.current === m.roomId && !hidden) return;
const sender = lookup.current.people.find((p) => p.id === m.userId);
const from = sender ? sender.displayName || sender.username : "?";
const room = lookup.current.rooms.find((r) => r.id === m.roomId);
const title = room && room.kind === "group" ? `${from} · ${room.name || "…"}` : from;
const text = preview(m);
setNotice({ key: m.id, roomId: m.roomId, title, text });
const prefs = loadNotifPrefs();
if (prefs.enabled) playSound(prefs.sound);
if (hidden) void localNotification(title, text);
});
// Reconnected: frames may have been missed.
const offConn = eventBus.onConnection((up) => {
@@ -114,6 +169,31 @@ export function useChat(): ChatState {
);
}, []);
const setActive = useCallback((roomId: number | null) => {
active.current = roomId;
if (roomId != null) setNotice((n) => (n && n.roomId === roomId ? null : n));
}, []);
const dismissNotice = useCallback(() => setNotice(null), []);
// A notice goes away by itself.
useEffect(() => {
if (!notice) return;
const id = window.setTimeout(() => setNotice(null), 10000);
return () => window.clearTimeout(id);
}, [notice]);
const unread = useMemo(() => rooms.reduce((n, r) => n + r.unread, 0), [rooms]);
return { enabled, me, rooms, people, unread, reload, upsert, onMessage, markRead };
return {
enabled,
me,
rooms,
people,
unread,
reload,
upsert,
onMessage,
markRead,
setActive,
notice,
dismissNotice,
};
}
+2 -1
View File
@@ -261,7 +261,8 @@
"sysRenamed": "{{who}} renamed the group",
"sysLeft": "{{who}} left",
"sysAdded": "{{who}} added {{names}}",
"sysRemoved": "{{who}} removed {{names}}"
"sysRemoved": "{{who}} removed {{names}}",
"openFrom": "Open the chat with {{name}}"
}
},
"pricing": {
+2 -1
View File
@@ -261,7 +261,8 @@
"sysRenamed": "{{who}} đã đổi tên nhóm",
"sysLeft": "{{who}} đã rời nhóm",
"sysAdded": "{{who}} đã thêm {{names}}",
"sysRemoved": "{{who}} đã xoá {{names}}"
"sysRemoved": "{{who}} đã xoá {{names}}",
"openFrom": "Mở chat với {{name}}"
}
},
"pricing": {