Compare commits

...

2 Commits

Author SHA1 Message Date
nntrivi2001 0792c88612 chore: v2.8.67 — remove user chat and drop its data
The chat_* tables are dropped at start, and <data dir>/chat/ (its uploads)
is deleted.
2026-11-03 10:08:57 +07:00
nntrivi2001 a3d1dce2ca revert: remove user chat (direct + groups) from the pet window
Reverts 21a042d, 0d3e92c, 0016b6a, c797e98, 03ebea1. Keeps the pet's
own-sessions scope, status bar mine=1, scroll speed and the health hover card.
The chat_* tables and <data dir>/chat/ files are left in place (unused).
2026-11-03 10:06:12 +07:00
27 changed files with 92 additions and 4343 deletions
+1 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.66 (2026-11-03) — chat: delete for yourself, purge what everyone deleted, Seen, chat button without the pet._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.67 (2026-11-03) — user chat removed; its tables and uploads are dropped at start._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -65,7 +65,6 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A chat between users is its members' alone.** `server/lib/chat.js` (`/api/chat`, server mode only): every read and write resolves the room through `roomFor(caller, id)` — not a member is `404`, an admin included, and there is no route listing another user's rooms; `chat_message`/`chat_room`/`chat_deleted` frames go only to the ids in `to` (`exactAudience`); a message is deleted only by its author; "delete chat" is the caller's alone (`cleared_id`/`hidden`), and only what EVERY member deleted is purged for real (`purge` — messages, files, a direct chat both deleted). A read mark never passes the room's newest message (it is what others see as Seen). Files are stored under a server-made name in `<data dir>/chat/<room>/` and served only to members under a `sandbox` CSP + `nosniff` (raster images inline, anything else an attachment). Do not add an admin view of chats.
- **A web page is previewed by URL in the session panel's Preview tab, never on the dashboard's origin, and every link follows ONE rule.** `client/src/components/lanes/UrlPreview.tsx` shows an `<iframe src>` whose sandbox has no `allow-top-navigation*`; `normalizePreviewUrl` (`client/src/lib/openInPreview.ts`) takes http(s) only, no userinfo, and refuses the dashboard's own origin (with `allow-same-origin` that frame would BE the dashboard). A chat link, bare URL or terminal link to `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` opens there only while a session panel answers the event and the dashboard itself is on loopback; anything else — and any Ctrl/Cmd click — opens a browser tab. Do not add a second preview surface or a per-place button, and do not proxy a page through the dashboard's origin.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
-108
View File
@@ -1,108 +0,0 @@
/**
* @file ChatDock.tsx
* @description Chat between users for someone who hid the pet (server mode):
* a round 💬 button in the bottom-right corner, above the status bar, with
* the unread count, that opens the same chat window the pet has (`ChatHub.tsx`
* — rooms rail without the pet, a room, New chat). A new message from someone
* shows as the same card above the button while the window is closed. Mounted
* by `SagiMount` only while the pet is off, so the two never both run.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MessagesSquare } from "lucide-react";
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
import { useChat } from "./useChat";
export function ChatDock() {
const { t } = useTranslation("settings");
const chat = useChat();
const [open, setOpen] = useState(false);
const [pane, setPane] = useState<"new" | number>("new");
const [settingsAsk, setSettingsAsk] = useState(0);
const room = typeof pane === "number" ? chat.rooms.find((r) => r.id === pane) : undefined;
// Opening with nothing picked: the newest chat, else New chat.
useEffect(() => {
if (!open) return;
if (typeof pane === "number" && !room) setPane(chat.rooms[0]?.id ?? "new");
}, [open, pane, room, chat.rooms]);
useEffect(() => {
if (open && pane === "new" && chat.rooms[0]) setPane(chat.rooms[0].id);
// Only when the window opens.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const { setActive } = chat;
useEffect(() => {
setActive(open && typeof pane === "number" ? pane : null);
}, [open, pane, setActive]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
if (!chat.enabled) return null;
return (
<div className="fixed bottom-10 right-4 z-40 flex flex-col items-end gap-2">
{open && (
<div className="flex items-start gap-2">
<ChatRail
chat={chat}
active={pane}
onSelect={(v) => setPane(v === "pet" ? "new" : v)}
onSettings={(id) => {
setPane(id);
setSettingsAsk((n) => n + 1);
}}
/>
{room ? (
<ChatRoomView
key={`${room.id}:${room.clearedId ?? 0}`}
chat={chat}
room={room}
onClose={() => setOpen(false)}
onLeft={() => setPane("new")}
onOpenRoom={setPane}
showSettings={settingsAsk}
/>
) : (
<NewChat chat={chat} onOpen={setPane} onClose={() => setOpen(false)} />
)}
</div>
)}
{!open && chat.notice && (
<ChatNotice
notice={chat.notice}
onDismiss={chat.dismissNotice}
onOpen={(roomId) => {
chat.dismissNotice();
setPane(roomId);
setOpen(true);
}}
/>
)}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="relative flex h-11 w-11 items-center justify-center rounded-full bg-accent-solid text-white shadow-lg transition-opacity hover:opacity-90"
aria-label={
chat.unread ? t("sagi.people.dockUnread", { n: chat.unread }) : t("sagi.people.dock")
}
title={t("sagi.people.dock")}
aria-expanded={open}
>
<MessagesSquare size={20} />
{chat.unread > 0 && (
<span className="absolute -right-1 -top-1 min-w-[1.25rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-5 text-white">
{chat.unread > 99 ? "99+" : chat.unread}
</span>
)}
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff
+41 -109
View File
@@ -7,10 +7,7 @@
* 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. 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.
* itself is answered there, anything else goes to the AI.
* 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
@@ -103,8 +100,6 @@ import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu";
import { Heart } from "lucide-react";
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
import { ChatNotice, 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.
@@ -243,21 +238,6 @@ 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");
// Bumped by the rail's "Group settings": the room opens on its settings.
const [settingsAsk, setSettingsAsk] = useState(0);
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");
}, [pane, room, people.rooms.length]);
const place = useSagiPosition(size);
// Keep the pet and its size in sync with Settings / other tabs (enabled is
@@ -436,76 +416,47 @@ function SagiBody() {
re-attaches the same session. */}
{open && (
<SagiFlyout anchor={anchor} beside>
<div className="flex items-start gap-2">
{people.enabled && (
<ChatRail
chat={people}
active={pane}
onSelect={setPane}
petName={name}
onSettings={(id) => {
setPane(id);
setSettingsAsk((n) => n + 1);
}}
/>
)}
{people.enabled && pane === "new" ? (
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
) : people.enabled && room ? (
<ChatRoomView
// Cleared for me: read the (now shorter) history again.
key={`${room.id}:${room.clearedId ?? 0}`}
chat={people}
room={room}
onClose={() => setOpen(false)}
onLeft={() => setPane("pet")}
onOpenRoom={setPane}
showSettings={settingsAsk}
/>
) : (
<PetChat
<PetChat
name={name}
title={
<PetNameButton
name={name}
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}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
/>
)}
</div>
}
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}
/>
</SagiFlyout>
)}
@@ -523,21 +474,7 @@ function SagiBody() {
</SagiFlyout>
)}
{!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 && (
{!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble
bubble={brain.bubble}
@@ -613,11 +550,6 @@ 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}
+2 -14
View File
@@ -3,17 +3,14 @@
* @description Gate for the Sagi mascot: follows the enabled preference
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
* WebSocket reducer. While it is off, the chat between users still has a
* way in: `ChatDock` (its own chunk, server mode only).
* WebSocket reducer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { lazy, Suspense, useEffect, useState } from "react";
import { sagiPrefs } from "./prefs";
import { useAuth } from "../AuthGate";
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
const ChatDock = lazy(() => import("./ChatDock").then((m) => ({ default: m.ChatDock })));
/** The mascot's enabled preference, followed live (Settings, other tabs). */
export function useSagiEnabled(): boolean {
@@ -23,16 +20,7 @@ export function useSagiEnabled(): boolean {
}
export function SagiMount() {
const { state } = useAuth();
if (!useSagiEnabled()) {
// The pet hidden: chat with other users from a button of its own.
if (state.mode !== "server") return null;
return (
<Suspense fallback={null}>
<ChatDock />
</Suspense>
);
}
if (!useSagiEnabled()) return null;
return (
<Suspense fallback={null}>
<Sagi />
@@ -1,69 +0,0 @@
/**
* @file ChatDock.test.tsx
* @description The chat button shown while the pet is hidden (`ChatDock.tsx`,
* mounted by `SagiMount`): server mode only, the unread count on it, and a
* click opens the newest chat with the rooms rail beside it (no pet item).
* @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";
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(),
messages: vi.fn(),
read: vi.fn(),
fileUrl: () => "",
},
users: { avatarUrl: () => null },
me: { avatarUrl: () => null },
}));
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
import { ChatDock } from "../ChatDock";
const room = {
id: 7,
kind: "dm" as const,
name: "",
createdBy: 1,
createdAt: "2026-11-03T01:00:00Z",
lastMessageAt: null,
members: [1, 2],
lastMessage: null,
lastReadId: 0,
unread: 3,
};
beforeEach(() => {
vi.clearAllMocks();
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 }],
});
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
api.chat.read.mockResolvedValue({ lastReadId: 0 });
});
describe("ChatDock", () => {
it("shows the unread count and opens the newest chat", async () => {
render(<ChatDock />);
const btn = await screen.findByRole("button", { name: "Chats, 3 unread" });
fireEvent.click(btn);
await waitFor(() => expect(screen.getByRole("dialog", { name: "Chat with An" })).toBeTruthy());
expect(screen.queryByText("🐾")).toBeNull();
});
it("is not there in local mode", () => {
auth.state = { mode: "local" };
render(<ChatDock />);
expect(screen.queryByRole("button", { name: /Chats/ })).toBeNull();
});
});
@@ -1,389 +0,0 @@
/**
* @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; the group settings
* scroll only one way; a new message raises a notice that opens its room; and
* the right-click menus of a room, a message, a file and a group member —
* deleting a chat for yourself and disbanding a group (its creator) included;
* "Seen" under your newest message; a disabled account never offered for a
* new chat, a deleted one named as such; and the rail without the pet.
* @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(),
deleteMessage: vi.fn(),
clear: vi.fn(),
disband: 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,
}));
const clip = vi.hoisted(() => ({ copyText: vi.fn(async () => true) }));
vi.mock("../../../lib/clipboard", () => clip);
const confirm = vi.hoisted(() => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/confirm", () => confirm);
vi.mock("../../AuthGate", () => ({
useAuth: () => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
}),
}));
import { ChatNotice, 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(),
setActive: vi.fn(),
notice: null,
dismissNotice: vi.fn(),
onDeleted: vi.fn(),
muted: new Set<number>(),
toggleMute: vi.fn(),
reloadPeople: vi.fn(async () => {}),
...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={() => {}}
onOpenRoom={() => {}}
/>
);
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();
});
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={() => {}}
onOpenRoom={() => {}}
/>
);
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);
});
it("right-click on a room: mark read, mute, copy its name, settings and leave", async () => {
const chat = state({ rooms: [{ ...group, lastMessage: msg(5, 2, "x") }, dm] });
const onSelect = vi.fn();
const onSettings = vi.fn();
api.chat.read.mockResolvedValue({ lastReadId: 5 });
api.chat.leave.mockResolvedValue({ ok: true });
render(
<ChatRail chat={chat} active={7} onSelect={onSelect} petName="Sagi" onSettings={onSettings} />
);
const open = () =>
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Mark as read" }));
expect(chat.markRead).toHaveBeenCalledWith(7, 5);
expect(api.chat.read).toHaveBeenCalledWith(7, 5);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Mute notifications" }));
expect(chat.toggleMute).toHaveBeenCalledWith(7);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy name" }));
expect(clip.copyText).toHaveBeenCalledWith("Team");
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Group settings" }));
expect(onSettings).toHaveBeenCalledWith(7);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Leave group" }));
await waitFor(() => expect(api.chat.leave).toHaveBeenCalledWith(7));
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
// A direct chat has no group items.
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
expect(screen.queryByRole("menuitem", { name: "Leave group" })).toBeNull();
});
it("right-click on a message: copy, reply, message the sender, delete your own", async () => {
const chat = state();
const onOpenRoom = vi.fn();
api.chat.messages.mockResolvedValue({
messages: [
msg(1, 2, "của An"),
{
...msg(2, 1, "của tôi"),
files: [{ id: "f1", name: "a.pdf", type: "application/pdf", size: 9 }],
},
],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
api.chat.openDm.mockResolvedValue({ room: dm });
api.chat.deleteMessage.mockResolvedValue({ ok: true });
render(
<ChatRoomView
chat={chat}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={onOpenRoom}
/>
);
fireEvent.contextMenu(await screen.findByText("của An"));
expect(screen.queryByRole("menuitem", { name: "Delete message" })).toBeNull();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy text" }));
expect(clip.copyText).toHaveBeenCalledWith("của An");
fireEvent.contextMenu(screen.getByText("của An"));
fireEvent.click(screen.getByRole("menuitem", { name: "Reply" }));
expect((screen.getByLabelText("Message Team") as HTMLTextAreaElement).value).toBe(
"An:\n> của An\n"
);
fireEvent.contextMenu(screen.getByText("của An"));
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
// A file has its own items.
fireEvent.contextMenu(screen.getByText("a.pdf"));
expect(screen.getByRole("menuitem", { name: "Download" })).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy file name" }));
expect(clip.copyText).toHaveBeenCalledWith("a.pdf");
fireEvent.contextMenu(screen.getByText("của tôi"));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete message" }));
await waitFor(() => expect(api.chat.deleteMessage).toHaveBeenCalledWith(7, 2));
await waitFor(() => expect(screen.queryByText("của tôi")).toBeNull());
});
it("right-click on a group member: message them; the creator may remove them", async () => {
const onOpenRoom = vi.fn();
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
api.chat.openDm.mockResolvedValue({ room: dm });
api.chat.updateGroup.mockResolvedValue({ room: { ...group, members: [1, 3] } });
render(
<ChatRoomView
chat={state()}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={onOpenRoom}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
const row = screen
.getAllByText("An")
.find((el) => el.closest("li"))!
.closest("li")!;
fireEvent.contextMenu(row);
fireEvent.click(screen.getByRole("menuitem", { name: "Remove An" }));
await waitFor(() => expect(api.chat.updateGroup).toHaveBeenCalledWith(7, { remove: [2] }));
fireEvent.contextMenu(row);
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
});
it("deletes a chat for yourself, and lets the creator disband a group", async () => {
const chat = state();
const onSelect = vi.fn();
api.chat.clear.mockResolvedValue({ hidden: true });
api.chat.disband.mockResolvedValue({ ok: true });
render(<ChatRail chat={chat} active={8} onSelect={onSelect} petName="Sagi" />);
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
fireEvent.click(screen.getByRole("menuitem", { name: "Delete chat" }));
await waitFor(() => expect(api.chat.clear).toHaveBeenCalledWith(8));
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
expect(confirm.confirmDialog).toHaveBeenCalled();
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
expect(screen.getByRole("menuitem", { name: "Delete chat history" })).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Disband group" }));
await waitFor(() => expect(api.chat.disband).toHaveBeenCalledWith(7));
});
it("offers Disband only to the group's creator", () => {
render(<ChatRail chat={state({ me: 2 })} active="pet" onSelect={() => {}} petName="Sagi" />);
fireEvent.contextMenu(screen.getByRole("button", { name: /Team/ }));
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
expect(screen.getByRole("menuitem", { name: "Leave group" })).toBeTruthy();
});
it("shows who has seen your newest message", async () => {
api.chat.messages.mockResolvedValue({
messages: [msg(1, 2, "của An"), msg(2, 1, "của tôi")],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
const view = (room: ChatRoom) => (
<ChatRoomView
chat={state()}
room={room}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={() => {}}
/>
);
// Direct chat, not read yet: nothing; read: "Seen".
const { rerender } = render(view({ ...dm, id: 7, reads: { 1: 2, 2: 1 } }));
await screen.findByText("của tôi");
expect(screen.queryByTestId("chat-seen")).toBeNull();
rerender(view({ ...dm, id: 7, reads: { 1: 2, 2: 2 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen");
// Group: by name, then everyone.
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 1 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by An");
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 2 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by everyone");
});
it("never offers a disabled account, and names a deleted one", () => {
const chat = state({
people: [
{ id: 2, username: "an", displayName: "An", avatarAt: null },
{ id: 3, username: "binh", displayName: null, avatarAt: null, disabled: true },
],
// A direct chat whose other person's account was deleted.
rooms: [{ ...dm, members: [1] }],
});
render(<NewChat chat={chat} onOpen={() => {}} onClose={() => {}} />);
expect(screen.queryByRole("checkbox", { name: /binh/ })).toBeNull();
expect(chat.reloadPeople).toHaveBeenCalled();
render(<ChatRail chat={chat} active="new" onSelect={() => {}} />);
expect(screen.getByRole("button", { name: "Deleted user" })).toBeTruthy();
// No pet name: no pet item.
expect(screen.queryByText("🐾")).toBeNull();
});
});
@@ -261,9 +261,7 @@ 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");
});
@@ -315,9 +313,7 @@ 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"));
@@ -1,137 +0,0 @@
/**
* @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; a `chat_read` frame moves that member's read mark
* ("Seen"); a group change is no unread message; with several tabs only the
* first to see a message plays its sound; 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();
});
it("moves a member's read mark on a chat_read frame", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() =>
eventBus.publish({
type: "chat_read",
data: { roomId: 7, userId: 2, lastReadId: 9 },
timestamp: "t",
})
);
expect(result.current.rooms[0]!.reads?.[2]).toBe(9);
});
it("does not count a group change as unread", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() => frame({ ...msg(5, 2), kind: "system", body: "renamed" }));
expect(result.current.unread).toBe(0);
expect(result.current.notice).toBeNull();
});
it("plays a message's sound in one tab only", async () => {
localStorage.setItem(
"agent-monitor-notifications",
JSON.stringify({ enabled: true, sound: "pop" })
);
const a = renderHook(() => useChat());
const b = renderHook(() => useChat());
await waitFor(() => expect(a.result.current.rooms.length).toBe(1));
await waitFor(() => expect(b.result.current.rooms.length).toBe(1));
act(() => frame(msg(6, 2)));
expect(sound.playSound).toHaveBeenCalledTimes(1);
// Both still show the card.
expect(a.result.current.notice).not.toBeNull();
expect(b.result.current.notice).not.toBeNull();
});
});
+1 -21
View File
@@ -6,8 +6,7 @@
* 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. The blue dot counts unread
* chat messages from other users (server mode).
* [data-reduced="1"] and prefers-reduced-motion.
* 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>
@@ -407,25 +406,6 @@
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;
-305
View File
@@ -1,305 +0,0 @@
/**
* @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. 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). A room can be muted (no notice, no sound — its
* unread count still shows), kept per browser in `agenthub.chat.muted`; a
* `chat_deleted` frame drops a message from the open view and re-reads the
* list; a `chat_read` frame moves that member's read mark (`reads`, what the
* room shows as Seen). With several tabs open, only the first to see a
* message plays its sound / system notification (`agenthub.chat.notified`).
* 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 { 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;
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;
/** 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;
/** The open room's view, told when a message of it is deleted. */
onDeleted: (fn: ((roomId: number, messageId: number) => void) | null) => void;
/** Read the people list again (New chat, a room change). */
reloadPeople: () => Promise<void>;
/** Rooms whose new messages raise no notice or sound. */
muted: Set<number>;
toggleMute: (roomId: number) => void;
};
const MUTED_KEY = "agenthub.chat.muted";
const NOTIFIED_KEY = "agenthub.chat.notified";
/** True for the first tab of this browser to claim message `id`'s sound / notification. */
function claimNotice(id: number): boolean {
try {
if (Number(localStorage.getItem(NOTIFIED_KEY) || 0) >= id) return false;
localStorage.setItem(NOTIFIED_KEY, String(id));
} catch {
// No storage: every tab plays it.
}
return true;
}
function readMuted(): Set<number> {
try {
const v = JSON.parse(localStorage.getItem(MUTED_KEY) || "[]");
return new Set(Array.isArray(v) ? v.filter((x) => Number.isInteger(x)) : []);
} catch {
return new Set();
}
}
/** 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;
const enabled = me != null;
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 deletedListener = useRef<((roomId: number, messageId: number) => void) | null>(null);
const [muted, setMuted] = useState<Set<number>>(readMuted);
const mutedRef = useRef(muted);
mutedRef.current = muted;
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;
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]);
const reloadPeople = useCallback(async () => {
if (!enabled) return;
try {
setPeople((await api.chat.people()).people);
} catch {
// Names fall back until the next read.
}
}, [enabled]);
useEffect(() => {
if (!enabled) return;
void reload();
void reloadPeople();
}, [enabled, reload, reloadPeople]);
useEffect(() => {
if (!enabled) return;
const off = eventBus.subscribe((msg: WSMessage) => {
if (msg.type === "chat_room") {
void reload();
void reloadPeople();
return;
}
if (msg.type === "chat_read") {
const d = msg.data as { roomId: number; userId?: number; lastReadId?: number };
if (d.userId == null || d.lastReadId == null) return;
setRooms((prev) =>
prev.map((r) =>
r.id === d.roomId
? {
...r,
reads: {
...r.reads,
[d.userId!]: Math.max(r.reads?.[d.userId!] ?? 0, d.lastReadId!),
},
}
: r
)
);
return;
}
if (msg.type === "chat_deleted") {
const d = msg.data as { roomId: number; messageId?: number };
if (d.messageId != null) deletedListener.current?.(d.roomId, d.messageId);
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,
// A group change (system line) is not a message to read.
unread: own ? 0 : m.kind === "text" ? r.unread + 1 : r.unread,
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);
if (m.userId === me || m.kind !== "text") return;
const hidden = document.visibilityState !== "visible";
if (active.current === m.roomId && !hidden) return;
if (mutedRef.current.has(m.roomId)) 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 });
// Several tabs open: only the first to see a message makes a sound or a
// system notification (message ids only grow).
if (!claimNotice(m.id)) return;
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) => {
if (up) void reload();
});
return () => {
off();
offConn();
};
}, [enabled, me, reload, reloadPeople]);
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),
reads:
me != null ? { ...r.reads, [me]: Math.max(r.reads?.[me] ?? 0, upTo) } : r.reads,
}
: r
)
);
},
[me]
);
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), []);
const onDeleted = useCallback((fn: ((roomId: number, messageId: number) => void) | null) => {
deletedListener.current = fn;
}, []);
const toggleMute = useCallback((roomId: number) => {
setMuted((prev) => {
const next = new Set(prev);
if (next.has(roomId)) next.delete(roomId);
else next.add(roomId);
try {
localStorage.setItem(MUTED_KEY, JSON.stringify([...next]));
} catch {
// private mode: muted for this tab only
}
return next;
});
}, []);
// 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,
setActive,
notice,
dismissNotice,
onDeleted,
reloadPeople,
muted,
toggleMute,
};
}
+9 -112
View File
@@ -77,21 +77,13 @@
"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 🦉",
@@ -114,11 +106,7 @@
"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 🐾",
@@ -175,41 +163,17 @@
"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}}",
@@ -227,73 +191,6 @@
"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}}",
"openFrom": "Open the chat with {{name}}",
"menuOpen": "Open",
"menuRead": "Mark as read",
"menuReadAll": "Mark all chats as read",
"menuMute": "Mute notifications",
"menuUnmute": "Unmute notifications",
"menuCopyName": "Copy name",
"menuPet": "Chat with {{name}}",
"menuDm": "Message {{name}} privately",
"menuOpenFile": "Open in a new tab",
"menuDownload": "Download",
"menuCopyFileName": "Copy file name",
"menuCopyText": "Copy text",
"menuReply": "Reply",
"menuDelete": "Delete message",
"deleteTitle": "Delete this message?",
"deleteBody": "It is removed for everyone in the chat, with its files. This cannot be undone.",
"menuClearDm": "Delete chat",
"menuClearGroup": "Delete chat history",
"clearDmTitle": "Delete this chat?",
"clearDmBody": "The chat with {{name}} and its history are deleted for you only — they keep theirs. It comes back, without the old messages, if either of you writes again.",
"clearGroupTitle": "Delete this group's history?",
"clearGroupBody": "Every message in {{name}} so far is deleted for you only. You stay in the group; the others keep theirs.",
"disband": "Disband group",
"disbandTitle": "Disband this group?",
"disbandBody": "{{name}} and all its messages and files are deleted for every member. This cannot be undone.",
"seen": "Seen",
"seenAll": "Seen by everyone",
"seenBy": "Seen by {{names}}",
"deletedUser": "Deleted user",
"dock": "Chats",
"dockUnread": "Chats, {{n}} unread"
}
},
"pricing": {
+12 -127
View File
@@ -77,26 +77,10 @@
"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",
@@ -114,11 +98,7 @@
"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ẽ 🐾",
@@ -175,41 +155,13 @@
"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}}",
@@ -227,73 +179,6 @@
"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}}",
"openFrom": "Mở chat với {{name}}",
"menuOpen": "Mở",
"menuRead": "Đánh dấu đã đọc",
"menuReadAll": "Đánh dấu đã đọc tất cả",
"menuMute": "Tắt thông báo",
"menuUnmute": "Bật lại thông báo",
"menuCopyName": "Sao chép tên",
"menuPet": "Chat với {{name}}",
"menuDm": "Nhắn riêng cho {{name}}",
"menuOpenFile": "Mở trong tab mới",
"menuDownload": "Tải về",
"menuCopyFileName": "Sao chép tên file",
"menuCopyText": "Sao chép nội dung",
"menuReply": "Trả lời",
"menuDelete": "Xoá tin nhắn",
"deleteTitle": "Xoá tin nhắn này?",
"deleteBody": "Tin nhắn và file đính kèm sẽ bị xoá với mọi người trong cuộc chat. Không thể hoàn tác.",
"menuClearDm": "Xoá cuộc trò chuyện",
"menuClearGroup": "Xoá lịch sử chat",
"clearDmTitle": "Xoá cuộc trò chuyện này?",
"clearDmBody": "Cuộc trò chuyện với {{name}} và lịch sử của nó chỉ bị xoá ở phía bạn — người kia vẫn giữ. Nếu một trong hai nhắn tiếp, cuộc chat sẽ hiện lại nhưng không còn tin cũ.",
"clearGroupTitle": "Xoá lịch sử của nhóm này?",
"clearGroupBody": "Mọi tin nhắn trong {{name}} đến giờ chỉ bị xoá ở phía bạn. Bạn vẫn ở trong nhóm; người khác vẫn giữ tin của họ.",
"disband": "Giải tán nhóm",
"disbandTitle": "Giải tán nhóm này?",
"disbandBody": "{{name}} cùng toàn bộ tin nhắn và file sẽ bị xoá với mọi thành viên. Không thể hoàn tác.",
"seen": "Đã xem",
"seenAll": "Mọi người đã xem",
"seenBy": "{{names}} đã xem",
"deletedUser": "Người dùng đã xoá",
"dock": "Trò chuyện",
"dockUnread": "Trò chuyện, {{n}} tin chưa đọc"
}
},
"pricing": {
-95
View File
@@ -547,44 +547,6 @@ 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 disabled account: named in old chats, never offered for a new one. */
disabled?: boolean;
};
/** 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;
/** Messages up to this id were deleted by the viewer for themselves. */
clearedId?: number;
/** Every member's read mark by user id ("Seen"); absent from an older server. */
reads?: Record<string, number>;
unread: number;
};
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
disabled: boolean;
@@ -1605,63 +1567,6 @@ 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 }),
}),
/** Delete the chat for yourself only (a direct chat leaves your list). */
clear: (id: number) =>
request<{ hidden: boolean }>(`/chat/rooms/${id}/clear`, { method: "POST" }),
/** The group's creator deletes it for everyone. */
disband: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}`, { method: "DELETE" }),
/** Your own message only (403 otherwise). */
deleteMessage: (roomId: number, messageId: number) =>
request<{ ok: true }>(`/chat/rooms/${roomId}/messages/${messageId}`, { method: "DELETE" }),
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) =>
+2 -17
View File
@@ -1917,16 +1917,10 @@ 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 };
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to };
* chat_deleted → { roomId, messageId, to }; chat_read → { roomId, userId, lastReadId, to }. */
* lane_update → { lane?: Lane; removed?: number }. */
type:
| "session_created"
| "session_updated"
| "chat_message"
| "chat_room"
| "chat_deleted"
| "chat_read"
| "agent_created"
| "agent_updated"
| "new_event"
@@ -1973,16 +1967,7 @@ export interface WSMessage {
| { lane?: Lane; removed?: number }
| LaneHookOutputPayload
| LaneRuntimePayload
| LaneHookResultPayload
/** chat_message / chat_room (lib/api.ts ChatMessage). */
| {
roomId: number;
message?: unknown;
messageId?: number;
userId?: number;
lastReadId?: number;
to?: number[];
};
| LaneHookResultPayload;
/** ISO timestamp the server broadcast this message (not necessarily the
* same instant the underlying event occurred). */
timestamp: string;
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.66 (rebuild client to match)
UI build v2.8.67 (rebuild client to match)
</span>
</p>
</div>
-21
View File
@@ -3497,27 +3497,6 @@ A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles s
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
### Chat between users (server mode)
The pet's window has a rail of chats beside the chat with the pet: direct chats (one per pair of users) and groups. Only a room's MEMBERS read or write it — an admin or analyst included; another user's room answers `404 ENOTFOUND` like a missing one. Local mode answers `400 ELOCALMODE`. Code: `server/lib/chat.js`, `server/routes/chat.js`, client `client/src/components/Sagi/ChatHub.tsx` + `useChat.ts`.
```
GET /api/chat/people {people: [{id, username, displayName, avatarAt, disabled}]} — every account but you; a disabled one is named in old chats, never offered for a new one
GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, clearedId, reads (every member's read mark — "Seen"), unread
POST /api/chat/rooms {kind: "dm", userId} → the pair's one direct chat (201 new / 200 existing); {kind: "group", name?, userIds} → 201
PATCH /api/chat/rooms/:id {name?, add?, remove?} — the group's creator only (403 EFORBIDDEN)
DELETE /api/chat/rooms/:id Disband a group — its creator only, for every member, files included
POST /api/chat/rooms/:id/clear Delete the chat for YOURSELF: {hidden, purged} — see below
POST /api/chat/rooms/:id/leave Leave a group; the last one out deletes it and its files
GET /api/chat/rooms/:id/messages?before=&limit= {messages (oldest first), hasMore} — 50 by default, ≤ 200
POST /api/chat/rooms/:id/messages JSON {body} or multipart `body` + ≤ 10 `files` of ≤ 25 MB; body ≤ 8000 chars
DELETE /api/chat/rooms/:id/messages/:messageId Delete your OWN text message and its files (403 EFORBIDDEN otherwise, an admin included)
POST /api/chat/rooms/:id/read {upTo} — read mark, never moves back nor past the newest message; a move is sent as `chat_read`
GET /api/chat/rooms/:id/files/:fileId The file, members only, `sandbox` CSP + `nosniff`; PNG/JPEG/GIF/WebP inline, else attachment
```
Files are stored under `<data dir>/chat/<room id>/<32 hex id>` — a name the server makes. Right-click menus in the window: a room (open, mark read, mute notifications — per browser —, copy name, group settings, leave), the rail's pet and **+** (mark all read), a message (copy text, reply, message the sender privately, delete your own), a file (open, download, copy name) and a group member (message privately, remove — the creator). **Deleting a chat** is per person: the messages so far vanish for the caller only (`chat_members.cleared_id`) and a direct chat leaves their list (`hidden`) until a new message arrives; the others keep theirs. What EVERY member has deleted is deleted for real — the messages up to the lowest `cleared_id` and their files on disk, and a direct chat both people deleted, whole (`purge`, also run when a member leaves or is removed). A group whose creator left or was deleted passes to its longest-standing member. Group changes (`system` lines) are sent live as `chat_message` and never count as unread. An upload is written to `<data dir>/chat/.incoming/` and moved into place — never held in memory whole. In the browser, only the first tab to see a message plays its sound or shows a system notification; a message from someone in a room not on screen shows as a card on the pet. With the pet hidden, a 💬 button in the bottom-right corner (`ChatDock.tsx`) opens the same chat window. **Seen**: the room shows under your newest message "Seen" (direct chat), "Seen by …" or "Seen by everyone" (group), from `reads`. A group change adds a `system` message (`created`, `renamed`, `left`, `added:<ids>`, `removed:<ids>`). Every change is sent over `/ws` as `chat_message` (`{roomId, message, to}`), `chat_room` (`{roomId, to}`) or `chat_deleted` (`{roomId, messageId, to}`) or `chat_read` (`{roomId, userId, lastReadId, to}`) to exactly the user ids in `to` (`scope.exactAudience`), whatever their role.
### Session snapshots
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.66",
"version": "2.8.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.66",
"version": "2.8.67",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.66",
"version": "2.8.67",
"bin": {
"agenthub": "bin/agenthub.js"
},
-306
View File
@@ -1,306 +0,0 @@
/**
* @file chat.test.js
* @description Chat between users (server/lib/chat.js, server/routes/chat.js):
* a direct chat is one per pair, a group's creator alone changes it, only a
* room's members read it — an admin included —, unread counts and read marks,
* files served only to members under a sandbox CSP, a message deleted only by
* its author, "delete chat" for the caller alone, a group disbanded only by its
* creator, local mode refused, and
* every chat frame reaches exactly the users in its `to` (scope.exactAudience).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "chat-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = TMP;
const prevMode = process.env.AGENTHUB_MODE;
process.env.AGENTHUB_MODE = "server";
const { db } = require("../db");
const scope = require("../lib/scope");
const USERS = {
1: { id: 1, username: "boss", role: "admin" },
2: { id: 2, username: "an", role: "developer" },
3: { id: 3, username: "binh", role: "developer" },
4: { id: 4, username: "chi", role: "analyst" },
};
let srv;
let base;
before(async () => {
const ins = db.prepare(
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
);
for (const u of Object.values(USERS)) ins.run(u.id, u.username, u.role);
const express = require("express");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = USERS[req.headers["x-user"]];
next();
});
app.use("/api/chat", require("../routes/chat"));
await new Promise((r) => (srv = app.listen(0, r)));
base = `http://127.0.0.1:${srv.address().port}/api/chat`;
});
after(() => {
srv?.close();
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
else process.env.AGENTHUB_MODE = prevMode;
fs.rmSync(TMP, { recursive: true, force: true });
});
async function call(user, method, p, body) {
const init = { method, headers: { "x-user": String(user) } };
if (body instanceof FormData) init.body = body;
else if (body !== undefined) {
init.body = JSON.stringify(body);
init.headers["content-type"] = "application/json";
}
const r = await fetch(base + p, init);
const type = r.headers.get("content-type") || "";
return {
status: r.status,
headers: r.headers,
body: type.includes("json") ? await r.json() : await r.text(),
};
}
describe("chat", () => {
let dm;
let group;
it("lists the other enabled users to chat with", async () => {
const r = await call(2, "GET", "/people");
assert.deepEqual(r.body.people.map((p) => p.username).sort(), ["binh", "boss", "chi"]);
assert.ok(r.body.people.every((p) => p.disabled === false));
});
it("opens one direct chat per pair, whoever starts it", async () => {
const a = await call(2, "POST", "/rooms", { kind: "dm", userId: 3 });
assert.equal(a.status, 201);
const b = await call(3, "POST", "/rooms", { kind: "dm", userId: 2 });
assert.equal(b.status, 200);
assert.equal(b.body.room.id, a.body.room.id);
dm = a.body.room.id;
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 2 })).status, 400);
});
it("keeps a chat to its members — an admin gets 404 like a missing room", async () => {
const m = await call(2, "POST", `/rooms/${dm}/messages`, { body: "chào Bình" });
assert.equal(m.status, 201);
assert.equal((await call(1, "GET", `/rooms/${dm}/messages`)).status, 404);
assert.equal((await call(1, "POST", `/rooms/${dm}/messages`, { body: "x" })).status, 404);
assert.deepEqual((await call(1, "GET", "/rooms")).body.rooms, []);
const seen = await call(3, "GET", `/rooms/${dm}/messages`);
assert.deepEqual(
seen.body.messages.map((x) => x.body),
["chào Bình"]
);
});
it("counts unread messages until they are marked read", async () => {
let rooms = (await call(3, "GET", "/rooms")).body.rooms;
assert.equal(rooms[0].unread, 1);
assert.equal((await call(2, "GET", "/rooms")).body.rooms[0].unread, 0); // your own
await call(3, "POST", `/rooms/${dm}/read`, { upTo: rooms[0].lastMessage.id });
rooms = (await call(3, "GET", "/rooms")).body.rooms;
assert.equal(rooms[0].unread, 0);
});
it("lets a group's creator alone change it, and any member leave", async () => {
const g = await call(2, "POST", "/rooms", { kind: "group", name: "Team", userIds: [3] });
assert.equal(g.status, 201);
group = g.body.room.id;
assert.deepEqual(g.body.room.members, [2, 3]);
assert.equal((await call(3, "PATCH", `/rooms/${group}`, { add: [4] })).status, 403);
const up = await call(2, "PATCH", `/rooms/${group}`, { add: [4], name: "Team A" });
assert.deepEqual(up.body.room.members, [2, 3, 4]);
assert.equal(up.body.room.name, "Team A");
assert.equal((await call(4, "POST", `/rooms/${group}/leave`)).status, 200);
assert.equal((await call(4, "GET", `/rooms/${group}/messages`)).status, 404);
assert.equal((await call(2, "PATCH", `/rooms/${group}`, { add: [99] })).status, 400);
});
it("stores files and serves them only to members, sandboxed", async () => {
const fd = new FormData();
fd.append("body", "ảnh nè");
fd.append("files", new Blob([Buffer.from("PNGDATA")], { type: "image/png" }), "màn hình.png");
fd.append("files", new Blob(["<script>x</script>"], { type: "text/html" }), "a.html");
const m = await call(3, "POST", `/rooms/${group}/messages`, fd);
assert.equal(m.status, 201);
const [img, page] = m.body.message.files;
assert.equal(img.name, "màn hình.png");
const got = await call(2, "GET", `/rooms/${group}/files/${img.id}`);
assert.equal(got.status, 200);
assert.equal(got.body, "PNGDATA");
assert.match(got.headers.get("content-security-policy"), /sandbox/);
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
assert.match(got.headers.get("content-disposition"), /^inline/);
const html = await call(2, "GET", `/rooms/${group}/files/${page.id}`);
assert.match(html.headers.get("content-disposition"), /^attachment/);
assert.equal((await call(1, "GET", `/rooms/${group}/files/${img.id}`)).status, 404);
assert.equal(
(await call(2, "GET", `/rooms/${group}/files/..%2f..%2fdashboard.db`)).status,
404
);
});
it("lets only the author delete a message, its files too", async () => {
const fd = new FormData();
fd.append("body", "xoá tôi");
fd.append("files", new Blob(["x"], { type: "text/plain" }), "a.txt");
const m = (await call(2, "POST", `/rooms/${group}/messages`, fd)).body.message;
assert.equal((await call(3, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 403);
assert.equal((await call(1, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 404);
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 200);
const left = (await call(2, "GET", `/rooms/${group}/messages`)).body.messages;
assert.ok(!left.some((x) => x.id === m.id));
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
// A system line is nobody's to delete.
const sys = left.find((x) => x.kind === "system");
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${sys.id}`)).status, 403);
});
it("deletes a direct chat for the caller only, until a new message", async () => {
await call(3, "POST", `/rooms/${dm}/messages`, { body: "cũ" });
const c = await call(2, "POST", `/rooms/${dm}/clear`);
assert.deepEqual(c.body, { hidden: true, purged: false });
assert.ok(!(await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === dm));
assert.deepEqual((await call(2, "GET", `/rooms/${dm}/messages`)).body.messages, []);
// The other person keeps everything.
assert.ok((await call(3, "GET", `/rooms/${dm}/messages`)).body.messages.length >= 2);
await call(3, "POST", `/rooms/${dm}/messages`, { body: "mới" });
const back = (await call(2, "GET", "/rooms")).body.rooms.find((r) => r.id === dm);
assert.equal(back.unread, 1);
assert.deepEqual(
(await call(2, "GET", `/rooms/${dm}/messages`)).body.messages.map((m) => m.body),
["mới"]
);
});
it("hides a cleared message's files from the one who cleared it", async () => {
const fd = new FormData();
fd.append("files", new Blob(["x"], { type: "text/plain" }), "b.txt");
const m = (await call(3, "POST", `/rooms/${group}/messages`, fd)).body.message;
await call(2, "POST", `/rooms/${group}/clear`);
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
assert.equal((await call(3, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 200);
// A group stays in the list after its history is cleared.
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === group));
});
it("lets only a group's creator disband it, for everyone", async () => {
const g = (await call(3, "POST", "/rooms", { kind: "group", name: "Tạm", userIds: [2] })).body
.room.id;
assert.equal((await call(2, "DELETE", `/rooms/${g}`)).status, 403);
assert.equal((await call(3, "DELETE", `/rooms/${g}`)).status, 200);
assert.equal((await call(2, "GET", `/rooms/${g}/messages`)).status, 404);
assert.equal((await call(3, "DELETE", `/rooms/${dm}`)).status, 400);
});
it("deletes for real what everyone deleted, and a direct chat both deleted whole", async () => {
const { db } = require("../db");
const fd = new FormData();
fd.append("body", "bí mật");
fd.append("files", new Blob(["y"], { type: "text/plain" }), "c.txt");
const room = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room.id;
const m = (await call(4, "POST", `/rooms/${room}/messages`, fd)).body.message;
const onDisk = path.join(TMP, "chat", String(room), m.files[0].id);
assert.ok(fs.existsSync(onDisk));
await call(4, "POST", `/rooms/${room}/clear`);
// One side only: still stored for the other.
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
assert.ok(fs.existsSync(onDisk));
const both = await call(3, "POST", `/rooms/${room}/clear`);
assert.equal(both.body.purged, true);
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
assert.equal(db.prepare("SELECT 1 FROM chat_rooms WHERE id = ?").get(room), undefined);
assert.ok(!fs.existsSync(onDisk));
// Opening it again starts afresh.
const again = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room;
assert.equal(again.lastMessage, null);
});
it("purges a group's history once every member cleared it", async () => {
const { db } = require("../db");
const g = (await call(2, "POST", "/rooms", { kind: "group", userIds: [3] })).body.room.id;
const m = (await call(2, "POST", `/rooms/${g}/messages`, { body: "x" })).body.message;
await call(2, "POST", `/rooms/${g}/clear`);
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
await call(3, "POST", `/rooms/${g}/clear`);
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
// The group itself stays.
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === g));
});
it("tells every member's read mark (seen), never past the newest message", async () => {
const r = await call(3, "POST", `/rooms/${group}/read`, { upTo: 1e9 });
const room = (await call(2, "GET", "/rooms")).body.rooms.find((x) => x.id === group);
assert.equal(room.reads[3], r.body.lastReadId);
assert.equal(room.reads[3], room.lastMessage ? room.lastMessage.id : room.reads[3]);
assert.ok(r.body.lastReadId < 1e9);
assert.deepEqual([...scope.exactAudience("chat_read", { to: [2, 3] })], [2, 3]);
});
it("hands a group to its longest-standing member when the creator leaves", async () => {
const g = (await call(2, "POST", "/rooms", { kind: "group", name: "Kế", userIds: [3, 4] })).body
.room.id;
await call(2, "POST", `/rooms/${g}/leave`);
const room = (await call(3, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
assert.equal(room.createdBy, 3);
assert.equal((await call(3, "PATCH", `/rooms/${g}`, { name: "Kế 2" })).status, 200);
// The "left" and "renamed" lines never count as unread.
const four = (await call(4, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
assert.equal(four.unread, 0);
});
it("still names a disabled user, and never offers them for a new chat", async () => {
const { db } = require("../db");
db.prepare("UPDATE users SET disabled = 1 WHERE id = 4").run();
try {
const chi = (await call(2, "GET", "/people")).body.people.find((p) => p.id === 4);
assert.equal(chi.disabled, true);
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 4 })).status, 400);
} finally {
db.prepare("UPDATE users SET disabled = 0 WHERE id = 4").run();
}
});
it("leaves nothing behind in the upload folder", async () => {
const fd = new FormData();
fd.append("files", new Blob(["z"], { type: "text/plain" }), "d.txt");
assert.equal((await call(2, "POST", `/rooms/${group}/messages`, fd)).status, 201);
const fd2 = new FormData();
fd2.append("files", new Blob(["z"], { type: "text/plain" }), "e.txt");
assert.equal((await call(1, "POST", `/rooms/${group}/messages`, fd2)).status, 404);
assert.deepEqual(fs.readdirSync(path.join(TMP, "chat", ".incoming")), []);
});
it("refuses an empty message", async () => {
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
});
it("is refused in local mode", async () => {
delete process.env.AGENTHUB_MODE;
try {
assert.equal((await call(2, "GET", "/rooms")).body.error.code, "ELOCALMODE");
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
it("sends a chat frame to exactly its `to`, an admin not included", () => {
const only = scope.exactAudience("chat_message", { roomId: 1, to: [2, 3] });
assert.deepEqual([...only], [2, 3]);
assert.deepEqual([...scope.exactAudience("chat_room", {})], []);
});
});
+16 -50
View File
@@ -1854,6 +1854,22 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
}
// v2.8.65–2.8.66 shipped a chat between users (chat_* tables, uploads under
// <data dir>/chat/); it was removed in v2.8.67, and its data goes with it.
try {
db.exec(`
DROP TABLE IF EXISTS chat_files;
DROP TABLE IF EXISTS chat_messages;
DROP TABLE IF EXISTS chat_members;
DROP TABLE IF EXISTS chat_rooms;
`);
if (!process.env.DASHBOARD_DB_PATH) {
fs.rmSync(path.join(getDataDir(), "chat"), { recursive: true, force: true });
}
} catch {
/* best effort: a leftover is unused */
}
// Server mode: whose browser a push subscription is (`users.id`); NULL in
// local mode. A user's notifications go only to their own subscriptions.
try {
@@ -1912,56 +1928,6 @@ db.exec(`
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Chat between users (server mode, server/lib/chat.js): a direct chat (`dm`,
// one per pair — `dm_key` is "<low id>:<high id>") or a group. Only a room's
// members read it, an admin included. Files live under <data dir>/chat/.
db.exec(`
CREATE TABLE IF NOT EXISTS chat_rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK (kind IN ('dm', 'group')),
name TEXT NOT NULL DEFAULT '',
dm_key TEXT UNIQUE,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_message_at TEXT
);
CREATE TABLE IF NOT EXISTS chat_members (
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_read_id INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_chat_members_user ON chat_members(user_id);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
kind TEXT NOT NULL DEFAULT 'text' CHECK (kind IN ('text', 'system')),
body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_room ON chat_messages(room_id, id);
CREATE TABLE IF NOT EXISTS chat_files (
id TEXT PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
size INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_files_message ON chat_files(message_id);
`);
// A member's own "delete this chat": messages up to cleared_id are gone for
// them (not for anyone else), and a hidden direct chat leaves their list until
// a new message arrives. Additive.
try {
db.prepare("SELECT cleared_id, hidden FROM chat_members LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE chat_members ADD COLUMN cleared_id INTEGER NOT NULL DEFAULT 0").run();
db.prepare("ALTER TABLE chat_members ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0").run();
}
// An account's own agent instructions, put before every workspace's in each
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
try {
-1
View File
@@ -237,7 +237,6 @@ function createApp() {
app.use("/api/projects", projectsRouter);
app.use("/api/locks", locksRouter);
app.use("/api/pets", petsRouter);
app.use("/api/chat", require("./routes/chat"));
app.use("/api/snapshots", snapshotsRouter);
app.use("/api/artifacts", require("./routes/artifacts"));
app.use("/api/alerts", alertsRouter);
-567
View File
@@ -1,567 +0,0 @@
/**
* @file chat.js
* @description Chat between the users of a server-mode install, shown in the
* pet's window beside the chat with the pet: a direct chat (`dm`, at most one
* per pair of users) or a group anyone may start and name. Only a room's
* MEMBERS read or write it — an admin or analyst included, and there is no
* route that lists another user's rooms. A group's creator adds, removes and
* renames; any member may leave; anyone deletes their own messages (only).
* "Delete chat" is the caller's alone: the messages so far vanish for THEM
* (`cleared_id`) and a direct chat leaves their list (`hidden`) until a new
* message arrives — the other members keep everything. Only a group's creator
* disbands it, for everyone, its files included. Once EVERY member has
* deleted a stretch of history, it is deleted for real (`purge`): the
* messages up to the lowest `cleared_id` and their files, and a direct chat
* both people deleted, whole. "Seen": each room carries every member's read
* mark (`reads`), and a read that moves is told to the members (`chat_read`) —
* a mark never passes the room's newest message, so it cannot claim to have
* seen one not yet written. A group whose creator left or was deleted passes
* to its longest-standing member (`ensureOwner`). Group changes add `system`
* lines that are sent live like messages and never count as unread. An
* upload arrives on disk (`<data dir>/chat/.incoming/`) and is renamed into
* place — never held in memory whole. A message is text (≤ 8000 chars) and up to
* `MAX_FILES` files of ≤ `MAX_FILE_BYTES` each, stored once under
* `<data dir>/chat/<room id>/<random id>` — a name the server makes, never one
* from the request — and served back only to the room's members (the route
* adds a `sandbox` CSP and `nosniff`; only raster images are shown inline).
* Every change is told to exactly the users concerned
* (`chat_message` / `chat_room` frames, `to` = their ids, see
* `scope.exactAudience`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { getDataDir } = require("./claude-home");
const db = () => require("../db").db;
const sql = (q) => db().prepare(q);
const MAX_BODY = 8000;
const MAX_NAME = 80;
const MAX_FILES = 10;
const MAX_FILE_BYTES = 25 * 1024 * 1024;
const MAX_GROUP = 200;
/** Raster types shown inline; anything else is a download. */
const INLINE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
function err(code, message, status = 400) {
return Object.assign(new Error(message), { code, status });
}
const chatDir = () => path.join(getDataDir(), "chat");
const fileDir = (roomId) => path.join(chatDir(), String(Number(roomId)));
/** A user who may be chatted with: exists and is not disabled. */
function activeUser(id) {
const n = Number(id);
if (!Number.isSafeInteger(n) || n <= 0) return null;
return sql("SELECT id FROM users WHERE id = ? AND COALESCE(disabled, 0) = 0").get(n) || null;
}
/** Every account but the caller, `disabled` marked — names for old chats, the enabled ones to start a new one with. */
function people(me) {
return sql(
`SELECT id, username, display_name, avatar_at, COALESCE(disabled, 0) AS disabled FROM users
WHERE id != ? ORDER BY COALESCE(display_name, username)`
)
.all(me)
.map((u) => ({
id: u.id,
username: u.username,
displayName: u.display_name ?? null,
avatarAt: u.avatar_at ?? null,
// Still named in old chats; never offered for a new one.
disabled: Boolean(u.disabled),
}));
}
function memberIds(roomId) {
return sql("SELECT user_id FROM chat_members WHERE room_id = ? ORDER BY user_id")
.all(roomId)
.map((r) => r.user_id);
}
/** The room, when `me` is one of its members; else ENOTFOUND (404) — never "forbidden". */
function roomFor(me, roomId) {
const id = Number(roomId);
const row =
Number.isSafeInteger(id) && id > 0
? sql(
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
WHERE r.id = ? AND m.user_id = ?`
).get(id, me)
: null;
if (!row) throw err("ENOTFOUND", "no such chat", 404);
return ensureOwner(row);
}
function filesOf(messageIds) {
if (!messageIds.length) return new Map();
const rows = sql(
`SELECT id, message_id, name, type, size FROM chat_files
WHERE message_id IN (${messageIds.map(() => "?").join(",")}) ORDER BY rowid`
).all(...messageIds);
const out = new Map();
for (const f of rows) {
if (!out.has(f.message_id)) out.set(f.message_id, []);
out.get(f.message_id).push({ id: f.id, name: f.name, type: f.type, size: f.size });
}
return out;
}
function shapeMessages(rows) {
const files = filesOf(rows.map((r) => r.id));
return rows.map((r) => ({
id: r.id,
roomId: r.room_id,
userId: r.user_id,
kind: r.kind,
body: r.body,
createdAt: r.created_at,
files: files.get(r.id) || [],
}));
}
/**
* A group's creator who left or whose account was deleted hands it to the
* longest-standing member, so somebody can still manage it. Answers the room.
*/
function ensureOwner(r) {
if (r.kind !== "group") return r;
const holds =
r.created_by != null &&
sql("SELECT 1 FROM chat_members WHERE room_id = ? AND user_id = ?").get(r.id, r.created_by);
if (holds) return r;
const next = sql(
"SELECT user_id FROM chat_members WHERE room_id = ? ORDER BY joined_at, rowid LIMIT 1"
).get(r.id);
if (!next) return r;
sql("UPDATE chat_rooms SET created_by = ? WHERE id = ?").run(next.user_id, r.id);
return { ...r, created_by: next.user_id };
}
/** One room as `me` sees it: members, newest message, unread count. */
function shapeRoom(me, r) {
r = ensureOwner(r);
const mine = sql(
"SELECT last_read_id, cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?"
).get(r.id, me);
const cleared = mine ? mine.cleared_id : 0;
const last = sql(
"SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT 1"
).get(r.id, cleared);
const unread = sql(
"SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND kind = 'text' AND COALESCE(user_id, -1) != ?"
).get(r.id, Math.max(mine ? mine.last_read_id : 0, cleared), me).n;
return {
id: r.id,
kind: r.kind,
name: r.name,
createdBy: r.created_by,
createdAt: r.created_at,
lastMessageAt: r.last_message_at,
members: memberIds(r.id),
lastMessage: last ? shapeMessages([last])[0] : null,
lastReadId: mine ? mine.last_read_id : 0,
clearedId: cleared,
reads: Object.fromEntries(
sql("SELECT user_id, last_read_id FROM chat_members WHERE room_id = ?")
.all(r.id)
.map((x) => [x.user_id, x.last_read_id])
),
unread,
};
}
/** `me`'s rooms, the most recently active first. */
function listRooms(me) {
return sql(
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
WHERE m.user_id = ? AND m.hidden = 0
ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`
)
.all(me)
.map((r) => shapeRoom(me, r));
}
function cleanName(name) {
if (name === undefined || name === null) return "";
if (typeof name !== "string") throw err("EBADFIELD", "name: text");
return name.trim().slice(0, MAX_NAME);
}
function userList(value, field) {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > MAX_GROUP)
throw err("EBADFIELD", `${field}: a list of user ids`);
const ids = [...new Set(value.map(Number))];
for (const id of ids) if (!activeUser(id)) throw err("ENOUSER", `no such user: ${id}`);
return ids;
}
/** The direct chat between `me` and `other`, made on first use. `created` says which. */
function openDm(me, other) {
const them = activeUser(other);
if (!them || them.id === me) throw err("ENOUSER", "pick another user");
const key = [me, them.id].sort((a, b) => a - b).join(":");
const found = sql("SELECT * FROM chat_rooms WHERE dm_key = ?").get(key);
if (found) {
// Opened again on purpose: back in the list (its cleared history stays gone).
sql("UPDATE chat_members SET hidden = 0 WHERE room_id = ? AND user_id = ?").run(found.id, me);
return { room: shapeRoom(me, found), created: false };
}
const id = db().transaction(() => {
const { lastInsertRowid } = sql(
"INSERT INTO chat_rooms (kind, dm_key, created_by) VALUES ('dm', ?, ?)"
).run(key, me);
const add = sql("INSERT INTO chat_members (room_id, user_id) VALUES (?, ?)");
add.run(lastInsertRowid, me);
add.run(lastInsertRowid, them.id);
return lastInsertRowid;
})();
return {
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(id)),
created: true,
};
}
/** Adds a `system` line; answers its id (the caller sends it live). */
function systemLine(roomId, me, body) {
const { lastInsertRowid } = sql(
"INSERT INTO chat_messages (room_id, user_id, kind, body) VALUES (?, ?, 'system', ?)"
).run(roomId, me, body);
sql(
"UPDATE chat_rooms SET last_message_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
).run(roomId);
return lastInsertRowid;
}
/** The messages of these ids, shaped, to send live. */
function linesOf(ids) {
if (!ids.length) return [];
return shapeMessages(
sql(
`SELECT * FROM chat_messages WHERE id IN (${ids.map(() => "?").join(",")}) ORDER BY id`
).all(...ids)
);
}
/** A new group of `me` plus `userIds`. */
function createGroup(me, { name, userIds }) {
const title = cleanName(name);
const ids = userList(userIds, "userIds").filter((id) => id !== me);
if (!ids.length) throw err("EBADFIELD", "a group needs at least one other member");
const id = db().transaction(() => {
const { lastInsertRowid } = sql(
"INSERT INTO chat_rooms (kind, name, created_by) VALUES ('group', ?, ?)"
).run(title, me);
const add = sql("INSERT INTO chat_members (room_id, user_id) VALUES (?, ?)");
for (const uid of [me, ...ids]) add.run(lastInsertRowid, uid);
systemLine(lastInsertRowid, me, "created");
return lastInsertRowid;
})();
return shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(id));
}
/**
* A group's creator renames it, adds or removes members. Answers the room and
* `notify` = every user to tell (removed ones included, so they drop it).
*/
function updateGroup(me, roomId, { name, add, remove }) {
const room = roomFor(me, roomId);
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat has no members to change");
if (room.created_by !== me) throw err("EFORBIDDEN", "only the group's creator changes it", 403);
const before = memberIds(room.id);
const adds = userList(add, "add").filter((id) => !before.includes(id));
const drops = userList(remove, "remove").filter((id) => id !== me && before.includes(id));
const ids = [];
db().transaction(() => {
if (name !== undefined) {
const title = cleanName(name);
if (title !== room.name) {
sql("UPDATE chat_rooms SET name = ? WHERE id = ?").run(title, room.id);
ids.push(systemLine(room.id, me, "renamed"));
}
}
const ins = sql("INSERT OR IGNORE INTO chat_members (room_id, user_id) VALUES (?, ?)");
for (const uid of adds) ins.run(room.id, uid);
const del = sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?");
for (const uid of drops) del.run(room.id, uid);
if (adds.length) ids.push(systemLine(room.id, me, `added:${adds.join(",")}`));
if (drops.length) ids.push(systemLine(room.id, me, `removed:${drops.join(",")}`));
})();
// A removed member's mark no longer holds history back.
if (drops.length) purge(room.id);
return {
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id)),
notify: [...new Set([...before, ...adds])],
lines: linesOf(ids),
};
}
/** `me` leaves a group; the last one out deletes it and its files. Answers who to tell. */
function leaveGroup(me, roomId) {
const room = roomFor(me, roomId);
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat cannot be left");
const before = memberIds(room.id);
let line = null;
db().transaction(() => {
sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?").run(room.id, me);
if (before.length > 1) line = systemLine(room.id, me, "left");
})();
const gone = purge(room.id);
const rest = sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id);
if (rest) ensureOwner(rest);
return { notify: before, lines: gone || line == null ? [] : linesOf([line]) };
}
/** A page of messages, oldest first: the newest `limit`, or those before `before`. */
function messages(me, roomId, { before, limit } = {}) {
const room = roomFor(me, roomId);
const cleared = sql("SELECT cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
room.id,
me
).cleared_id;
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
const b = parseInt(before, 10);
const rows = Number.isSafeInteger(b)
? sql(
"SELECT * FROM chat_messages WHERE room_id = ? AND id > ? AND id < ? ORDER BY id DESC LIMIT ?"
).all(room.id, cleared, b, n)
: sql("SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT ?").all(
room.id,
cleared,
n
);
return { messages: shapeMessages(rows.reverse()), hasMore: rows.length === n };
}
/** A file's media type, kept only when it is a plain `type/subtype`. */
function cleanType(t) {
return typeof t === "string" && /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(t)
? t.toLowerCase()
: "application/octet-stream";
}
function cleanFileName(n) {
const base = path.basename(String(n || "file")).replace(/[\u0000-\u001f\u007f]/g, "");
return base.slice(0, 200) || "file";
}
/**
* `me` posts to a room: `body` text and `files` ([{name, type, buffer}]).
* Answers the message and who to tell (every member).
*/
function post(me, roomId, { body, files = [] }) {
const room = roomFor(me, roomId);
const text = typeof body === "string" ? body.replace(/\s+$/, "") : "";
if (text.length > MAX_BODY)
throw err("ETOOBIG", `a message is at most ${MAX_BODY} characters`, 413);
if (files.length > MAX_FILES) throw err("ETOOBIG", `at most ${MAX_FILES} files`, 413);
const sizeOf = (f) => (f.path ? fs.statSync(f.path).size : f.buffer.length);
for (const f of files)
if (sizeOf(f) > MAX_FILE_BYTES) throw err("ETOOBIG", "a file is at most 25 MB", 413);
if (!text.trim() && !files.length) throw err("EBADFIELD", "an empty message");
const dir = fileDir(room.id);
const stored = files.map((f) => ({
id: crypto.randomBytes(16).toString("hex"),
name: cleanFileName(f.name),
type: cleanType(f.type),
size: sizeOf(f),
from: f.path || null,
buffer: f.buffer,
}));
if (stored.length) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
for (const f of stored) {
const dest = path.join(dir, f.id);
// An upload already on disk (multer) is moved, never read into memory.
if (f.from) {
fs.renameSync(f.from, dest);
fs.chmodSync(dest, 0o600);
} else fs.writeFileSync(dest, f.buffer, { mode: 0o600 });
}
}
let id;
try {
id = db().transaction(() => {
const { lastInsertRowid } = sql(
"INSERT INTO chat_messages (room_id, user_id, body) VALUES (?, ?, ?)"
).run(room.id, me, text);
const ins = sql(
"INSERT INTO chat_files (id, message_id, room_id, name, type, size) VALUES (?, ?, ?, ?, ?, ?)"
);
for (const f of stored) ins.run(f.id, lastInsertRowid, room.id, f.name, f.type, f.size);
sql(
"UPDATE chat_rooms SET last_message_at = (SELECT created_at FROM chat_messages WHERE id = ?) WHERE id = ?"
).run(lastInsertRowid, room.id);
// A new message brings the chat back to everyone who deleted it.
sql("UPDATE chat_members SET hidden = 0 WHERE room_id = ? AND hidden = 1").run(room.id);
// Your own message is read.
sql("UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?").run(
lastInsertRowid,
room.id,
me
);
return lastInsertRowid;
})();
} catch (e) {
for (const f of stored) fs.rmSync(path.join(dir, f.id), { force: true });
throw e;
}
const [message] = shapeMessages([sql("SELECT * FROM chat_messages WHERE id = ?").get(id)]);
return { message, notify: memberIds(room.id) };
}
/** `me` has read the room up to message `upTo` (never moves back). */
function markRead(me, roomId, upTo) {
const room = roomFor(me, roomId);
const n = Number(upTo);
if (!Number.isSafeInteger(n) || n < 0) throw err("EBADFIELD", "upTo: a message id");
const top = sql("SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE room_id = ?").get(
room.id
).id;
const { changes } = sql(
"UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ? AND last_read_id < ?"
).run(Math.min(n, top), room.id, me, Math.min(n, top));
const now = sql("SELECT last_read_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
room.id,
me
).last_read_id;
return { lastReadId: now, moved: changes > 0, notify: memberIds(room.id) };
}
/**
* Delete for real what every member has deleted for themselves: messages up
* to the lowest `cleared_id` and their files; a direct chat both people
* deleted (hidden, nothing left) goes whole. Answers whether the room is gone.
*/
function purge(roomId) {
const room = sql("SELECT * FROM chat_rooms WHERE id = ?").get(roomId);
if (!room) return true;
const low = sql(
"SELECT MIN(cleared_id) AS low, MIN(hidden) AS allHidden FROM chat_members WHERE room_id = ?"
).get(room.id);
if (low.low == null) {
// Nobody left.
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return true;
}
if (low.low > 0) {
const files = sql("SELECT id FROM chat_files WHERE room_id = ? AND message_id <= ?").all(
room.id,
low.low
);
sql("DELETE FROM chat_messages WHERE room_id = ? AND id <= ?").run(room.id, low.low);
for (const f of files) fs.rmSync(path.join(fileDir(room.id), f.id), { force: true });
}
const left = sql("SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ?").get(room.id).n;
if (room.kind === "dm" && low.allHidden === 1 && left === 0) {
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return true;
}
return false;
}
/**
* `me` deletes their OWN text message (and its files) — never anyone else's,
* an admin included. Answers who to tell (every member).
*/
function removeMessage(me, roomId, messageId) {
const room = roomFor(me, roomId);
const id = Number(messageId);
const m = Number.isSafeInteger(id)
? sql("SELECT * FROM chat_messages WHERE id = ? AND room_id = ?").get(id, room.id)
: null;
if (!m) throw err("ENOTFOUND", "no such message", 404);
if (m.user_id !== me || m.kind !== "text")
throw err("EFORBIDDEN", "only your own messages can be deleted", 403);
const files = sql("SELECT id FROM chat_files WHERE message_id = ?").all(id);
db().transaction(() => {
sql("DELETE FROM chat_messages WHERE id = ?").run(id);
const last = sql("SELECT MAX(created_at) AS at FROM chat_messages WHERE room_id = ?").get(
room.id
);
sql("UPDATE chat_rooms SET last_message_at = ? WHERE id = ?").run(last.at ?? null, room.id);
})();
for (const f of files) fs.rmSync(path.join(fileDir(room.id), f.id), { force: true });
return { notify: memberIds(room.id) };
}
/**
* "Delete chat" for `me` only: every message so far is gone for them; a
* direct chat also leaves their list until a new message arrives. Nobody
* else's copy changes.
*/
function clearChat(me, roomId) {
const room = roomFor(me, roomId);
const top = sql("SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE room_id = ?").get(
room.id
).id;
sql(
`UPDATE chat_members SET cleared_id = ?, last_read_id = MAX(last_read_id, ?), hidden = ?
WHERE room_id = ? AND user_id = ?`
).run(top, top, room.kind === "dm" ? 1 : 0, room.id, me);
const gone = purge(room.id);
return { hidden: room.kind === "dm", purged: gone };
}
/** The group's creator deletes it for everyone, files included. Answers who to tell. */
function disbandGroup(me, roomId) {
const room = roomFor(me, roomId);
if (room.kind !== "group") throw err("EBADFIELD", "only a group can be disbanded");
if (room.created_by !== me) throw err("EFORBIDDEN", "only the group's creator disbands it", 403);
const before = memberIds(room.id);
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return { notify: before };
}
/** A file of a room `me` is in: `{path, name, type, inline}`. */
function file(me, roomId, fileId) {
const room = roomFor(me, roomId);
if (typeof fileId !== "string" || !/^[0-9a-f]{32}$/.test(fileId))
throw err("ENOTFOUND", "no such file", 404);
const f = sql(
`SELECT f.* FROM chat_files f JOIN chat_members m ON m.room_id = f.room_id AND m.user_id = ?
WHERE f.id = ? AND f.room_id = ? AND f.message_id > m.cleared_id`
).get(me, fileId, room.id);
const p = f && path.join(fileDir(room.id), f.id);
if (!f || !fs.existsSync(p)) throw err("ENOTFOUND", "no such file", 404);
return { path: p, name: f.name, type: f.type, inline: INLINE_TYPES.has(f.type) };
}
/** Where multer writes an upload before `post` moves it into its room. */
function incomingDir() {
const d = path.join(chatDir(), ".incoming");
fs.mkdirSync(d, { recursive: true, mode: 0o700 });
return d;
}
module.exports = {
incomingDir,
MAX_FILES,
MAX_FILE_BYTES,
people,
listRooms,
openDm,
createGroup,
updateGroup,
leaveGroup,
messages,
post,
markRead,
purge,
removeMessage,
clearChat,
disbandGroup,
file,
roomFor,
shapeRoom,
};
+1 -7
View File
@@ -340,16 +340,10 @@ function broadcastAudience(type, data) {
* the role-based rules above. Pure (no DB): asked for every client. A change
* to a member's OWN Claude Code config (`cc_config_changed` with `ownerId`,
* routes/cc-config.js) names their files: it reaches that member and nobody
* else, an admin or analyst included. A chat frame (`chat_message`,
* `chat_room`) reaches exactly the users in its `to`.
* else, an admin or analyst included.
*/
function exactAudience(type, data) {
const d = data && typeof data === "object" ? data : {};
// Chat (server/lib/chat.js): only the room's members, whatever their role —
// an admin reads no one else's chat. No list = nobody.
if (["chat_message", "chat_room", "chat_deleted", "chat_read"].includes(type)) {
return new Set(Array.isArray(d.to) ? d.to : []);
}
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
return null;
}
+1 -2
View File
@@ -76,8 +76,7 @@ module.exports = combine(
overrideCore,
overridePricingAlerts,
overrideOps,
gitRefs,
require("./openapi-extra/chat")
gitRefs
);
// The branch & tag writes join the git action entry in place.
gitRefs.extend(module.exports);
-352
View File
@@ -1,352 +0,0 @@
/**
* @file OpenAPI fragment for `/api/chat` (server/routes/chat.js): chat between
* the users of a server-mode install — direct chats and groups, text and
* files. Every route acts on the caller's own rooms only (404 otherwise, an
* admin included); local mode answers `400 ELOCALMODE`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const err = { $ref: "#/components/schemas/ErrorResponse" };
const json = (schema) => ({ content: { "application/json": { schema } } });
const fail = (codes) =>
Object.fromEntries(codes.map((c) => [String(c), { description: "Refused", ...json(err) }]));
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
const tags = [
{
name: "Chat",
description:
"Chat between users (server mode): direct chats and groups in the pet's window. Only a room's members read it.",
},
];
const schemas = {
ChatFile: {
type: "object",
properties: {
id: { type: "string", example: "9f0c1b2a3d4e5f60718293a4b5c6d7e8" },
name: { type: "string", example: "screenshot.png" },
type: { type: "string", example: "image/png" },
size: { type: "integer", example: 20480 },
},
},
ChatMessage: {
type: "object",
properties: {
id: { type: "integer", example: 42 },
roomId: { type: "integer", example: 3 },
userId: { type: "integer", nullable: true, example: 2 },
kind: {
type: "string",
enum: ["text", "system"],
description:
"`system`: a group change — body `created`, `renamed`, `left`, `added:<ids>` or `removed:<ids>`.",
},
body: { type: "string", example: "Chào cả nhóm" },
createdAt: { type: "string", format: "date-time" },
files: { type: "array", items: { $ref: "#/components/schemas/ChatFile" } },
},
},
ChatRoom: {
type: "object",
properties: {
id: { type: "integer", example: 3 },
kind: { type: "string", enum: ["dm", "group"] },
name: { type: "string", description: "A group's name; empty for a direct chat." },
createdBy: { type: "integer", nullable: true },
createdAt: { type: "string", format: "date-time" },
lastMessageAt: { type: "string", format: "date-time", nullable: true },
members: { type: "array", items: { type: "integer" } },
lastMessage: { allOf: [{ $ref: "#/components/schemas/ChatMessage" }], nullable: true },
lastReadId: { type: "integer" },
clearedId: {
type: "integer",
description: "Messages up to this id were deleted by the caller for themselves.",
},
unread: { type: "integer", description: "Messages of others after `lastReadId`." },
reads: {
type: "object",
additionalProperties: { type: "integer" },
description: "Every member's read mark by user id — what the room shows as Seen.",
},
},
},
};
const roomResp = json({
type: "object",
properties: { room: { $ref: "#/components/schemas/ChatRoom" } },
});
const paths = {
"/api/chat/people": {
get: {
tags: ["Chat"],
summary: "Users the caller can chat with",
description:
"Every account but the caller: id, username, display name, avatar time, and `disabled` (not offered for a new chat).",
responses: {
200: {
description: "People",
...json({
type: "object",
properties: {
people: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer" },
username: { type: "string" },
displayName: { type: "string", nullable: true },
avatarAt: { type: "string", nullable: true },
disabled: {
type: "boolean",
description: "A disabled account: still named in old chats, never offered for a new one.",
},
},
},
},
},
}),
},
...fail([400]),
},
},
},
"/api/chat/rooms": {
get: {
tags: ["Chat"],
summary: "The caller's rooms",
description:
"Most recently active first, each with its members, newest message and unread count.",
responses: {
200: {
description: "Rooms",
...json({
type: "object",
properties: {
rooms: { type: "array", items: { $ref: "#/components/schemas/ChatRoom" } },
},
}),
},
...fail([400]),
},
},
post: {
tags: ["Chat"],
summary: "Open a direct chat or create a group",
description:
"`{kind: 'dm', userId}` answers the one direct chat of the pair (201 when made now, 200 when it existed); `{kind: 'group', name, userIds}` makes a group of the caller plus `userIds`. Broadcasts `chat_room` to the members.",
requestBody: {
required: true,
...json({
type: "object",
required: ["kind"],
properties: {
kind: { type: "string", enum: ["dm", "group"] },
userId: { type: "integer" },
name: { type: "string", maxLength: 80 },
userIds: { type: "array", items: { type: "integer" } },
},
}),
},
responses: {
200: { description: "The existing direct chat", ...roomResp },
201: { description: "Created", ...roomResp },
...fail([400]),
},
},
},
"/api/chat/rooms/{id}": {
delete: {
tags: ["Chat"],
summary: "Disband a group",
description:
"The group's creator only (`403 EFORBIDDEN`): deletes it for every member, its files included. Broadcasts `chat_room`.",
parameters: [idParam],
responses: {
200: {
description: "Disbanded",
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
},
...fail([400, 403, 404]),
},
},
patch: {
tags: ["Chat"],
summary: "Rename a group, add or remove members",
description: "Only the group's creator (`403 EFORBIDDEN`). Removed users are told too.",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
properties: {
name: { type: "string" },
add: { type: "array", items: { type: "integer" } },
remove: { type: "array", items: { type: "integer" } },
},
}),
},
responses: { 200: { description: "Updated", ...roomResp }, ...fail([400, 403, 404]) },
},
},
"/api/chat/rooms/{id}/clear": {
post: {
tags: ["Chat"],
summary: "Delete a chat for yourself",
description:
"Every message so far is gone for the caller only (`clearedId`); a direct chat also leaves their list until a new message arrives. Nobody else's copy changes — but what EVERY member has deleted is deleted for real (messages, files, and a direct chat both people deleted, whole).",
parameters: [idParam],
responses: {
200: {
description: "Cleared",
...json({
type: "object",
properties: {
hidden: { type: "boolean" },
purged: {
type: "boolean",
description: "Every member has now deleted it: the chat is gone from the server.",
},
},
}),
},
...fail([404]),
},
},
},
"/api/chat/rooms/{id}/leave": {
post: {
tags: ["Chat"],
summary: "Leave a group",
description: "The last member out deletes the group and its files.",
parameters: [idParam],
responses: {
200: {
description: "Left",
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
},
...fail([400, 404]),
},
},
},
"/api/chat/rooms/{id}/messages": {
get: {
tags: ["Chat"],
summary: "A page of a room's messages",
description:
"Oldest first: the newest `limit` (default 50, max 200), or those before `before`.",
parameters: [
idParam,
{ name: "before", in: "query", schema: { type: "integer" } },
{ name: "limit", in: "query", schema: { type: "integer" } },
],
responses: {
200: {
description: "Messages",
...json({
type: "object",
properties: {
messages: { type: "array", items: { $ref: "#/components/schemas/ChatMessage" } },
hasMore: { type: "boolean" },
},
}),
},
...fail([400, 404]),
},
},
post: {
tags: ["Chat"],
summary: "Post a message",
description:
"JSON `{body}` or multipart `body` + up to 10 `files` of ≤ 25 MB. Broadcasts `chat_message` to the room's members.",
parameters: [idParam],
requestBody: {
required: true,
content: {
"application/json": {
schema: { type: "object", properties: { body: { type: "string", maxLength: 8000 } } },
},
"multipart/form-data": {
schema: {
type: "object",
properties: {
body: { type: "string" },
files: { type: "array", items: { type: "string", format: "binary" } },
},
},
},
},
},
responses: {
201: {
description: "Posted",
...json({
type: "object",
properties: { message: { $ref: "#/components/schemas/ChatMessage" } },
}),
},
...fail([400, 404, 413]),
},
},
},
"/api/chat/rooms/{id}/messages/{messageId}": {
delete: {
tags: ["Chat"],
summary: "Delete your own message",
description:
"Only the message's author (`403 EFORBIDDEN`, an admin included); its files go with it. Broadcasts `chat_deleted` to the members.",
parameters: [
idParam,
{ name: "messageId", in: "path", required: true, schema: { type: "integer" } },
],
responses: {
200: {
description: "Deleted",
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
},
...fail([403, 404]),
},
},
},
"/api/chat/rooms/{id}/read": {
post: {
tags: ["Chat"],
summary: "Mark a room read up to a message",
description:
"Never moves back, never past the room's newest message. A mark that moved is told to the members as `chat_read` (`{roomId, userId, lastReadId}`).",
parameters: [idParam],
requestBody: {
required: true,
...json({ type: "object", required: ["upTo"], properties: { upTo: { type: "integer" } } }),
},
responses: {
200: {
description: "Read mark",
...json({ type: "object", properties: { lastReadId: { type: "integer" } } }),
},
...fail([400, 404]),
},
},
},
"/api/chat/rooms/{id}/files/{fileId}": {
get: {
tags: ["Chat"],
summary: "One file of a room",
description:
"Members only. Served under a `sandbox` CSP and `nosniff`; PNG/JPEG/GIF/WebP inline, anything else as an attachment.",
parameters: [
idParam,
{ name: "fileId", in: "path", required: true, schema: { type: "string" } },
],
responses: {
200: { description: "The file's bytes", content: { "application/octet-stream": {} } },
...fail([400, 404]),
},
},
},
};
module.exports = { tags, schemas, paths };
-215
View File
@@ -1,215 +0,0 @@
/**
* @file chat.js (routes)
* @description `/api/chat` — chat between users in server mode (the pet's
* window, `client/src/components/Sagi/ChatHub.tsx`): people to chat with,
* the caller's rooms, a direct chat or a group, messages (text + files, a
* multipart `POST`), read marks and a room's files. Everything acts on the
* CALLER's own rooms (`server/lib/chat.js`): a room they are not in is 404,
* whatever their role. Local mode has one person and no chat (`400 ELOCALMODE`).
* Each change is broadcast as `chat_message` / `chat_room` / `chat_deleted` to exactly the
* users concerned (`to`, `scope.exactAudience`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const express = require("express");
const chat = require("../lib/chat");
const { isServerMode } = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
function send(res, e) {
const status = e.status || (e.code === "ENOUSER" || e.code === "EBADFIELD" ? 400 : 500);
if (status === 500) console.error("[chat]", e);
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
/** Run `fn(userId)`; refuses local mode and an anonymous request. */
const handle = (fn) => (req, res) => {
if (!isServerMode() || !req.user) {
return res
.status(400)
.json({ error: { code: "ELOCALMODE", message: "chat is for a shared server" } });
}
try {
fn(req.user.id, req, res);
} catch (e) {
send(res, e);
}
};
function tell(type, data, to) {
try {
require("../websocket").broadcast(type, { ...data, to });
} catch {
// no socket server (tests): nothing to tell
}
}
/** Multipart `files` + `body`, written to disk (never held whole in memory), within the chat's limits. */
function upload(req, res, next) {
if (!req.is("multipart/form-data")) return next();
const multer = require("multer");
multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, chat.incomingDir()),
// A name of ours; the browser's goes only into the database.
filename: (_req, _file, cb) =>
cb(null, require("node:crypto").randomBytes(16).toString("hex")),
}),
limits: { fileSize: chat.MAX_FILE_BYTES, files: chat.MAX_FILES },
}).array("files")(req, res, (e) => {
if (!e) return next();
const big = e.code === "LIMIT_FILE_SIZE" || e.code === "LIMIT_FILE_COUNT";
return res.status(big ? 413 : 400).json({
error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: e.message },
});
});
}
router.get(
"/people",
handle((me, _req, res) => res.json({ people: chat.people(me) }))
);
router.get(
"/rooms",
handle((me, _req, res) => res.json({ rooms: chat.listRooms(me) }))
);
router.post(
"/rooms",
sameOriginGuard,
handle((me, req, res) => {
const b = req.body || {};
if (b.kind === "dm") {
const { room, created } = chat.openDm(me, b.userId);
if (created) tell("chat_room", { roomId: room.id }, room.members);
return res.status(created ? 201 : 200).json({ room });
}
if (b.kind === "group") {
const room = chat.createGroup(me, { name: b.name, userIds: b.userIds });
tell("chat_room", { roomId: room.id }, room.members);
return res.status(201).json({ room });
}
throw Object.assign(new Error("kind: dm or group"), { code: "EBADFIELD" });
})
);
router.patch(
"/rooms/:id",
sameOriginGuard,
handle((me, req, res) => {
const b = req.body || {};
const { room, notify, lines } = chat.updateGroup(me, req.params.id, {
name: b.name,
add: b.add,
remove: b.remove,
});
tell("chat_room", { roomId: room.id }, notify);
// The "X added Y" lines, live in every open view.
for (const message of lines) tell("chat_message", { roomId: room.id, message }, room.members);
res.json({ room });
})
);
router.post(
"/rooms/:id/leave",
sameOriginGuard,
handle((me, req, res) => {
const { notify, lines } = chat.leaveGroup(me, req.params.id);
tell("chat_room", { roomId: Number(req.params.id) }, notify);
const stay = notify.filter((id) => id !== me);
for (const message of lines)
tell("chat_message", { roomId: Number(req.params.id), message }, stay);
res.json({ ok: true });
})
);
// Delete the chat for the caller only (their history; a direct chat leaves their list).
router.post(
"/rooms/:id/clear",
sameOriginGuard,
handle((me, req, res) => res.json(chat.clearChat(me, req.params.id)))
);
// The group's creator deletes it for everyone.
router.delete(
"/rooms/:id",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.disbandGroup(me, req.params.id);
tell("chat_room", { roomId: Number(req.params.id) }, notify);
res.json({ ok: true });
})
);
router.get(
"/rooms/:id/messages",
handle((me, req, res) =>
res.json(chat.messages(me, req.params.id, { before: req.query.before, limit: req.query.limit }))
)
);
router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
handle((me) => {
const files = (req.files || []).map((f) => ({
// multer reads names as latin1; browsers send UTF-8.
name: Buffer.from(f.originalname, "latin1").toString("utf8"),
type: f.mimetype,
path: f.path,
}));
let posted;
try {
posted = chat.post(me, req.params.id, { body: req.body?.body, files });
} finally {
// Moved into place on success; anything left over is a refused upload.
for (const f of files) require("node:fs").rmSync(f.path, { force: true });
}
const { message, notify } = posted;
tell("chat_message", { roomId: message.roomId, message }, notify);
res.status(201).json({ message });
})(req, res)
);
router.delete(
"/rooms/:id/messages/:messageId",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.removeMessage(me, req.params.id, req.params.messageId);
const roomId = Number(req.params.id);
tell("chat_deleted", { roomId, messageId: Number(req.params.messageId) }, notify);
res.json({ ok: true });
})
);
router.post(
"/rooms/:id/read",
sameOriginGuard,
handle((me, req, res) => {
const { lastReadId, moved, notify } = chat.markRead(me, req.params.id, req.body?.upTo);
// "Seen": the other members' views move their read marks.
if (moved) tell("chat_read", { roomId: Number(req.params.id), userId: me, lastReadId }, notify);
res.json({ lastReadId });
})
);
router.get(
"/rooms/:id/files/:fileId",
handle((me, req, res) => {
const f = chat.file(me, req.params.id, req.params.fileId);
const ascii = f.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
res.set({
"Content-Type": f.type,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'",
"Cache-Control": "private, max-age=86400",
"Content-Disposition": `${f.inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(f.name)}`,
});
require("../lib/send-file").sendFile(res, f.path);
})
);
module.exports = router;