Compare commits

..

11 Commits

Author SHA1 Message Date
nntrivi2001 1dd2521183 chore: v2.8.65 — chat between users, own-only counts, slower scroll 2026-11-03 09:36:54 +07:00
nntrivi2001 21a042dc2b feat(chat): direct chats and groups between users in the pet's window
Server mode: a rail beside the pet's chat lists the pet, then the user's
direct chats (one per pair) and groups with unread counts, and + for a new
chat. A room shows its messages (group senders named), loads older pages,
and sends text, pasted / dropped / attached files (<= 10 x 25 MB; images
inline). A group's creator renames it and adds or removes people; anyone may
leave. A blue dot on the pet counts unread messages.

Only a room's members read or write it, an admin included (roomFor → 404).
Files are stored under server-made names in <data dir>/chat/<room>/ and
served to members only under a sandbox CSP + nosniff. chat_message /
chat_room frames reach exactly the ids in `to` (scope.exactAudience).
New tables chat_rooms / chat_members / chat_messages / chat_files (additive).
2026-11-03 09:33:33 +07:00
nntrivi2001 85edc88a6d fix(status-bar): running / need-you count only the viewer's own sessions
An admin's status bar and the Dashboard's Needs-you strip counted every
user's waiting sessions; both now ask GET /api/sessions with mine=1, like
Today's spend already did.
2026-11-03 09:07:06 +07:00
nntrivi2001 c229b4b79b fix(pet): each user's pet tells only of their own sessions
An admin/analyst's pet counted every user's sessions (live, waiting, errors)
because their WebSocket carries everyone's. Server mode now marks their
session/agent/event frames `mine: true|false` (scope.broadcastAudience owners,
worked out once per broadcast), and GET /api/sessions?mine=1 lists only the
caller's own sessions (ownSessionWhere). The pet seeds from mine=1 and skips
frames marked mine: false. Developers and local mode are unchanged.
2026-11-03 09:01:57 +07:00
nntrivi2001 9721941962 feat(terminal): scroll 30% slower
lib/wheelReports.ts SCROLL_SPEED = 0.7: a touchpad's travel is scaled, and a
wheel notch's reports are carried as a fraction (rounded, so the first notch
still moves; reset when the direction turns). xterm's own scrollback gets the
same scrollSensitivity.
2026-11-03 08:52:43 +07:00
nntrivi2001 b777e08371 fix(health): the user hover card leaves out the login name
It now opens only once the users list is read (admin/analyst): role, today's
spend, workspaces, sessions and last sign-in. A developer gets no card.
2026-11-03 08:50:26 +07:00
nntrivi2001 d0b4f8aaf6 fix(health): the user hover card sits on the name row only
Over the whole tile it opened together with the CPU bar's own help tip and
the two overlapped.
2026-11-03 08:47:13 +07:00
nntrivi2001 916505fbac chore: v2.8.64 — user hover card on System Health 2026-11-03 08:38:51 +07:00
nntrivi2001 75a4693f27 feat(health): hovering a user tile shows their login, role and today's spend
System Health's Users · CPU / RAM card: each tile's hover card names the login
name; for an admin or analyst it adds role, $ spent today, workspaces,
sessions and last sign-in from GET /api/users (staff-only, re-read every 60 s).
A developer's card shows the login name alone and never calls /api/users.
2026-11-03 08:38:48 +07:00
nntrivi2001 cd6a1d36ff chore: v2.8.63 — commit signing per workspace 2026-11-02 16:50:15 +07:00
nntrivi2001 2342f54179 feat(git-identity): commit signing per workspace, and Delete in the signing card
Signing was account-wide (gpg.format/user.signingkey/commit.gpgsign in
~/.gitconfig), so every repository was signed. Now Settings → Git →
Commit signing lists the user's workspaces, each with its own key picker
("Don't sign" by default). PUT /api/me/git/signing/lane {laneId, key}
writes a small file of ours (lane-<id>.gitconfig beside the account's
keys; a member's through write-as) and includes it from ~/.gitconfig
with includeIf "gitdir:<the workspace's git dir>" and
"gitdir:<common dir>/worktrees/ah-<id>-*" (its sessions' worktrees) —
only that workspace signs, from the terminal, the Changes panel or an
agent, and nothing is written into the repository. The git dirs come
from git run in the workspace's folder as its account; a workspace that
runs as another account is EFOREIGN, a folder that is not a repo
ENOREPO. Stopping needs no workspace, so one removed from the dashboard
while it signed is still listed and can be turned off.

The card now lists the keys that can sign, each with Delete (confirm;
deleting a key stops every workspace signing with it), a way out of the
old account-wide setting, and Remove for a signing setup the user made
themselves (PUT /signing {clearForeign: true}: the three settings only,
never their key file). Importing a key no longer turns on signing.

Verified with real signatures: the picked workspace's commits are G,
another repo's and an unrelated worktree's N, a session worktree's G.
2026-11-02 16:49:27 +07:00
46 changed files with 3668 additions and 274 deletions
+3 -2
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,7 +1,8 @@
/**
* @file NeedsYouStrip.tsx
* @description The Dashboard's pinned "Needs you" strip: every active session
* blocked on the user (`awaiting_input_since`), each linking straight to its
* of the viewer's OWN blocked on them (`awaiting_input_since`, `mine=1` — an
* admin's lists no other user's), each linking straight to its
* workspace pane (or replay), else its analysis page. Renders nothing while no
* session is waiting. `compact` is the one-line form shown above Analytics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
const load = useCallback(() => {
api.sessions
.list({ awaiting: true, limit: 20 })
.list({ awaiting: true, limit: 20, mine: true })
.then((r) => {
setSessions(r.sessions);
setTotal(r.total);
+846
View File
@@ -0,0 +1,846 @@
/**
* @file ChatHub.tsx
* @description Chat between users inside the pet's window (server mode,
* `/api/chat`, state from `useChat.ts`). `ChatRail` is the narrow column
* beside the window: the pet itself first (its AI chat, `PetChat.tsx`), then
* the user's direct chats and groups, newest first, each with its unread
* count, and **+** for a new chat. `ChatRoomView` is one room, the same size as
* the pet's chat: messages (a group shows who wrote each), older pages on
* demand, text with links, pasted / dropped / attached files (images shown
* inline, anything else a download, all served only to the room's members),
* and for a group its settings — the creator renames it and adds or removes
* people; anyone may leave. `NewChat` picks people: one person opens the
* direct chat with them, more (or a name) makes a group. Only a room's members
* ever see it; an admin has no view of other people's chats.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
ArrowUp,
FileText,
LogOut,
Paperclip,
Plus,
Settings2,
Users,
X,
} from "lucide-react";
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
import { formatBytes, formatTime } from "../../lib/format";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { UserAvatar } from "../users/UserAvatar";
import { useAuth } from "../AuthGate";
import type { ChatState } from "./useChat";
const MAX_FILES = 10;
const MAX_FILE_BYTES = 25 * 1024 * 1024;
/** Shown name of a person (display name, else login). */
const personName = (p: Pick<ChatPerson, "username" | "displayName"> | undefined) =>
p ? p.displayName || p.username : "?";
/** Everyone the view may need a name for: the people list plus the viewer. */
function useDirectory(chat: ChatState) {
const { state } = useAuth();
const self = state.mode === "server" ? state.user : null;
return useMemo(() => {
const m = new Map<number, ChatPerson>(chat.people.map((p) => [p.id, p]));
if (self)
m.set(self.id, {
id: self.id,
username: self.username,
displayName: self.displayName ?? null,
avatarAt: self.avatarAt ?? null,
});
return m;
}, [chat.people, self]);
}
/** The other person of a direct chat. */
const otherOf = (room: ChatRoom, me: number | null) =>
room.members.find((id) => id !== me) ?? room.members[0] ?? -1;
function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerson>) {
if (room.kind === "dm") return personName(dir.get(otherOf(room, me)));
if (room.name) return room.name;
return room.members
.filter((id) => id !== me)
.map((id) => personName(dir.get(id)))
.join(", ");
}
function RoomIcon({
room,
me,
dir,
className,
}: {
room: ChatRoom;
me: number | null;
dir: Map<number, ChatPerson>;
className: string;
}) {
if (room.kind === "dm") {
const p = dir.get(otherOf(room, me));
return (
<UserAvatar
name={personName(p)}
userId={p?.id}
avatarAt={p?.avatarAt}
className={className}
/>
);
}
return (
<span
className={`flex items-center justify-center rounded-full bg-surface-4 text-fg-secondary ${className}`}
>
<Users size={16} aria-hidden />
</span>
);
}
const Badge = ({ n }: { n: number }) =>
n > 0 ? (
<span className="absolute -right-0.5 -top-0.5 min-w-[1.1rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-[1.1rem] text-white">
{n > 99 ? "99+" : n}
</span>
) : null;
/** The column beside the window: the pet, the rooms, and New chat. */
export function ChatRail({
chat,
active,
onSelect,
petName,
}: {
chat: ChatState;
/** `"pet"`, `"new"` or a room id. */
active: "pet" | "new" | number;
onSelect: (v: "pet" | "new" | number) => void;
petName: string;
}) {
const { t } = useTranslation("settings");
const dir = useDirectory(chat);
const ring = (on: boolean) =>
on ? "ring-2 ring-accent ring-offset-2 ring-offset-surface-2" : "opacity-80 hover:opacity-100";
return (
<nav
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-14 flex-col items-center gap-2 overflow-y-auto rounded-3xl border border-border-light py-3 shadow-2xl"
aria-label={t("sagi.people.rail")}
>
<button
type="button"
onClick={() => onSelect("pet")}
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-4 text-lg transition ${ring(active === "pet")}`}
title={petName}
aria-label={petName}
aria-pressed={active === "pet"}
>
<span aria-hidden>🐾</span>
</button>
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
{chat.rooms.map((r) => {
const title = roomTitle(r, chat.me, dir);
return (
<button
key={r.id}
type="button"
onClick={() => onSelect(r.id)}
className={`relative shrink-0 rounded-full transition ${ring(active === r.id)}`}
title={title}
aria-label={r.unread ? t("sagi.people.unreadIn", { name: title, n: r.unread }) : title}
aria-pressed={active === r.id}
>
<RoomIcon room={r} me={chat.me} dir={dir} className="h-9 w-9 text-[11px]" />
<Badge n={r.unread} />
</button>
);
})}
<button
type="button"
onClick={() => onSelect("new")}
className={`mt-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-dashed border-border text-fg-muted transition hover:text-fg-primary ${active === "new" ? "ring-2 ring-accent" : ""}`}
title={t("sagi.people.new")}
aria-label={t("sagi.people.new")}
>
<Plus size={16} />
</button>
</nav>
);
}
function Shell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
role="dialog"
aria-label={label}
>
{children}
</div>
);
}
function HeadButton({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
onClick={onClick}
aria-label={label}
title={label}
>
{children}
</button>
);
}
/** People picker with checkboxes. */
function PeoplePicker({
people,
picked,
onToggle,
}: {
people: ChatPerson[];
picked: Set<number>;
onToggle: (id: number) => void;
}) {
const { t } = useTranslation("settings");
const [q, setQ] = useState("");
const shown = people.filter((p) =>
`${p.displayName ?? ""} ${p.username}`.toLowerCase().includes(q.trim().toLowerCase())
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<input
className="input text-sm"
placeholder={t("sagi.people.search")}
aria-label={t("sagi.people.search")}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{shown.map((p) => (
<li key={p.id}>
<label className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-4">
<input type="checkbox" checked={picked.has(p.id)} onChange={() => onToggle(p.id)} />
<UserAvatar
name={personName(p)}
userId={p.id}
avatarAt={p.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{personName(p)}
</span>
</label>
</li>
))}
{!shown.length && (
<li className="px-2 py-4 text-center text-xs text-fg-muted">{t("sagi.people.none")}</li>
)}
</ul>
</div>
);
}
/** Start a chat: one person → the direct chat; more, or a name → a group. */
export function NewChat({
chat,
onOpen,
onClose,
}: {
chat: ChatState;
onOpen: (roomId: number) => void;
onClose: () => void;
}) {
const { t } = useTranslation("settings");
const [picked, setPicked] = useState<Set<number>>(new Set());
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const group = picked.size > 1 || name.trim() !== "";
const toggle = (id: number) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const start = async () => {
if (!picked.size) return;
setBusy(true);
try {
const { room } = group
? await api.chat.createGroup(name.trim(), [...picked])
: await api.chat.openDm([...picked][0]!);
chat.upsert(room);
onOpen(room.id);
} catch (e) {
toast.error(e);
} finally {
setBusy(false);
}
};
return (
<Shell label={t("sagi.people.new")}>
<div className="pet-chat-head flex items-center gap-1 px-3 py-2.5">
<div className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
{t("sagi.people.new")}
</div>
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
<X size={16} />
</HeadButton>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2">
<p className="text-[11px] text-fg-muted">{t("sagi.people.newHint")}</p>
<PeoplePicker people={chat.people} picked={picked} onToggle={toggle} />
{picked.size > 1 && (
<input
className="input text-sm"
placeholder={t("sagi.people.groupName")}
aria-label={t("sagi.people.groupName")}
maxLength={80}
value={name}
onChange={(e) => setName(e.target.value)}
/>
)}
</div>
<div className="pet-chat-foot px-3 py-2.5">
<button
type="button"
className="btn-primary w-full justify-center"
disabled={!picked.size || busy}
onClick={() => void start()}
>
{group ? t("sagi.people.createGroup", { n: picked.size }) : t("sagi.people.startDm")}
</button>
</div>
</Shell>
);
}
const URL_RE = /(https?:\/\/[^\s<>"']+)/g;
/** Text with its http(s) links clickable (a new tab). */
function Linkified({ text }: { text: string }) {
const parts = text.split(URL_RE);
return (
<>
{parts.map((p, i) =>
i % 2 === 1 ? (
<a key={i} href={p} target="_blank" rel="noopener noreferrer" className="underline">
{p}
</a>
) : (
<span key={i}>{p}</span>
)
)}
</>
);
}
function SystemLine({ m, dir }: { m: ChatMessage; dir: Map<number, ChatPerson> }) {
const { t } = useTranslation("settings");
const who = personName(m.userId != null ? dir.get(m.userId) : undefined);
const [kind, ids] = m.body.split(":");
const names = (ids ?? "")
.split(",")
.filter(Boolean)
.map((id) => personName(dir.get(Number(id))))
.join(", ");
const text =
kind === "added"
? t("sagi.people.sysAdded", { who, names })
: kind === "removed"
? t("sagi.people.sysRemoved", { who, names })
: kind === "left"
? t("sagi.people.sysLeft", { who })
: kind === "renamed"
? t("sagi.people.sysRenamed", { who })
: t("sagi.people.sysCreated", { who });
return <p className="py-1 text-center text-[11px] text-fg-muted">{text}</p>;
}
function Files({ m, own }: { m: ChatMessage; own: boolean }) {
return (
<div className="mt-1 flex flex-col gap-1">
{m.files.map((f) => {
const url = api.chat.fileUrl(m.roomId, f.id);
return f.type.startsWith("image/") && /png|jpeg|gif|webp/.test(f.type) ? (
<a key={f.id} href={url} target="_blank" rel="noopener noreferrer">
<img
src={url}
alt={f.name}
loading="lazy"
className="max-h-48 max-w-full rounded-xl object-contain"
/>
</a>
) : (
<a
key={f.id}
href={url}
download={f.name}
className={`flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs ${own ? "bg-white/15" : "bg-surface-4"}`}
>
<FileText size={14} className="shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">{f.name}</span>
<span className="shrink-0 opacity-70">{formatBytes(f.size)}</span>
</a>
);
})}
</div>
);
}
/** A group's settings: name, members, Leave. */
function GroupSettings({
chat,
room,
dir,
onBack,
onLeft,
}: {
chat: ChatState;
room: ChatRoom;
dir: Map<number, ChatPerson>;
onBack: () => void;
onLeft: () => void;
}) {
const { t } = useTranslation("settings");
const owner = room.createdBy === chat.me;
const [name, setName] = useState(room.name);
const [adding, setAdding] = useState<Set<number>>(new Set());
const save = async (patch: { name?: string; add?: number[]; remove?: number[] }) => {
try {
const r = await api.chat.updateGroup(room.id, patch);
chat.upsert(r.room);
setAdding(new Set());
} catch (e) {
toast.error(e);
}
};
const leave = async () => {
const ok = await confirmDialog({
title: t("sagi.people.leaveTitle"),
message: t("sagi.people.leaveBody", { name: roomTitle(room, chat.me, dir) }),
confirmLabel: t("sagi.people.leave"),
});
if (!ok) return;
try {
await api.chat.leave(room.id);
await chat.reload();
onLeft();
} catch (e) {
toast.error(e);
}
};
const outside = chat.people.filter((p) => !room.members.includes(p.id));
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 self-start text-xs text-fg-muted hover:text-fg-primary"
>
<ArrowLeft size={13} /> {t("sagi.people.back")}
</button>
{owner && (
<form
className="flex gap-1.5"
onSubmit={(e) => {
e.preventDefault();
void save({ name });
}}
>
<input
className="input flex-1 text-sm"
aria-label={t("sagi.people.groupName")}
placeholder={t("sagi.people.groupName")}
maxLength={80}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit" className="btn-secondary" disabled={name === room.name}>
{t("sagi.people.rename")}
</button>
</form>
)}
<div>
<p className="mb-1 text-[11px] uppercase tracking-wider text-fg-muted">
{t("sagi.people.members", { n: room.members.length })}
</p>
<ul className="space-y-0.5">
{room.members.map((id) => {
const p = dir.get(id);
return (
<li key={id} className="flex items-center gap-2 rounded-lg px-2 py-1">
<UserAvatar
name={personName(p)}
userId={id}
avatarAt={p?.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{personName(p)}
{id === room.createdBy && (
<span className="ml-1 text-[10px] text-fg-muted">
{t("sagi.people.creator")}
</span>
)}
</span>
{owner && id !== chat.me && (
<button
type="button"
className="rounded-full p-1 text-fg-muted hover:text-status-danger"
aria-label={t("sagi.people.remove", { name: personName(p) })}
title={t("sagi.people.remove", { name: personName(p) })}
onClick={() => void save({ remove: [id] })}
>
<X size={13} />
</button>
)}
</li>
);
})}
</ul>
</div>
{owner && outside.length > 0 && (
<div className="flex min-h-[10rem] flex-col gap-1.5">
<p className="text-[11px] uppercase tracking-wider text-fg-muted">
{t("sagi.people.add")}
</p>
<PeoplePicker
people={outside}
picked={adding}
onToggle={(id) =>
setAdding((prev) => {
const n = new Set(prev);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
})
}
/>
<button
type="button"
className="btn-secondary justify-center"
disabled={!adding.size}
onClick={() => void save({ add: [...adding] })}
>
{t("sagi.people.addN", { n: adding.size })}
</button>
</div>
)}
<button
type="button"
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-status-danger hover:bg-status-danger/10"
onClick={() => void leave()}
>
<LogOut size={14} /> {t("sagi.people.leave")}
</button>
</div>
);
}
/** One direct chat or group. */
export function ChatRoomView({
chat,
room,
onClose,
onLeft,
}: {
chat: ChatState;
room: ChatRoom;
onClose: () => void;
onLeft: () => void;
}) {
const { t } = useTranslation("settings");
const dir = useDirectory(chat);
const title = roomTitle(room, chat.me, dir);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [hasMore, setHasMore] = useState(false);
const [draft, setDraft] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [sending, setSending] = useState(false);
const [settings, setSettings] = useState(false);
const [drag, setDrag] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const stick = useRef(true);
const { markRead } = chat;
const read = useCallback(
(upTo: number) => {
markRead(room.id, upTo);
api.chat.read(room.id, upTo).catch(() => undefined);
},
[room.id, markRead]
);
// The room's newest page on open, then each new message as it arrives.
useEffect(() => {
let live = true;
setMessages([]);
setSettings(false);
stick.current = true;
api.chat
.messages(room.id)
.then((r) => {
if (!live) return;
setMessages(r.messages);
setHasMore(r.hasMore);
const last = r.messages[r.messages.length - 1];
if (last) read(last.id);
})
.catch((e) => toast.error(e));
chat.onMessage((m) => {
if (m.roomId !== room.id) return;
setMessages((prev) => (prev.some((x) => x.id === m.id) ? prev : [...prev, m]));
if (document.visibilityState === "visible") read(m.id);
});
return () => {
live = false;
chat.onMessage(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room.id]);
useEffect(() => {
const el = listRef.current;
if (el && stick.current) el.scrollTop = el.scrollHeight;
}, [messages]);
const older = async () => {
const first = messages[0];
if (!first) return;
try {
const r = await api.chat.messages(room.id, first.id);
stick.current = false;
setMessages((prev) => [...r.messages, ...prev]);
setHasMore(r.hasMore);
} catch (e) {
toast.error(e);
}
};
const addFiles = (list: FileList | File[] | null) => {
if (!list) return;
const picked = [...list];
const big = picked.find((f) => f.size > MAX_FILE_BYTES);
if (big) toast.error(t("sagi.people.tooBig", { name: big.name }));
setFiles((prev) =>
[...prev, ...picked.filter((f) => f.size <= MAX_FILE_BYTES)].slice(0, MAX_FILES)
);
};
const submit = async (e?: FormEvent) => {
e?.preventDefault();
if ((!draft.trim() && !files.length) || sending) return;
setSending(true);
try {
const { message } = await api.chat.post(room.id, draft, files);
stick.current = true;
setMessages((prev) => (prev.some((x) => x.id === message.id) ? prev : [...prev, message]));
setDraft("");
setFiles([]);
} catch (err) {
toast.error(err);
} finally {
setSending(false);
}
};
return (
<Shell label={t("sagi.people.roomLabel", { name: title })}>
<div className="pet-chat-head flex items-center gap-2 px-2.5 py-2">
<RoomIcon room={room} me={chat.me} dir={dir} className="h-7 w-7 text-[10px]" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-fg-primary" title={title}>
{title}
</div>
{room.kind === "group" && (
<div className="text-[10px] uppercase tracking-wider text-fg-muted">
{t("sagi.people.members", { n: room.members.length })}
</div>
)}
</div>
{room.kind === "group" && (
<HeadButton label={t("sagi.people.settings")} onClick={() => setSettings((v) => !v)}>
<Settings2 size={15} />
</HeadButton>
)}
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
<X size={16} />
</HeadButton>
</div>
{settings && room.kind === "group" ? (
<GroupSettings
chat={chat}
room={room}
dir={dir}
onBack={() => setSettings(false)}
onLeft={onLeft}
/>
) : (
<>
<div
ref={listRef}
className={`flex-1 space-y-1.5 overflow-y-auto px-3 py-3 ${drag ? "ring-2 ring-inset ring-accent" : ""}`}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
onDragOver={(e) => {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
setDrag(true);
}
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault();
setDrag(false);
addFiles(e.dataTransfer.files);
}}
>
{hasMore && (
<button
type="button"
className="mx-auto block text-[11px] text-fg-muted hover:text-fg-primary"
onClick={() => void older()}
>
{t("sagi.people.older")}
</button>
)}
{!messages.length && (
<p className="py-10 text-center text-xs text-fg-muted">
{t("sagi.people.empty", { name: title })}
</p>
)}
{messages.map((m, i) => {
if (m.kind === "system") return <SystemLine key={m.id} m={m} dir={dir} />;
const own = m.userId === chat.me;
const prev = messages[i - 1];
const showName =
!own &&
room.kind === "group" &&
(!prev || prev.userId !== m.userId || prev.kind === "system");
return (
<div key={m.id} className={`flex flex-col ${own ? "items-end" : "items-start"}`}>
{showName && (
<span className="mb-0.5 px-1 text-[10px] text-fg-muted">
{personName(m.userId != null ? dir.get(m.userId) : undefined)}
</span>
)}
<div
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm ${
own
? "rounded-br-md bg-accent-solid text-white"
: "pet-chat-reply rounded-bl-md text-fg-primary"
}`}
title={formatTime(m.createdAt)}
>
{m.body && <Linkified text={m.body} />}
{m.files.length > 0 && <Files m={m} own={own} />}
</div>
</div>
);
})}
</div>
<div className="pet-chat-foot px-2.5 py-2.5">
{files.length > 0 && (
<ul className="mb-1.5 flex flex-wrap gap-1">
{files.map((f, i) => (
<li
key={`${f.name}-${i}`}
className="flex max-w-full items-center gap-1 rounded-full bg-surface-4 py-0.5 pl-2 pr-1 text-[11px] text-fg-secondary"
>
<span className="max-w-[10rem] truncate">{f.name}</span>
<button
type="button"
className="rounded-full p-0.5 hover:text-status-danger"
aria-label={t("sagi.people.unattach", { name: f.name })}
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
>
<X size={11} />
</button>
</li>
))}
</ul>
)}
<form onSubmit={submit} className="flex items-end gap-1.5">
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
addFiles(e.target.files);
e.target.value = "";
}}
/>
<button
type="button"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
aria-label={t("sagi.people.attach")}
title={t("sagi.people.attach")}
onClick={() => fileRef.current?.click()}
>
<Paperclip size={16} />
</button>
<textarea
rows={1}
autoFocus
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.people.placeholder", { name: title })}
aria-label={t("sagi.people.placeholder", { name: title })}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPaste={(e) => {
if (e.clipboardData.files.length) {
e.preventDefault();
addFiles(e.clipboardData.files);
}
}}
onKeyDown={(e) => {
if (isSubmitEnter(e) && !e.shiftKey) {
e.preventDefault();
void submit();
} else if (e.key === "Escape") {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClose();
}
}}
/>
<button
type="submit"
disabled={(!draft.trim() && !files.length) || sending}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
aria-label={t("sagi.panel.send")}
>
<ArrowUp size={16} />
</button>
</form>
</div>
</>
)}
</Shell>
);
}
+74 -40
View File
@@ -7,7 +7,10 @@
* shares the single WebSocket.
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
* itself is answered there, anything else goes to the AI.
* itself is answered there, anything else goes to the AI. In server mode a
* rail beside it (`ChatHub.tsx`, `useChat.ts`) switches to direct chats and
* groups with other users, and a blue dot on the pet counts their unread
* messages.
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
@@ -100,6 +103,8 @@ import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu";
import { Heart } from "lucide-react";
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
import { ChatRail, ChatRoomView, NewChat } from "./ChatHub";
import { useChat } from "./useChat";
// Stroking: this many left/right turns of the pointer over the pet, within
// STROKE_MS and with no button held, is a pat; then a pause before the next.
@@ -238,6 +243,14 @@ function SagiBody() {
const navigate = useNavigate();
const href = useSettingsHref();
const brain = useSagiBrain();
// Server mode: chat with other users beside the pet's own (ChatHub.tsx).
const people = useChat();
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
// A room that went away (left, removed): back to the pet.
useEffect(() => {
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
}, [pane, room, people.rooms.length]);
const place = useSagiPosition(size);
// Keep the pet and its size in sync with Settings / other tabs (enabled is
@@ -416,47 +429,63 @@ function SagiBody() {
re-attaches the same session. */}
{open && (
<SagiFlyout anchor={anchor} beside>
<PetChat
name={name}
title={
<PetNameButton
<div className="flex items-start gap-2">
{people.enabled && (
<ChatRail chat={people} active={pane} onSelect={setPane} petName={name} />
)}
{people.enabled && pane === "new" ? (
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
) : people.enabled && room ? (
<ChatRoomView
chat={people}
room={room}
onClose={() => setOpen(false)}
onLeft={() => setPane("pet")}
/>
) : (
<PetChat
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
title={
<PetNameButton
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
/>
}
headerActions={
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={brain.pat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
}
info={
<SagiInfo
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setOpen(false);
}}
onBusy={onBusy}
/>
}
headerActions={
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={brain.pat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
}
info={
<SagiInfo
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setOpen(false);
}}
onBusy={onBusy}
/>
)}
</div>
</SagiFlyout>
)}
@@ -550,6 +579,11 @@ function SagiBody() {
</span>
</span>
)}
{people.unread > 0 && (
<span className="sagi-chat-dot" aria-hidden>
{people.unread > 9 ? "9+" : people.unread}
</span>
)}
{brain.status.errorCount > 0 ? (
<span className="sagi-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
@@ -0,0 +1,142 @@
/**
* @file ChatHub.test.tsx
* @description The pet window's chat between users (`ChatHub.tsx`): the rail
* lists the pet then the rooms with their unread counts; New chat opens a
* direct chat for one person and makes a group for several; a room reads its
* messages (a group names who wrote each), marks them read, appends one that
* arrives live, and sends text with the attached files.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ChatMessage, ChatRoom } from "../../../lib/api";
import type { ChatState } from "../useChat";
const api = vi.hoisted(() => ({
chat: {
openDm: vi.fn(),
createGroup: vi.fn(),
messages: vi.fn(),
post: vi.fn(),
read: vi.fn(),
updateGroup: vi.fn(),
leave: vi.fn(),
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
},
users: { avatarUrl: () => null },
me: { avatarUrl: () => null },
}));
vi.mock("../../../lib/api", async (orig) => ({
...(await orig<Record<string, unknown>>()),
api,
}));
vi.mock("../../AuthGate", () => ({
useAuth: () => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
}),
}));
import { ChatRail, ChatRoomView, NewChat } from "../ChatHub";
const msg = (id: number, userId: number, body: string): ChatMessage => ({
id,
roomId: 7,
userId,
kind: "text",
body,
createdAt: "2026-11-03T02:00:00Z",
files: [],
});
const group: ChatRoom = {
id: 7,
kind: "group",
name: "Team",
createdBy: 1,
createdAt: "2026-11-03T01:00:00Z",
lastMessageAt: null,
members: [1, 2, 3],
lastMessage: null,
lastReadId: 0,
unread: 2,
};
const dm: ChatRoom = { ...group, id: 8, kind: "dm", name: "", members: [1, 2], unread: 0 };
function state(over: Partial<ChatState> = {}): ChatState {
return {
enabled: true,
me: 1,
rooms: [group, dm],
people: [
{ id: 2, username: "an", displayName: "An", avatarAt: null },
{ id: 3, username: "binh", displayName: null, avatarAt: null },
],
unread: 2,
reload: vi.fn(async () => {}),
upsert: vi.fn(),
onMessage: vi.fn(),
markRead: vi.fn(),
...over,
};
}
beforeEach(() => vi.clearAllMocks());
describe("ChatHub", () => {
it("lists the pet, then each room with its unread count", () => {
const onSelect = vi.fn();
render(<ChatRail chat={state()} active="pet" onSelect={onSelect} petName="Sagi" />);
expect(screen.getByRole("button", { name: "Sagi" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Team, 2 unread" }));
expect(onSelect).toHaveBeenCalledWith(7);
// A direct chat is named after the other person.
expect(screen.getByRole("button", { name: "An" })).toBeTruthy();
});
it("opens a direct chat for one person and a group for several", async () => {
const chat = state();
const onOpen = vi.fn();
api.chat.openDm.mockResolvedValue({ room: dm });
render(<NewChat chat={chat} onOpen={onOpen} onClose={() => {}} />);
fireEvent.click(screen.getByRole("checkbox", { name: /An/ }));
fireEvent.click(screen.getByRole("button", { name: "Start chat" }));
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(8));
expect(api.chat.openDm).toHaveBeenCalledWith(2);
api.chat.createGroup.mockResolvedValue({ room: group });
fireEvent.click(screen.getByRole("checkbox", { name: /binh/ }));
fireEvent.change(screen.getByLabelText("Group name (optional)"), { target: { value: "Team" } });
fireEvent.click(screen.getByRole("button", { name: "Create group (2 people)" }));
await waitFor(() => expect(api.chat.createGroup).toHaveBeenCalledWith("Team", [2, 3]));
});
it("reads the room, names group senders, marks it read and sends with files", async () => {
const chat = state();
api.chat.messages.mockResolvedValue({
messages: [msg(1, 2, "chào"), msg(2, 1, "hi")],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
render(<ChatRoomView chat={chat} room={group} onClose={() => {}} onLeft={() => {}} />);
expect(await screen.findByText("chào")).toBeTruthy();
expect(screen.getByText("An")).toBeTruthy(); // sender of a group message
expect(api.chat.read).toHaveBeenCalledWith(7, 2);
expect(chat.markRead).toHaveBeenCalledWith(7, 2);
// A message arriving live is appended.
const calls = vi.mocked(chat.onMessage).mock.calls;
const push = calls[calls.length - 1]![0]!;
push(msg(3, 3, "mới"));
expect(await screen.findByText("mới")).toBeTruthy();
const file = new File(["x"], "a.png", { type: "image/png" });
const box = screen.getByLabelText("Message Team");
fireEvent.paste(box, { clipboardData: { files: [file] } });
expect(screen.getByText("a.png")).toBeTruthy();
fireEvent.change(box, { target: { value: "xem nè" } });
api.chat.post.mockResolvedValue({ message: { ...msg(4, 1, "xem nè") } });
fireEvent.click(screen.getByRole("button", { name: /send/i }));
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
expect(await screen.findByText("xem nè")).toBeTruthy();
});
});
@@ -200,6 +200,20 @@ describe("Sagi widget", () => {
expect(within(liveChip).getByText("2")).toBeInTheDocument();
});
it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => {
renderSagi();
act(() => {
eventBus.publish({ ...sessionMsg("mine", "active"), mine: true });
eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false });
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
});
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
expect(within(liveChip).getByText("1")).toBeInTheDocument();
expect(screen.queryByText(/theirs/)).toBeNull();
});
it("respects the enabled preference", () => {
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
renderSagi();
+21 -1
View File
@@ -6,7 +6,8 @@
* infinite animation makes the browser draw a new frame 60 times a second for
* as long as the page is open (the drop-shadow filter re-rendered each time),
* which made the whole desktop stutter. All motion is disabled under
* [data-reduced="1"] and prefers-reduced-motion.
* [data-reduced="1"] and prefers-reduced-motion. The blue dot counts unread
* chat messages from other users (server mode).
* Transform origins are the delicate part - see the comments on .sagi-avatar
* and .sagi-eye-blink for which reference box each one resolves against.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -406,6 +407,25 @@
outline: 2px solid #818cf8;
outline-offset: 3px;
}
/* Unread chat messages from other users: top-left, opposite the error dot. */
.sagi-chat-dot {
position: absolute;
top: 2px;
left: 2px;
min-width: 14px;
height: 14px;
padding: 0 2px;
border-radius: 9999px;
background: #3b82f6;
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #0c0c14;
}
.sagi-error-dot {
position: absolute;
top: 2px;
+119
View File
@@ -0,0 +1,119 @@
/**
* @file useChat.ts
* @description The pet window's chat between users (server mode only): the
* signed-in user's rooms (`GET /api/chat/rooms`), the people they can start a
* chat with (`GET /api/chat/people`, read once), and the total unread count
* for the badge on the pet. A `chat_message` frame updates its room's newest
* message and unread count in place and is handed to the open room's view
* (`onMessage`); a `chat_room` frame (made, renamed, members changed, left)
* re-reads the list. Off in local mode — one person has no one to chat with.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { useAuth } from "../AuthGate";
import type { WSMessage } from "../../lib/types";
export type ChatState = {
/** Server mode with a login: the chat exists. */
enabled: boolean;
me: number | null;
rooms: ChatRoom[];
people: ChatPerson[];
unread: number;
reload: () => Promise<void>;
/** A room changed here (opened, renamed, read): put it in the list now. */
upsert: (room: ChatRoom) => void;
/** The open room's view, told of each new message for it. */
onMessage: (fn: ((m: ChatMessage) => void) | null) => void;
/** Mark a room read locally (the view also tells the server). */
markRead: (roomId: number, upTo: number) => void;
};
export function useChat(): ChatState {
const { state } = useAuth();
const me = state.mode === "server" && "user" in state && state.user ? state.user.id : null;
const enabled = me != null;
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [people, setPeople] = useState<ChatPerson[]>([]);
const listener = useRef<((m: ChatMessage) => void) | null>(null);
const reload = useCallback(async () => {
if (!enabled) return;
try {
setRooms((await api.chat.rooms()).rooms);
} catch {
// The pet's chat with the AI still works; the list retries on the next frame.
}
}, [enabled]);
useEffect(() => {
if (!enabled) return;
void reload();
api.chat
.people()
.then((r) => setPeople(r.people))
.catch(() => undefined);
}, [enabled, reload]);
useEffect(() => {
if (!enabled) return;
const off = eventBus.subscribe((msg: WSMessage) => {
if (msg.type === "chat_room") {
void reload();
return;
}
if (msg.type !== "chat_message") return;
const m = (msg.data as { message?: ChatMessage }).message;
if (!m) return;
let known = false;
setRooms((prev) => {
const next = prev.map((r) => {
if (r.id !== m.roomId) return r;
known = true;
const own = m.userId === me;
return {
...r,
lastMessage: m,
lastMessageAt: m.createdAt,
unread: own ? 0 : r.unread + 1,
lastReadId: own ? m.id : r.lastReadId,
};
});
return next.sort((a, b) =>
(b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)
);
});
// A room this list has not seen yet (added a moment ago): read it all.
if (!known) void reload();
listener.current?.(m);
});
// Reconnected: frames may have been missed.
const offConn = eventBus.onConnection((up) => {
if (up) void reload();
});
return () => {
off();
offConn();
};
}, [enabled, me, reload]);
const upsert = useCallback((room: ChatRoom) => {
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
}, []);
const onMessage = useCallback((fn: ((m: ChatMessage) => void) | null) => {
listener.current = fn;
}, []);
const markRead = useCallback((roomId: number, upTo: number) => {
setRooms((prev) =>
prev.map((r) =>
r.id === roomId ? { ...r, unread: 0, lastReadId: Math.max(r.lastReadId, upTo) } : r
)
);
}, []);
const unread = useMemo(() => rooms.reduce((n, r) => n + r.unread, 0), [rooms]);
return { enabled, me, rooms, people, unread, reload, upsert, onMessage, markRead };
}
+6 -2
View File
@@ -7,7 +7,8 @@
* burst of the same event into one count), the pet's one-shot animation
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
* for the UI shell. It also greets once per browser session and makes an
* idle pet fidget now and then.
* idle pet fidget now and then. It only ever counts the viewer's OWN sessions
* (`mine=1` seed, and frames marked `mine: false` skipped).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
}, GREET_DELAY_MS);
};
api.sessions
.list({ status: "active", limit: 100 })
.list({ status: "active", limit: 100, mine: true })
.then((res) => {
if (cancelled) return;
update((prev) => seedSessions(prev, res.sessions, Date.now()));
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
// Subscribe to the live stream and connection status.
useEffect(() => {
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
// Each user's pet tells only of their own sessions: an admin/analyst
// gets every user's, marked `mine: false` when not theirs.
if (msg.mine === false) return;
const prev = stateRef.current;
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
// Most frames only move lastActivityAt, which the moods read at a 1 s
+4 -3
View File
@@ -4,7 +4,8 @@
* which the sidebar owns and which also carries the update check), the version
* — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* `?awaiting=1` — the Dashboard's Needs-you rule —, both `&mine=1`: the user's
* OWN sessions even for an admin —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
* user's OWN spend even for an admin — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
@@ -388,11 +389,11 @@ export function StatusBar({
const loadCounts = useCallback(() => {
api.sessions
.list({ status: "active", limit: 1 })
.list({ status: "active", limit: 1, mine: true })
.then((r) => setActive(r.total))
.catch(() => undefined);
api.sessions
.list({ awaiting: true, limit: 1 })
.list({ awaiting: true, limit: 1, mine: true })
.then((r) => setNeedYou(r.total))
.catch(() => undefined);
}, []);
@@ -183,6 +183,9 @@ describe("StatusBar", () => {
);
await screen.findByText("1 need you");
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
// The user's OWN sessions, even for an admin who sees every user's rows.
for (const [p] of vi.mocked(api.sessions.list).mock.calls)
expect(p).toMatchObject({ mine: true });
await user.click(conn);
expect(onConnectionClick).toHaveBeenCalled();
// Polite live region: the connection and who is waiting, nothing that ticks.
+73 -22
View File
@@ -11,7 +11,9 @@
* GPUs), disks (how full, read / write rate) with network interfaces, and the
* busiest processes with who each counts for (history, CPU threads and
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
* workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
* Administration users table shares for each home's share of the homes disk.
* Read-only.
@@ -38,14 +40,17 @@ import {
type HostHistoryRange,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
} from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
import { usePermissions } from "../AuthGate";
import { Tip } from "../Tip";
import { MiniChart } from "../admin/MiniChart";
import { UserAvatar } from "../users/UserAvatar";
const POLL_MS = 5000;
const HISTORY_POLL_MS = 30000;
const USERS_POLL_MS = 60000;
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
@@ -324,6 +329,25 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const [history, setHistory] = useState<HostHistory | null>(null);
// The user tile whose busiest processes are shown.
const [open, setOpen] = useState<string | null>(null);
// Each account's role and spend for the tiles' hover card. `GET /api/users`
// is admin/analyst only, so a developer gets no card.
const { seesAll, local } = usePermissions();
const { t: tu } = useTranslation("users");
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
useEffect(() => {
if (!seesAll || local || !hasPeople) return;
const load = () =>
api.users
.list()
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
.catch(() => {});
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, USERS_POLL_MS);
return () => clearInterval(id);
}, [seesAll, local, hasPeople]);
const loadHistory = useCallback(
() =>
@@ -365,6 +389,29 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
if (key === "system") return t("host.systemGroup");
return key;
};
const userTip = (g: HostUsageGroup) => {
const u = g.userId != null ? accounts.get(g.userId) : undefined;
// No account row (a developer, or not read yet): no card at all.
if (!u) return "";
const lines = [groupName(g)];
{
lines.push(
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
);
if (u.today)
lines.push(
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
);
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
lines.push(
t("host.userTipLastLogin", {
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
})
);
}
return lines.join("\n");
};
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
const timeLabel = (iso: string) =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
@@ -676,26 +723,30 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const isOpen = open === g.key;
return (
<div key={g.key} className="space-y-2" data-testid="server-user">
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
{/* The hover card sits on the name row only: over the whole
tile it stacked on the CPU bar's own help tip. */}
<Tip raw={userTip(g)} block>
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
</Tip>
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
{isOpen && (
@@ -7,7 +7,8 @@
* rate and each network interface, the CPU / memory history with its range
* switch (`GET /api/host/history`), and the busiest processes with who each
* counts for, the processes of no user in a short list of their own, and
* each user's live CPU / RAM as a tile (their busiest processes on click);
* each user's live CPU / RAM as a tile (their busiest processes on click; on
* hover, for staff, their role and $ spent today);
* off Linux the cards it can never fill are left out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -69,8 +70,10 @@ const stats: HostStats = {
const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null },
users: { avatarUrl: () => null },
users: { avatarUrl: () => null, list: vi.fn() },
}));
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api };
@@ -105,6 +108,7 @@ const full: HostStats = {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
api.host.stats.mockResolvedValue(full);
api.host.history.mockResolvedValue({
range: "1h",
@@ -182,6 +186,50 @@ describe("HostHealth", () => {
expect(within(tile).getByText("claude")).toBeTruthy();
});
it("names the role and today's spend when a user tile is hovered", async () => {
Object.assign(perms, { local: false });
api.users.list.mockResolvedValue({
users: [
{
id: 2,
username: "mia",
role: "developer",
disabled: false,
workspaces: 2,
sessions: 5,
today: { cost: 1.5, tokens: 12000 },
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
lastLoginAt: null,
},
],
});
render(<Tab />);
const tile = await screen.findByTestId("server-user");
// The card exists only once the users list has arrived.
const nameRow = () => within(tile).getByRole("button").parentElement!;
const tip = await vi.waitFor(() => {
fireEvent.mouseEnter(nameRow());
return screen.getByText(/Role: Developer/);
});
expect(tip.textContent).not.toContain("Login");
expect(tip.textContent).toMatch(/Today: \$1\.5/);
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
// Only the name row carries it: the CPU bar keeps its own help tip alone.
fireEvent.mouseLeave(within(tile).getByRole("button").parentElement!);
fireEvent.mouseEnter(within(tile).getByRole("meter", { name: "CPU" }));
expect(screen.queryByText(/Role: Developer/)).toBeNull();
});
it("gives a developer no hover card, without reading the users list", async () => {
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
render(<Tab />);
const tile = await screen.findByTestId("server-user");
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
expect(screen.queryByText(/Today:/)).toBeNull();
expect(api.users.list).not.toHaveBeenCalled();
});
it("leaves out what Windows cannot report instead of empty cards", async () => {
api.host.stats.mockResolvedValue({
...stats,
+11 -3
View File
@@ -86,7 +86,7 @@ import {
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { wheelReports, type WheelState } from "../../lib/wheelReports";
import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
@@ -455,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
fontWeightBold: 700,
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
// xterm's own scrollback scrolls at the same share as the reports below.
scrollSensitivity: SCROLL_SPEED,
});
termRef.current = term;
const fit = new FitAddon();
@@ -538,8 +540,14 @@ export const TerminalView = memo(function TerminalView({
const ws = wsRef.current;
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols);
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
const col = clamp(
Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1,
term.cols
);
const row = clamp(
Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1,
term.rows
);
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
};
@@ -329,7 +329,8 @@ describe("TerminalView", () => {
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
const screenBox = () => {
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement };
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
.value as { element: HTMLElement };
const screen = document.createElement("div");
screen.className = "xterm-screen";
screen.getBoundingClientRect = () =>
@@ -350,9 +351,11 @@ describe("TerminalView", () => {
// A notch of 6 rows: one report, at the cell under the pointer.
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
// A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up.
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
// two reports, sent as they add up.
ws.sent.length = 0;
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
for (let i = 0; i < 6; i++)
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
});
@@ -523,9 +526,7 @@ describe("TerminalView", () => {
// wrong. The WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
true
);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true);
});
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
@@ -20,10 +20,11 @@
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. **Commit signing**
* (under the author): one picker signs every commit with one of the keys
* (`PUT /api/me/git/signing`), and **Import a key you have** takes a private
* key file or paste (`POST /api/me/git/ssh-key/import`) and, ticked by
* default, signs with it in the same step. The private key and
* (under the author) is PER WORKSPACE: one picker per workspace of the user's
* (`PUT /api/me/git/signing/lane`, "Don't sign" by default), the keys that can
* sign with Delete, a way out of the old account-wide setting and of a
* signing setup the user made themselves, and **Import a key you have** takes
* a private key file or paste (`POST /api/me/git/ssh-key/import`). The private key and
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -44,6 +45,7 @@ import {
Upload,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
@@ -233,9 +235,13 @@ function GitHostForm({
}
/**
* Commit signing: the key every commit is signed with (one picker — choosing
* a key turns signing on, "Don't sign" off) and Import a key the user already
* has, which signs with it in the same step unless unticked.
* Commit signing, PER WORKSPACE: one picker per workspace of the user's
* (`PUT /api/me/git/signing/lane` — "Don't sign" by default, so only the
* workspaces that need it sign), the keys that can sign, each with Delete,
* and Import a key the user already has. The old account-wide setting
* (every repo signed) only shows when it is on, with a button that turns it
* off; a signing setup the user made themselves in `~/.gitconfig` can be
* taken out (their key file stays).
*/
function SigningCard({
id,
@@ -251,51 +257,90 @@ function SigningCard({
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const auth = useAuth().state;
const me = auth.mode === "server" ? auth.user : null;
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
const current = signing.state === "on" ? keys.find((k) => k.id === signing.key) : undefined;
const [form, setForm] = useState<{ text: string; name: string; sign: boolean } | null>(null);
const laneSigning = id.laneSigning ?? [];
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
/** Sign with `keyId` (null = stop), asking before taking over the user's own setup. */
const choose = async (keyId: string | null) => {
if (keyId && signing.state === "foreign") {
if (
!(await confirmDialog({
title: t("gitIdentity.signReplaceConfirm", {
path: signing.path,
key: keyLabel(keyId),
}),
confirmLabel: t("gitIdentity.signReplace"),
}))
)
return;
}
useEffect(() => {
let live = true;
api.lanes
.list()
.then((r) => live && setLanes(r.lanes))
.catch(() => live && setLanes([]));
return () => {
live = false;
};
}, []);
// The user's OWN workspaces: an admin sees everyone's, but signs only theirs.
const mine = (lanes ?? []).filter((l) => !me || l.owner_id == null || l.owner_id === me.id);
const laneName = (l: Lane) => l.title || l.cwd;
// Still signing, but its workspace is gone from the list (removed): the
// folder keeps signing until it is turned off here.
const orphans =
lanes === null
? []
: laneSigning
.filter((x) => !lanes.some((l) => l.id === x.laneId))
.map(
(x) =>
({
id: x.laneId,
title: t("gitIdentity.signLaneGone", { id: x.laneId }),
cwd: "",
}) as Lane
);
const signedWith = (laneId: number) => laneSigning.find((x) => x.laneId === laneId);
const chooseLane = async (lane: Lane, keyId: string | null) =>
run(
() => api.gitIdentity.setLaneSigning(lane.id, keyId),
keyId
? t("gitIdentity.signLaneSaved", { name: laneName(lane), key: keyLabel(keyId) })
: t("gitIdentity.signLaneStopped", { name: laneName(lane) })
);
const clearForeign = async () => {
if (
!(await confirmDialog({
title: t("gitIdentity.signForeignConfirm", { path: signing.path }),
confirmLabel: t("gitIdentity.signForeignRemove"),
}))
)
return;
await run(
() => api.gitIdentity.setSigning(keyId, signing.state === "foreign"),
keyId ? t("gitIdentity.signSaved", { key: keyLabel(keyId) }) : t("gitIdentity.signStopped")
() => api.gitIdentity.setSigning(null, false, true),
t("gitIdentity.signForeignRemoved")
);
};
const deleteKey = async (k: Key) => {
if (
!(await confirmDialog({
title: t("gitIdentity.signKeyDeleteConfirm", { key: keyLabel(k.id) }),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(
() => api.gitIdentity.removeKeyById(k.id!),
t("gitIdentity.signKeyDeleted", { key: keyLabel(k.id) })
);
};
const doImport = async () => {
if (!form) return;
let imported: string | null = null;
let foreign = false;
const ok = await run(
async () => {
const r = await api.gitIdentity.importKey({
() =>
api.gitIdentity.importKey({
privateKey: form.text,
...(form.name.trim() ? { name: form.name.trim() } : {}),
sign: form.sign,
});
imported = r.imported?.id ?? null;
foreign = r.signError?.code === "EFOREIGNSIGN";
return r;
},
}),
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
);
if (!ok) return;
setForm(null);
// Saved, but the user's own signing setup was left alone: ask once.
if (imported && foreign) await choose(imported);
if (ok) setForm(null);
};
const readFile = async (file: File | undefined) => {
@@ -306,6 +351,8 @@ function SigningCard({
setForm({ ...form, text });
};
const usable = keys.filter((k) => k.id);
return (
<section
className="space-y-2 rounded-lg border border-border p-3"
@@ -321,7 +368,7 @@ function SigningCard({
type="button"
className={BTN}
disabled={busy}
onClick={() => setForm({ text: "", name: "", sign: true })}
onClick={() => setForm({ text: "", name: "" })}
>
<Upload className="h-3.5 w-3.5" />
{t("gitIdentity.importKey")}
@@ -330,52 +377,138 @@ function SigningCard({
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
<div className="flex flex-wrap items-center gap-2">
<span
data-testid="git-signing-state"
className={`min-w-0 flex-1 text-[11px] ${
signing.state === "on" ? "text-status-success" : "text-fg-secondary"
}`}
{signing.state === "on" && (
<div
data-testid="git-signing-legacy"
className="flex flex-wrap items-center gap-2 rounded border border-status-warning/40 bg-status-warning/10 px-2.5 py-1.5"
>
{signing.state === "on"
? t("gitIdentity.signOn", { key: keyLabel(signing.key) })
: signing.state === "foreign"
? t("gitIdentity.signForeign", { path: signing.path })
: t("gitIdentity.signOff")}
</span>
{keys.length > 0 && (
<select
aria-label={t("gitIdentity.signKey")}
value={signing.state === "on" ? (signing.key ?? "") : ""}
disabled={busy || !id.writable}
onChange={(e) => void choose(e.target.value || null)}
className="select !py-1 !text-xs"
>
<option value="">
{signing.state === "foreign" ? signing.path : t("gitIdentity.signNone")}
</option>
{keys
.filter((k) => k.id)
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
<span className="min-w-0 flex-1 text-[11px] text-fg-primary">
{t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })}
</span>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() =>
void run(() => api.gitIdentity.setSigning(null), t("gitIdentity.signLegacyStopped"))
}
>
{t("gitIdentity.signLegacyOff")}
</button>
)}
</div>
)}
{signing.state === "foreign" && (
<div
data-testid="git-signing-foreign"
className="flex flex-wrap items-center gap-2 rounded border border-border px-2.5 py-1.5"
>
<span className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
{t("gitIdentity.signForeign", { path: signing.path })}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void clearForeign()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.signForeignRemove")}
</button>
)}
</div>
)}
<div className="space-y-1" data-testid="git-signing-lanes">
<div className="text-[11px] font-medium text-fg-secondary">
{t("gitIdentity.signLanes")}
</div>
{usable.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signNeedKey")}</p>
) : lanes === null ? null : mine.length + orphans.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signLanesEmpty")}</p>
) : (
[...mine, ...orphans].map((l) => {
const on = signedWith(l.id);
return (
<div key={l.id} className="flex items-center gap-2">
<span
className={`min-w-0 flex-1 truncate text-xs ${
on ? "text-status-success" : "text-fg-primary"
}`}
title={l.cwd || undefined}
>
{laneName(l)}
</span>
<select
aria-label={t("gitIdentity.signLaneKey", { name: laneName(l) })}
value={on?.key ?? ""}
disabled={busy || !id.writable}
onChange={(e) => void chooseLane(l, e.target.value || null)}
className="select !w-48 !py-1 !text-xs"
>
<option value="">{t("gitIdentity.signNone")}</option>
{usable.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
</div>
);
})
)}
</div>
{current && (
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.signHostStep")}</span>
<button type="button" className={BTN} onClick={() => void copyText(current.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{usable.length > 0 && (
<div className="space-y-1" data-testid="git-signing-keys">
<div className="text-[11px] font-medium text-fg-secondary">
{t("gitIdentity.signKeys")}
</div>
<div className="truncate font-mono text-[10px] text-fg-muted">{current.fingerprint}</div>
</Step>
{usable.map((k) => {
const used = laneSigning.filter((x) => x.key === k.id).length;
return (
<div
key={k.id}
className="flex items-center gap-2 rounded border border-border px-2 py-1"
>
<FileKey className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-fg-primary">{keyLabel(k.id)}</div>
<div className="truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
{used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""}
</div>
</div>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
aria-label={t("gitIdentity.signKeyCopy", { key: keyLabel(k.id) })}
title={t("gitIdentity.signHostStep")}
>
<Copy className="h-3.5 w-3.5" />
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={t("gitIdentity.removeKeyNamed", { name: keyLabel(k.id) })}
onClick={() => void deleteKey(k)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
);
})}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.signHostStep")}</p>
</div>
)}
{form && (
@@ -407,24 +540,14 @@ function SigningCard({
onChange={(e) => setForm({ ...form, text: e.target.value })}
className={`${INPUT} resize-y font-mono text-[10px]`}
/>
<div className="flex flex-wrap items-center gap-3">
<input
aria-label={t("gitIdentity.importName")}
placeholder={t("gitIdentity.importName")}
value={form.name}
autoComplete="off"
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
className={`${INPUT} !w-40 font-mono`}
/>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
<input
type="checkbox"
checked={form.sign}
onChange={(e) => setForm({ ...form, sign: e.target.checked })}
/>
{t("gitIdentity.importSign")}
</label>
</div>
<input
aria-label={t("gitIdentity.importName")}
placeholder={t("gitIdentity.importName")}
value={form.name}
autoComplete="off"
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
className={`${INPUT} !w-40 font-mono`}
/>
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
<div className="flex gap-2">
<button
@@ -8,8 +8,10 @@
* the sections never mix; "Add another key" makes a named key as one more
* card in its own tab, a second AWS account is saved from that card under
* the key's name with that key, and a second GitHub key is tied to its host;
* commit signing: a key picked signs commits, an imported key signs in the
* same step, and taking over the user's own signing key asks first.
* commit signing PER WORKSPACE: an imported key signs nothing by itself, one
* workspace's picker signs that workspace only, a signing key is deleted from
* the card after a confirm, and the old account-wide setting and a setup the
* user made themselves each have a way out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -35,6 +37,16 @@ vi.mock("../../../lib/api", () => ({
removeHttps: vi.fn(),
importKey: vi.fn(),
setSigning: vi.fn(),
setLaneSigning: vi.fn(),
},
lanes: {
list: vi.fn(async () => ({
lanes: [
{ id: 1, title: "shop-api", cwd: "/w/shop-api" },
{ id: 2, title: "", cwd: "/w/docs" },
],
counts: {},
})),
},
},
}));
@@ -42,6 +54,7 @@ vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn()
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
const OFF = { state: "off" as const, key: null, path: null };
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
account: "member",
@@ -340,7 +353,7 @@ describe("GitIdentitySettings", () => {
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
});
it("signs commits with an imported key in one step", async () => {
it("imports a key without signing anything by itself", async () => {
const signingKey = {
...KEY,
id: "id_ed25519_signing",
@@ -348,17 +361,15 @@ describe("GitIdentitySettings", () => {
name: "signing",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [], signing: { state: "off", key: null, path: null } })
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
);
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
...view({
sshKeys: [signingKey],
signing: { state: "on", key: "id_ed25519_signing", path: null },
}),
...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }),
imported: signingKey,
signError: null,
});
render(<GitIdentitySettings />);
expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
const form = screen.getByRole("form", { name: "Import an existing private key" });
await userEvent.type(
@@ -368,26 +379,103 @@ describe("GitIdentitySettings", () => {
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
sign: true,
});
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent(/signed with signing/);
expect(confirmDialog).not.toHaveBeenCalled();
// Now there is a key: each workspace gets its own picker, all "Don't sign".
expect(
await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 })
).toHaveValue("");
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
});
it("asks before taking over a signing key the user set up themselves", async () => {
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
it("signs one workspace only, and stops it", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/id_ed25519" } })
view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] })
);
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
vi.mocked(api.gitIdentity.setLaneSigning)
.mockResolvedValueOnce(
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] })
)
.mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] }));
render(<GitIdentitySettings />);
const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 });
await userEvent.selectOptions(pick, k.id);
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id);
await waitFor(() =>
expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id)
);
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
expect(
within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/)
).toBeTruthy();
await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), "");
expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null);
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
});
it("lists a removed workspace that still signs, so it can be turned off", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] })
);
vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue(
view({ sshKeys: [k], signing: OFF, laneSigning: [] })
);
render(<GitIdentitySettings />);
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent("~/.ssh/id_ed25519");
await userEvent.selectOptions(screen.getByLabelText("Signing key"), "id_ed25519");
expect(confirmDialog).toHaveBeenCalled();
const pick = await screen.findByLabelText(
"Signing key for Workspace #9 (removed)",
{},
{ timeout: 5000 }
);
expect(pick).toHaveValue(k.id);
await userEvent.selectOptions(pick, "");
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null);
await waitFor(() =>
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith("id_ed25519", true)
expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull()
);
});
it("deletes a signing key from the signing card, after asking", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({
sshKeys: [k],
signing: { state: "off", key: null, path: null },
laneSigning: [{ laneId: 1, key: k.id }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [], signing: OFF, laneSigning: [] })
);
render(<GitIdentitySettings />);
const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 });
await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) })
);
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id);
await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull());
});
it("turns off the old account-wide signing and removes a setup the user made", async () => {
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
);
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } })
);
render(<GitIdentitySettings />);
const legacy = await screen.findByTestId("git-signing-legacy");
await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" }));
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null);
const foreign = await screen.findByTestId("git-signing-foreign");
expect(foreign).toHaveTextContent("~/.ssh/mine");
await userEvent.click(within(foreign).getByRole("button", { name: "Remove" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) })
);
expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true);
});
});
+7 -1
View File
@@ -182,6 +182,12 @@
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
"agenthub": "AgentHub",
"usersTitle": "Users · CPU / RAM now",
"usersCount": "{{n}} users"
"usersCount": "{{n}} users",
"userTipRole": "Role: {{v}}",
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
"userTipWork": "{{w}} workspaces · {{s}} sessions",
"userTipLastLogin": "Last sign-in: {{v}}",
"userTipNever": "never",
"userTipDisabled": "Disabled"
}
}
+101 -18
View File
@@ -77,13 +77,21 @@
"run_done_other": "{{count}} runs finished 🐾"
},
"greet": {
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
"morning": [
"good morning! ☀️",
"morning! coffee first? ☕",
"rise and shine 🌤️"
],
"afternoon": [
"good afternoon! 🐾",
"hey, welcome back 😺",
"afternoon! ready when you are ✨"
],
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
"evening": [
"good evening! 🌆",
"evening shift? I'm in 😼",
"hey there, night owl-to-be 🌙"
],
"night": [
"burning the midnight oil? 🌙",
"late one, huh? I'll keep watch 🦉",
@@ -106,7 +114,11 @@
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
"hello": "hi there! 😺 {{summary}}.",
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
"thanks": [
"anytime! 💜",
"happy to help 😸",
"you got it 🐾"
],
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
"errorsNone": "no errors - all clean 🐾",
@@ -163,17 +175,41 @@
"can't reach the server 📡",
"no signal - taking a nap 💤"
],
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
"worried": [
"that didn't look right 😟",
"keeping an eye out 👀",
"hmm, something's off 🫣"
],
"stuck": [
"a session's been quiet a while… 🤔",
"is something stuck? ⏳",
"still chewing on it… 😾"
],
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
"happy": [
"great run! 😻",
"love a tidy finish ✨",
"purrfect 💜"
],
"thinking": [
"hmm, let me look… 🤔",
"sniffing around… 🐾",
"one sec, checking 🔍"
],
"watching": [
"on the prowl 👀",
"watching your sessions 😼",
"eyes peeled 🐾"
],
"sleeping": [
"zzz… 💤",
"wake me if something happens 😴",
"curled up, all calm 🐈"
],
"idle": [
"all quiet 😺",
"ready when you are 🐾",
"just vibing ✨"
]
},
"chat": {
"label": "Chat with {{name}}",
@@ -191,6 +227,41 @@
"starting": "Starting {{tool}}…",
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
},
"people": {
"rail": "Chats",
"new": "New chat",
"newHint": "Pick one person for a direct chat, or several for a group.",
"search": "Search people",
"none": "No one found",
"groupName": "Group name (optional)",
"createGroup": "Create group ({{n}} people)",
"startDm": "Start chat",
"close": "Close",
"unreadIn": "{{name}}, {{n}} unread",
"roomLabel": "Chat with {{name}}",
"members": "{{n}} members",
"settings": "Group settings",
"back": "Back to the chat",
"rename": "Rename",
"creator": "· created the group",
"remove": "Remove {{name}}",
"add": "Add people",
"addN": "Add {{n}}",
"leave": "Leave group",
"leaveTitle": "Leave this group?",
"leaveBody": "You will no longer see {{name}}. Someone in it can add you again.",
"older": "Load older messages",
"empty": "No messages yet. Say hello to {{name}}.",
"placeholder": "Message {{name}}",
"attach": "Attach files",
"unattach": "Remove {{name}}",
"tooBig": "{{name}} is over 25 MB",
"sysCreated": "{{who}} created the group",
"sysRenamed": "{{who}} renamed the group",
"sysLeft": "{{who}} left",
"sysAdded": "{{who}} added {{names}}",
"sysRemoved": "{{who}} removed {{names}}"
}
},
"pricing": {
@@ -884,23 +955,35 @@
"authorEmail": "Email",
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
"signTitle": "Commit signing",
"signIntro": "Sign every commit — from your terminal, the Changes panel or an agent — with one of your SSH keys, so a host that requires signed commits accepts your push.",
"signOff": "Commits are not signed.",
"signOn": "Every commit is signed with {{key}}.",
"signForeign": "Commits are signed with a key you set up yourself: {{path}}.",
"signIntro": "Choose which workspaces sign their commits — from your terminal, the Changes panel or an agent — with one of your SSH keys. Every other repository stays unsigned.",
"signLanes": "Workspaces",
"signLanesEmpty": "You have no workspace yet.",
"signNeedKey": "Import a key you have, or make one below, to sign with.",
"signLaneGone": "Workspace #{{id}} (removed)",
"signLaneKey": "Signing key for {{name}}",
"signLaneSaved": "{{name}} now signs its commits with {{key}}.",
"signLaneStopped": "{{name}} no longer signs its commits.",
"signLegacy": "Every commit of your account is signed with {{key}} (the old account-wide setting).",
"signLegacyOff": "Sign per workspace only",
"signLegacyStopped": "Account-wide signing is off — each workspace keeps its own choice.",
"signForeign": "Your ~/.gitconfig signs every commit with a key you set up yourself: {{path}}.",
"signForeignRemove": "Remove",
"signForeignConfirm": "Take {{path}} out of your ~/.gitconfig? Commits stop being signed with it; the key file itself is not deleted.",
"signForeignRemoved": "The old signing setup was taken out of ~/.gitconfig.",
"signKeys": "Keys you can sign with",
"signKeyUsed_one": "signs {{count}} workspace",
"signKeyUsed_other": "signs {{count}} workspaces",
"signKeyCopy": "Copy the public key of {{key}}",
"signKeyDeleteConfirm": "Delete key {{key}}? Its private file is deleted, every workspace signing with it stops signing, and clones that use it can no longer fetch or push.",
"signKeyDeleted": "Key {{key}} deleted.",
"signKey": "Signing key",
"signNone": "Don't sign",
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
"signReplaceConfirm": "Your git already signs with {{path}}. Switch to {{key}}?",
"signReplace": "Switch",
"signSaved": "Commits are now signed with {{key}}.",
"signStopped": "Commits are no longer signed.",
"importKey": "Import a key you have",
"importTitle": "Import an existing private key",
"importFile": "Choose the key file",
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
"importName": "Name (optional)",
"importSign": "Sign my commits with it",
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
"importSubmit": "Import",
"imported": "Key {{name}} imported.",
+7 -1
View File
@@ -182,6 +182,12 @@
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
"agenthub": "AgentHub",
"usersTitle": "Người dùng · CPU / RAM hiện tại",
"usersCount": "{{n}} người dùng"
"usersCount": "{{n}} người dùng",
"userTipRole": "Vai trò: {{v}}",
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
"userTipWork": "{{w}} workspace · {{s}} phiên",
"userTipLastLogin": "Đăng nhập gần nhất: {{v}}",
"userTipNever": "chưa bao giờ",
"userTipDisabled": "Đã vô hiệu hóa"
}
}
+116 -21
View File
@@ -77,10 +77,26 @@
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
},
"greet": {
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
"morning": [
"chào buổi sáng! ☀️",
"sáng rồi! cà phê chưa? ☕",
"ngày mới tốt lành nha 🌤️"
],
"afternoon": [
"chào buổi chiều! 🐾",
"bạn quay lại rồi 😺",
"chiều nay làm gì đây? ✨"
],
"evening": [
"chào buổi tối! 🌆",
"ca tối hả? có mình đây 😼",
"tối rồi, cố lên nhé 🌙"
],
"night": [
"khuya rồi mà vẫn làm à? 🌙",
"để mình canh giúp cho 🦉",
"nhớ nghỉ ngơi nha 💤"
],
"summary_one": "{{count}} phiên đang chạy",
"summary_other": "{{count}} phiên đang chạy",
"summaryWaiting": "{{count}} phiên chờ bạn",
@@ -98,7 +114,11 @@
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
"hello": "chào bạn! 😺 {{summary}}.",
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
"thanks": [
"có gì đâu! 💜",
"rất vui được giúp 😸",
"chuyện nhỏ 🐾"
],
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
@@ -155,13 +175,41 @@
"không gọi được máy chủ 📡",
"mất sóng - đi ngủ chút 💤"
],
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
"worried": [
"có vẻ không ổn 😟",
"mình đang để mắt đây 👀",
"hừm, có gì đó lạ lạ 🫣"
],
"stuck": [
"một phiên im lặng lâu rồi… 🤔",
"có gì bị kẹt không? ⏳",
"vẫn đang nhai… 😾"
],
"happy": [
"tuyệt vời! 😻",
"kết thúc gọn ghê ✨",
"hoàn hảo 💜"
],
"thinking": [
"để mình xem… 🤔",
"đang đánh hơi… 🐾",
"chờ chút, đang kiểm tra 🔍"
],
"watching": [
"đang tuần tra 👀",
"đang canh các phiên của bạn 😼",
"mắt mở to 🐾"
],
"sleeping": [
"zzz… 💤",
"có gì thì gọi mình dậy nhé 😴",
"cuộn tròn, mọi thứ yên ổn 🐈"
],
"idle": [
"yên ắng quá 😺",
"sẵn sàng khi bạn cần 🐾",
"chill thôi ✨"
]
},
"chat": {
"label": "Trò chuyện với {{name}}",
@@ -179,6 +227,41 @@
"starting": "Đang khởi động {{tool}}…",
"notStarted": "Đang chờ phiên khởi động — nếu nó hỏi gì, hãy mở trong workspace.",
"notSent": "Không gửi được tới terminal của phiên — thử lại, hoặc mở trong workspace."
},
"people": {
"rail": "Trò chuyện",
"new": "Chat mới",
"newHint": "Chọn 1 người để chat riêng, hoặc nhiều người để tạo nhóm.",
"search": "Tìm người",
"none": "Không tìm thấy ai",
"groupName": "Tên nhóm (không bắt buộc)",
"createGroup": "Tạo nhóm ({{n}} người)",
"startDm": "Bắt đầu chat",
"close": "Đóng",
"unreadIn": "{{name}}, {{n}} tin chưa đọc",
"roomLabel": "Chat với {{name}}",
"members": "{{n}} thành viên",
"settings": "Cài đặt nhóm",
"back": "Quay lại cuộc chat",
"rename": "Đổi tên",
"creator": "· người tạo nhóm",
"remove": "Xoá {{name}} khỏi nhóm",
"add": "Thêm người",
"addN": "Thêm {{n}} người",
"leave": "Rời nhóm",
"leaveTitle": "Rời nhóm này?",
"leaveBody": "Bạn sẽ không còn thấy {{name}}. Người trong nhóm có thể thêm bạn lại.",
"older": "Xem tin cũ hơn",
"empty": "Chưa có tin nhắn. Hãy chào {{name}}.",
"placeholder": "Nhắn cho {{name}}",
"attach": "Đính kèm file",
"unattach": "Bỏ {{name}}",
"tooBig": "{{name}} lớn hơn 25 MB",
"sysCreated": "{{who}} đã tạo nhóm",
"sysRenamed": "{{who}} đã đổi tên nhóm",
"sysLeft": "{{who}} đã rời nhóm",
"sysAdded": "{{who}} đã thêm {{names}}",
"sysRemoved": "{{who}} đã xoá {{names}}"
}
},
"pricing": {
@@ -872,23 +955,35 @@
"authorEmail": "Email",
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
"signTitle": "Ký commit",
"signIntro": "Ký mọi commit — trong terminal, ở panel Thay đổi hay do agent tạo — bằng một SSH key của bạn, để host yêu cầu commit có chữ ký chấp nhận lần push của bạn.",
"signOff": "Commit chưa được ký.",
"signOn": "Mọi commit đang được ký bằng {{key}}.",
"signForeign": "Commit đang được ký bằng key bạn tự cấu hình: {{path}}.",
"signIntro": "Chọn workspace nào ký commit — từ terminal, bảng Changes hay agent — bằng một SSH key của bạn. Các repo khác không ký.",
"signLanes": "Workspace",
"signLanesEmpty": "Bạn chưa có workspace nào.",
"signNeedKey": "Hãy nhập key bạn có, hoặc tạo key ở phần dưới, để dùng ký.",
"signLaneGone": "Workspace #{{id}} (đã xóa)",
"signLaneKey": "Key ký cho {{name}}",
"signLaneSaved": "{{name}} giờ ký commit bằng {{key}}.",
"signLaneStopped": "{{name}} không còn ký commit.",
"signLegacy": "Mọi commit của tài khoản đang được ký bằng {{key}} (cài đặt cũ cho cả tài khoản).",
"signLegacyOff": "Chỉ ký theo workspace",
"signLegacyStopped": "Đã tắt ký cho cả tài khoản — mỗi workspace giữ lựa chọn riêng.",
"signForeign": "~/.gitconfig của bạn đang ký mọi commit bằng key bạn tự cài: {{path}}.",
"signForeignRemove": "Gỡ",
"signForeignConfirm": "Gỡ {{path}} khỏi ~/.gitconfig? Commit sẽ không còn ký bằng key này; file key không bị xóa.",
"signForeignRemoved": "Đã gỡ cấu hình ký cũ khỏi ~/.gitconfig.",
"signKeys": "Key dùng để ký",
"signKeyUsed_one": "ký {{count}} workspace",
"signKeyUsed_other": "ký {{count}} workspace",
"signKeyCopy": "Sao chép public key của {{key}}",
"signKeyDeleteConfirm": "Xóa key {{key}}? File khóa riêng sẽ bị xóa, các workspace đang ký bằng key này sẽ ngừng ký, và các bản clone dùng key này không fetch/push được nữa.",
"signKeyDeleted": "Đã xóa key {{key}}.",
"signKey": "Key dùng để ký",
"signNone": "Không ký",
"signHostStep": "Thêm public key này vào tài khoản trên host dưới dạng key KÝ (Gitea/GitHub: SSH / GPG Keys — trên Gitea bấm Verify), và giữ email commit ở trên trùng với email tài khoản trên host.",
"signReplaceConfirm": "Git của bạn đang ký bằng {{path}}. Chuyển sang {{key}}?",
"signReplace": "Chuyển",
"signSaved": "Commit giờ được ký bằng {{key}}.",
"signStopped": "Commit không còn được ký.",
"importKey": "Nhập key có sẵn",
"importTitle": "Nhập private key có sẵn",
"importFile": "Chọn file key",
"importPaste": "…hoặc dán private key (-----BEGIN … PRIVATE KEY-----)",
"importName": "Tên (không bắt buộc)",
"importSign": "Dùng key này để ký commit",
"importNote": "Key không được có passphrase (commit từ dashboard không có chỗ nhập). Key được lưu trong tài khoản của chính bạn; sau khi nhập chỉ public key được hiển thị.",
"importSubmit": "Nhập",
"imported": "Đã nhập key {{name}}.",
+16 -6
View File
@@ -6,7 +6,7 @@
*/
import { describe, it, expect } from "vitest";
import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
import { LINES_PER_REPORT, SCROLL_SPEED, wheelReports, type WheelState } from "../wheelReports";
const ROW = 20;
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
@@ -15,16 +15,17 @@ describe("wheelReports", () => {
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
const s: WheelState = { partial: 0 };
let sent = 0;
// 30 steps of 10px = 300px = 15 rows.
for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40);
expect(sent).toBe(15 / LINES_PER_REPORT);
// 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED.
for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40);
expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / LINES_PER_REPORT));
});
it("drops the carried remainder when the swipe turns around", () => {
const s: WheelState = { partial: 0 };
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
let sent = 0;
for (let i = 0; i < 5; i++) sent += wheelReports(s, px(-20), ROW, 40); // 5 rows up
// 8 rows up = 5.6 scrolled: one report, the 2 rows down forgotten.
for (let i = 0; i < 8; i++) sent += wheelReports(s, px(-20), ROW, 40);
expect(sent).toBe(-1);
});
@@ -32,7 +33,16 @@ describe("wheelReports", () => {
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(8);
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(6); // 8 × 0.7
});
it("slows a run of wheel notches to SCROLL_SPEED of a report each", () => {
const s: WheelState = { partial: 0 };
let sent = 0;
for (let i = 0; i < 10; i++) sent += wheelReports(s, px(100), ROW, 40);
expect(sent).toBe(10 * SCROLL_SPEED);
// Turning around starts from zero: the first notch up moves at once.
expect(wheelReports(s, px(-100), ROW, 40)).toBe(-1);
});
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
+102 -3
View File
@@ -547,6 +547,38 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** Someone to chat with (`GET /api/chat/people`). */
export type ChatPerson = {
id: number;
username: string;
displayName: string | null;
avatarAt: string | null;
};
/** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */
export type ChatFile = { id: string; name: string; type: string; size: number };
/** One chat message; `system` bodies: created, renamed, left, added:<ids>, removed:<ids>. */
export type ChatMessage = {
id: number;
roomId: number;
userId: number | null;
kind: "text" | "system";
body: string;
createdAt: string;
files: ChatFile[];
};
/** A direct chat or a group, as the caller sees it. */
export type ChatRoom = {
id: number;
kind: "dm" | "group";
name: string;
createdBy: number | null;
createdAt: string;
lastMessageAt: string | null;
members: number[];
lastMessage: ChatMessage | null;
lastReadId: number;
unread: number;
};
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
disabled: boolean;
@@ -1567,6 +1599,55 @@ export const api = {
request<HostHistory>(`/host/history?range=${range}`),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
/** `/api/chat` — chat between users in server mode (the pet's window). */
chat: {
people: () => request<{ people: ChatPerson[] }>("/chat/people"),
rooms: () => request<{ rooms: ChatRoom[] }>("/chat/rooms"),
/** The one direct chat with `userId`, made on first use. */
openDm: (userId: number) =>
request<{ room: ChatRoom }>("/chat/rooms", {
method: "POST",
body: JSON.stringify({ kind: "dm", userId }),
}),
createGroup: (name: string, userIds: number[]) =>
request<{ room: ChatRoom }>("/chat/rooms", {
method: "POST",
body: JSON.stringify({ kind: "group", name, userIds }),
}),
/** The group's creator only. */
updateGroup: (id: number, patch: { name?: string; add?: number[]; remove?: number[] }) =>
request<{ room: ChatRoom }>(`/chat/rooms/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
leave: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}/leave`, { method: "POST" }),
messages: (id: number, before?: number) =>
request<{ messages: ChatMessage[]; hasMore: boolean }>(
`/chat/rooms/${id}/messages${before ? `?before=${before}` : ""}`
),
/** Text and files (multipart when there are files). */
post: (id: number, body: string, files: File[] = []) => {
if (!files.length)
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
const form = new FormData();
form.append("body", body);
for (const f of files) form.append("files", f, f.name);
return request<{ message: ChatMessage }>(`/chat/rooms/${id}/messages`, {
method: "POST",
body: form,
});
},
read: (id: number, upTo: number) =>
request<{ lastReadId: number }>(`/chat/rooms/${id}/read`, {
method: "POST",
body: JSON.stringify({ upTo }),
}),
fileUrl: (roomId: number, fileId: string) =>
`${BASE}/chat/rooms/${roomId}/files/${encodeURIComponent(fileId)}`,
},
users: {
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
@@ -1858,6 +1939,8 @@ export const api = {
archived?: boolean;
/** Pinned sessions first (by pin time), before the limit. */
pinnedFirst?: boolean;
/** Server mode: the caller's OWN sessions, even for an admin/analyst (`mine=1`). */
mine?: boolean;
/** Only active sessions waiting on the user (`awaiting=1`). */
awaiting?: boolean;
/** ISO bounds on `started_at`. */
@@ -1878,6 +1961,7 @@ export const api = {
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
if (params?.pinnedFirst) qs.set("pinned_first", "1");
if (params?.awaiting) qs.set("awaiting", "1");
if (params?.mine) qs.set("mine", "1");
if (params?.from) qs.set("from", params.from);
if (params?.to) qs.set("to", params.to);
if (params?.sort_by) qs.set("sort_by", params.sort_by);
@@ -3528,11 +3612,21 @@ export const api = {
"/me/git/ssh-key/import",
{ method: "POST", body: JSON.stringify(body) }
),
/** PUT /api/me/git/signing — sign commits with key `key` (its id), or stop (null). */
setSigning: (key: string | null, replace = false) =>
/**
* PUT /api/me/git/signing — the account-wide setting: sign every commit
* with key `key` (its id), or stop (null); `clearForeign` also takes out a
* signing setup the user made themselves (their key file stays).
*/
setSigning: (key: string | null, replace = false, clearForeign = false) =>
request<GitIdentity>("/me/git/signing", {
method: "PUT",
body: JSON.stringify({ key, replace }),
body: JSON.stringify(clearForeign ? { key, replace, clearForeign } : { key, replace }),
}),
/** PUT /api/me/git/signing/lane — sign ONE workspace's commits with `key`, or stop (null). */
setLaneSigning: (laneId: number, key: string | null) =>
request<GitIdentity>("/me/git/signing/lane", {
method: "PUT",
body: JSON.stringify({ laneId, key }),
}),
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
setTerminalSsh: (on: boolean) =>
@@ -5450,6 +5544,11 @@ export interface GitIdentity {
* from an older server.
*/
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
/**
* The workspaces whose commits are signed (an `includeIf "gitdir:…"` each):
* `key` = the key's id, null when its file is gone. Absent from an older server.
*/
laneSigning?: { laneId: number; key: string | null }[];
/** The terminal switch (shared by both keys); null without a key. */
sshTerminal?: "on" | "off" | "foreign" | null;
/**
+11 -2
View File
@@ -1917,10 +1917,13 @@ export interface WSMessage {
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
* remote_source.status → RemoteSourceStatusPayload;
* remote_data.updated → RemoteDataUpdatedPayload;
* lane_update → { lane?: Lane; removed?: number }. */
* lane_update → { lane?: Lane; removed?: number };
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to }. */
type:
| "session_created"
| "session_updated"
| "chat_message"
| "chat_room"
| "agent_created"
| "agent_updated"
| "new_event"
@@ -1967,10 +1970,16 @@ export interface WSMessage {
| { lane?: Lane; removed?: number }
| LaneHookOutputPayload
| LaneRuntimePayload
| LaneHookResultPayload;
| LaneHookResultPayload
/** chat_message / chat_room (lib/api.ts ChatMessage). */
| { roomId: number; message?: unknown; to?: number[] };
/** ISO timestamp the server broadcast this message (not necessarily the
* same instant the underlying event occurred). */
timestamp: string;
/** Server mode, an admin/analyst's session/agent/event frames only: whether
* the session is their OWN (they also get every other user's). Absent for a
* developer (who only ever gets their own) and in local mode. */
mine?: boolean;
}
// ───── Session stats ─────
+20 -3
View File
@@ -8,13 +8,19 @@
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
* finger, and a wheel notch always sends at least one.
* finger, and a wheel notch always sends at least one. `SCROLL_SPEED` then
* slows both down (the user asked for 30 % less): a touchpad's travel is
* scaled, and a notch's reports are carried as a fraction, rounded, so the
* first notch still moves and the average comes out at that share.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
export const LINES_PER_REPORT = 5;
/** Share of the travel a pane scrolls (0.7 = 30 % slower than 1:1). */
export const SCROLL_SPEED = 0.7;
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
const NOTCH_PX = 50;
@@ -25,6 +31,10 @@ const DOM_DELTA_PAGE = 2;
export interface WheelState {
/** Touchpad travel not yet sent, in rows (signed). */
partial: number;
/** Wheel-notch reports owed but not sent yet (signed, a fraction). */
notch?: number;
/** Direction of the last notch (the carry above may round to either sign). */
notchDir?: number;
}
/**
@@ -42,18 +52,25 @@ export function wheelReports(
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
// Touchpad: carry the remainder, so slow swipes still add up.
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
state.partial += ev.deltaY / rowPx;
state.partial += (ev.deltaY / rowPx) * SCROLL_SPEED;
const n = Math.trunc(state.partial / LINES_PER_REPORT);
state.partial -= n * LINES_PER_REPORT;
return n;
}
// A notch (or a fast flick): at least one report, more for a long one.
state.partial = 0;
if (state.notchDir !== sign) state.notch = 0; // turned around
state.notchDir = sign;
const lines =
ev.deltaMode === DOM_DELTA_LINE
? ev.deltaY
: ev.deltaMode === DOM_DELTA_PAGE
? ev.deltaY * rows
: ev.deltaY / rowPx;
return sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT));
const owed =
(state.notch ?? 0) +
sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT)) * SCROLL_SPEED;
const n = sign * Math.round(Math.abs(owed));
state.notch = owed - n;
return n;
}
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.62 (rebuild client to match)
UI build v2.8.65 (rebuild client to match)
</span>
</p>
</div>
+23 -1
View File
@@ -1563,7 +1563,8 @@ GET /api/me/git { account: "member"|"host", writable, ssh: {
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
POST /api/me/git/ssh-key/import Import a private key the user already has — Body: { privateKey, name?, sign?, replace? } — OpenSSH (`BEGIN OPENSSH PRIVATE KEY`) or PEM (PKCS#1/PKCS#8) text, ed25519 or RSA ≥ 2048; saved as a NAMED key (`name`, else `signing`, `signing-2`…) written like a generated one, so it serves clones too; only `imported` (its public half) comes back. 400 EBADKEY (not a key, a `.pub`, halves that do not match), EKEYPASSPHRASE (an encrypted key — a dashboard commit has nowhere to type the passphrase), EBADKEYTYPE; 409 EEXISTS (the same key already imported, or the name taken), ETOOMANYKEYS. With `sign: true` commits are signed with it at once; a refusal of that step comes back as `signError` (the key stays saved) → 201
PUT /api/me/git/signing Sign commits — Body: { key: <key id> | null, replace? } — writes `gpg.format ssh`, `user.signingkey <the key's PRIVATE file>` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign)
PUT /api/me/git/signing Sign commits — Body: { key: <key id> | null, replace? } — writes `gpg.format ssh`, `user.signingkey <the key's PRIVATE file>` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign) — the old ACCOUNT-WIDE setting; `clearForeign: true` (with `key: null`) also takes out a signing setup the user made themselves, never their key file
PUT /api/me/git/signing/lane Sign ONE workspace's commits — Body: { laneId, key: <key id> | null } — a file of ours `lane-<id>.gitconfig` (gpg.format ssh, user.signingkey, commit.gpgsign) included from `~/.gitconfig` by `includeIf "gitdir:<the workspace's git dir>"` and `"gitdir:<common dir>/worktrees/ah-<id>-*"` (its sessions' worktrees), so only that workspace signs — terminal, Changes panel and agents alike — and nothing is written into the repository. 404 for a workspace the caller cannot see, 403 EFOREIGN when its folder runs as another account, 409 ENOREPO when it is not a git repository. `GET` answers `laneSigning: [{ laneId, key }]`; deleting a key stops every workspace signing with it. Two workspaces on one folder share it; a workspace in a SUBFOLDER of a repo signs the whole repo
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key?, allRegions? } — `allRegions: true` (a CodeCommit host, no alias — else 400 EBADHOST) writes the rule for EVERY CodeCommit region at once (the list is `codecommitRegions` in `GET /api/me/git`), replacing each region's own; `DELETE /api/me/git/ssh-user/<a CodeCommit host>?allRegions=1&user=<key ID>` removes them all. Otherwise it writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit.<region>.amazonaws.com/…` tries every CodeCommit login saved — the region's own first, then other regions' and every named key's (another AWS account), each with its own key: the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. So a second account's key clones from a plain AWS URL too, with no `ssh://<alias>/` rewrite. 400 EBADHOST / EBADSSHUSER
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
@@ -2175,6 +2176,7 @@ Returns all sessions, ordered by most recent activity.
| `offset` | integer | 0 | Pagination offset |
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a `status` value — use `awaiting=1` |
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
| `mine` | `1` | - | Server mode: only the caller's OWN sessions (workspaces they own, or their Linux account's), even for an admin or analyst. The pet seeds its count from it. No-op in local mode |
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
| `cwd` | string | - | Exact working directory |
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
@@ -3495,6 +3497,24 @@ 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}]} — every enabled account but you
GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, 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)
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
POST /api/chat/rooms/:id/read {upTo} — read mark, never moves back
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. 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}`) or `chat_room` (`{roomId, to}`) to exactly the user ids in `to` (`scope.exactAudience`), whatever their role.
### Session snapshots
```
@@ -3717,6 +3737,8 @@ stateDiagram-v2
Server broadcasts JSON messages to all connected clients:
Server mode: a developer gets only the frames of their own sessions and workspaces. An admin or analyst gets every user's, and each `session_created`, `session_updated`, `agent_created`, `agent_updated` and `new_event` frame they receive also carries `"mine": true|false`, which says whether the session is their own (the rule of `GET /api/sessions?mine=1`). The pet counts only frames with `mine` absent or true. Local mode never adds `mine`.
#### session.created
Sent when a new session is created.
+3 -1
View File
@@ -188,7 +188,9 @@ Workspaces made before server mode, or by the host account, have none.
load, memory and swap, every real disk (HDD or SSD, how full; the one
holding the users' homes is marked), any NVIDIA GPU. Each user's live
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
user, their busiest processes on click; their home share stays in
user, their busiest processes on click; on hover, for an
admin or analyst — from `GET /api/users` — their role, $ spent today,
workspaces, sessions and last sign-in; their home share stays in
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
resident memory over a 1 s `/proc` sample, a process count, the busiest
processes by their kernel name (never argv or env), and their home's size
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.62",
"version": "2.8.65",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.62",
"version": "2.8.65",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.62",
"version": "2.8.65",
"bin": {
"agenthub": "bin/agenthub.js"
},
+172
View File
@@ -0,0 +1,172 @@
/**
* @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, 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"]);
});
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("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", {})], []);
});
});
+129 -1
View File
@@ -5,7 +5,11 @@
* the same public half `ssh-keygen -y` prints, only that half returned, the
* private file 0600; a passphrase key, a `.pub`, an ecdsa key and the same key
* twice refused; a real commit signed and verified by git; a signing key the
* user set themselves kept unless replaced; deleting the key stops signing.
* user set themselves kept unless replaced, and taken out (its file kept) on
* `clearForeign`; deleting the key stops signing. Per workspace
* (`PUT /api/me/git/signing/lane`): only that workspace's commits — and its
* sessions' `ah-<id>-*` worktrees — are signed, never another repo's, the
* user's own includes stay, and deleting the key stops that workspace's too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -204,3 +208,127 @@ describe("PUT /api/me/git/signing", () => {
await removeAll();
});
});
describe("PUT /api/me/git/signing/lane", () => {
const env = {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "me@x",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "me@x",
};
const commit = (dir, msg) =>
execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", msg], { env });
/** `G` signed and verified, `N` not signed. */
const sig = (dir, allowed) =>
execFileSync(
"git",
["-C", dir, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"],
{ encoding: "utf8" }
).trim();
const lane = async (dir) => {
const r = await request("POST", "/api/lanes/ensure", { cwd: dir, exact: true });
return r.body.lane.id;
};
it("signs one workspace and its sessions' worktrees, never another repo", async () => {
const A = path.join(ROOT, "ws-a");
const B = path.join(ROOT, "ws-b");
const plain = path.join(ROOT, "ws-plain");
for (const d of [A, B]) {
execFileSync("git", ["init", "-q", d]);
commit(d, "first");
}
fs.mkdirSync(plain);
const [a, b, p] = [await lane(A), await lane(B), await lane(plain)];
// A user's own include stays whatever we do.
execFileSync("git", ["config", "--global", "includeIf.gitdir:/elsewhere/.path", "/mine"]);
const k = keygen("lane", ["-t", "ed25519"]);
const imp = await request("POST", "/api/me/git/ssh-key/import", {
privateKey: k.text,
name: "lane",
});
const key = imp.body.imported.id;
const allowed = path.join(ROOT, "allowed-lane");
fs.writeFileSync(allowed, `me@x ${k.pub}\n`);
const on = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
assert.equal(on.status, 200);
assert.deepEqual(on.body.laneSigning, [{ laneId: a, key }]);
// Nothing account-wide: the global settings stay unset.
assert.equal(on.body.signing.state, "off");
assert.equal(gitGet("commit.gpgsign"), "");
commit(A, "in a");
commit(B, "in b");
assert.equal(sig(A, allowed), "G");
assert.equal(sig(B, allowed), "N");
// A session's worktree of A is signed; any other worktree of A is not.
const wt = path.join(A, ".claude", "worktrees", `ah-${a}-x1`);
const other = path.join(ROOT, "other-wt");
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s1", wt]);
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s2", other]);
commit(wt, "in session worktree");
commit(other, "in other worktree");
assert.equal(sig(wt, allowed), "G");
assert.equal(sig(other, allowed), "N");
// Nothing was written into the repository itself.
assert.doesNotMatch(fs.readFileSync(path.join(A, ".git", "config"), "utf8"), /gpgsign/);
// Not a repo: refused; an unknown workspace: 404.
const notRepo = await request("PUT", "/api/me/git/signing/lane", { laneId: p, key });
assert.equal(notRepo.status, 409);
assert.equal(notRepo.body.error.code, "ENOREPO");
const gone = await request("PUT", "/api/me/git/signing/lane", { laneId: 9999, key });
assert.equal(gone.status, 404);
// Off: unsigned again, our file gone, the user's include kept.
const off = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key: null });
assert.deepEqual(off.body.laneSigning, []);
commit(A, "after off");
assert.equal(sig(A, allowed), "N");
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
// A workspace removed from the dashboard while it signed can still be turned off.
await request("PUT", "/api/me/git/signing/lane", { laneId: b, key });
await request("POST", `/api/lanes/${b}/remove`, {});
const orphan = await request("GET", "/api/me/git");
assert.deepEqual(orphan.body.laneSigning, [{ laneId: b, key }]);
const stopGone = await request("PUT", "/api/me/git/signing/lane", { laneId: b, key: null });
assert.equal(stopGone.status, 200);
assert.deepEqual(stopGone.body.laneSigning, []);
const b2 = await lane(B);
// Deleting the key stops every workspace that signs with it.
await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
await request("PUT", "/api/me/git/signing/lane", { laneId: b2, key });
const del = await request("DELETE", `/api/me/git/ssh-key?id=${key}`);
assert.equal(del.status, 200);
assert.deepEqual(del.body.laneSigning, []);
commit(A, "after the key went");
assert.equal(sig(A, allowed), "N");
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
assert.deepEqual(
fs
.readdirSync(path.join(ROOT, ".claude", "agenthub-signing"))
.filter((n) => n.endsWith(".gitconfig")),
[]
);
execFileSync("git", ["config", "--global", "--unset-all", "includeIf.gitdir:/elsewhere/.path"]);
await removeAll();
});
it("takes out a signing setup the user made themselves, keeping their key file", async () => {
const mine = path.join(ROOT, "my-own-key");
fs.writeFileSync(mine, "not ours");
execFileSync("git", ["config", "--global", "user.signingkey", mine]);
execFileSync("git", ["config", "--global", "commit.gpgsign", "true"]);
const kept = await request("PUT", "/api/me/git/signing", { key: null });
assert.equal(kept.body.signing.state, "foreign");
const r = await request("PUT", "/api/me/git/signing", { key: null, clearForeign: true });
assert.equal(r.status, 200);
assert.equal(r.body.signing.state, "off");
assert.equal(gitGet("user.signingkey"), "");
assert.equal(gitGet("commit.gpgsign"), "");
assert.equal(fs.readFileSync(mine, "utf8"), "not ours");
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* @file websocket-mine.test.js
* @description Server mode broadcasts (server/websocket.js `broadcast`): an
* admin/analyst's session, agent and event frames say whether the session is
* their OWN (`mine`), a developer's and local mode's never carry it, and
* `GET /api/sessions?mine=1` (server/routes/sessions.js) lists only the
* caller's own sessions whatever their role — the pet counts only those.
* @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(), "ws-mine-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const ws = require("../websocket");
const prevMode = process.env.AGENTHUB_MODE;
before(() => {
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lane.run("/w/boss", 99); // id 1, the admin's
lane.run("/w/dev", 2); // id 2, a developer's
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
);
s.run("s-boss", "boss", 1);
s.run("s-dev", "dev", 2);
});
after(() => {
ws._setClientsForTest(null);
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
else process.env.AGENTHUB_MODE = prevMode;
fs.rmSync(TMP, { recursive: true, force: true });
});
function client(user) {
const got = [];
return { readyState: 1, user, got, send: (m) => got.push(JSON.parse(m)) };
}
describe("broadcast `mine`", () => {
it("marks an admin's frames by whose session it is; a developer gets only theirs, unmarked", () => {
process.env.AGENTHUB_MODE = "server";
const boss = client({ id: 99, username: "boss", role: "admin" });
const dev = client({ id: 2, username: "dev", role: "developer" });
ws._setClientsForTest(new Set([boss, dev]));
ws.broadcast("session_updated", { id: "s-boss", status: "active" });
ws.broadcast("new_event", { session_id: "s-dev", event_type: "PostToolUse" });
ws.broadcast("lane_update", { lane: { id: 2 } });
assert.deepEqual(
boss.got.map((m) => [m.type, m.mine]),
[
["session_updated", true],
["new_event", false],
["lane_update", undefined],
]
);
assert.deepEqual(
dev.got.map((m) => [m.type, m.mine]),
[
["new_event", undefined],
["lane_update", undefined],
]
);
});
it("adds nothing in local mode", () => {
delete process.env.AGENTHUB_MODE;
const me = client(null);
ws._setClientsForTest(new Set([me]));
ws.broadcast("session_updated", { id: "s-dev" });
assert.equal(me.got[0].mine, undefined);
});
});
describe("GET /api/sessions?mine=1", () => {
it("lists only the caller's own sessions, an admin's too", async () => {
const express = require("express");
const app = express();
app.use((req, _res, next) => {
req.user = { id: 99, username: "boss", role: "admin" };
next();
});
app.use("/api/sessions", require("../routes/sessions"));
const srv = app.listen(0);
try {
const base = `http://127.0.0.1:${srv.address().port}/api/sessions`;
const ids = async (qs) =>
(await (await fetch(base + qs)).json()).sessions.map((s) => s.id).sort();
assert.deepEqual(await ids("?mine=1"), ["s-boss"]);
assert.deepEqual(await ids(""), ["s-boss", "s-dev"]);
} finally {
srv.close();
}
});
});
+41
View File
@@ -1912,6 +1912,47 @@ 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);
`);
// 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,6 +237,7 @@ 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);
+378
View File
@@ -0,0 +1,378 @@
/**
* @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. 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 enabled account but the caller — who a chat can be started with. */
function people(me) {
return sql(
`SELECT id, username, display_name, avatar_at FROM users
WHERE COALESCE(disabled, 0) = 0 AND 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,
}));
}
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 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) || [],
}));
}
/** One room as `me` sees it: members, newest message, unread count. */
function shapeRoom(me, r) {
const mine = sql("SELECT last_read_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
r.id,
me
);
const last = sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT 1").get(
r.id
);
const unread = sql(
"SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND COALESCE(user_id, -1) != ?"
).get(r.id, mine ? mine.last_read_id : 0, 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,
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 = ? 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) 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,
};
}
function systemLine(roomId, me, body) {
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);
}
/** 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));
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);
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) systemLine(room.id, me, `added:${adds.join(",")}`);
if (drops.length) systemLine(room.id, me, `removed:${drops.join(",")}`);
})();
return {
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id)),
notify: [...new Set([...before, ...adds])],
};
}
/** `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);
db().transaction(() => {
sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?").run(room.id, me);
if (before.length > 1) systemLine(room.id, me, "left");
else sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
})();
if (before.length <= 1) fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return { notify: before };
}
/** 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 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 < ? ORDER BY id DESC LIMIT ?").all(
room.id,
b,
n
)
: sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT ?").all(room.id, 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);
for (const f of files)
if (f.buffer.length > 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),
buffer: f.buffer,
}));
if (stored.length) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
for (const f of stored) fs.writeFileSync(path.join(dir, f.id), 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.buffer.length);
sql(
"UPDATE chat_rooms SET last_message_at = (SELECT created_at FROM chat_messages WHERE id = ?) WHERE id = ?"
).run(lastInsertRowid, 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");
sql(
"UPDATE chat_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?"
).run(n, room.id, me);
return { lastReadId: Math.max(n, 0) };
}
/** 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 * FROM chat_files WHERE id = ? AND room_id = ?").get(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) };
}
module.exports = {
MAX_FILES,
MAX_FILE_BYTES,
people,
listRooms,
openDm,
createGroup,
updateGroup,
leaveGroup,
messages,
post,
markRead,
file,
roomFor,
shapeRoom,
};
+146 -3
View File
@@ -574,7 +574,7 @@ async function signingState(account) {
* (EFOREIGNSIGN); off removes only our own settings. No value is a secret —
* the key's PATH crosses sudo's argv, never its content.
*/
async function setSigning(account, { id = null, replace = false } = {}) {
async function setSigning(account, { id = null, replace = false, clearForeign = false } = {}) {
const { git } = require("./worktree");
const home = homeOf(account);
const state = await signingState(account);
@@ -583,7 +583,9 @@ async function setSigning(account, { id = null, replace = false } = {}) {
/* already unset */
});
if (id == null) {
if (state.state === "foreign") return state;
// The user's own setup is left alone unless they asked to take it out
// (`clearForeign`): only the three settings go, never their key file.
if (state.state === "foreign" && !clearForeign) return state;
if ((await globalGet(account, "user.signingkey")) !== "") {
await unset("commit.gpgsign");
await unset("user.signingkey");
@@ -605,6 +607,138 @@ async function setSigning(account, { id = null, replace = false } = {}) {
return signingState(account);
}
// ── Commit signing per workspace (`includeIf "gitdir:…"`) ──────────────────
//
// A workspace is signed by a small config file of OURS per workspace
// (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign),
// pulled into the account's `~/.gitconfig` by `includeIf "gitdir:<dir>"` for
// the workspace's own git dir and for the git dirs of the worktrees its
// sessions make (`ah-<id>-*`, pty-run.js). Every git that reads the account's
// config — the terminal, the Changes panel, an agent — then signs there and
// nowhere else; nothing is written into the user's repository.
const LANE_SIGN_RE = /^lane-(\d+)\.gitconfig$/;
/** Where the per-workspace files live: ours, beside the account's keys. */
function signingDir(account) {
return account.kind === "member"
? path.join(paths(account).base, "signing")
: path.join(getClaudeHome(), "agenthub-signing");
}
const laneSignFile = (account, laneId) =>
path.join(signingDir(account), `lane-${Number(laneId)}.gitconfig`);
/** `[{key, value}]` of the `includeIf.*.path` lines naming one of OUR files. */
async function ourIncludes(account) {
const { git } = require("./worktree");
const out = await git(homeOf(account), [
"config",
"--global",
"--null",
"--get-regexp",
"^includeif\\..*\\.path$",
]).then(
(r) => r.stdout,
() => ""
);
const dir = signingDir(account);
const rows = [];
// `--null`: "key\nvalue\0" — a git dir or a path may hold spaces.
for (const rec of out.split("\0")) {
const at = rec.indexOf("\n");
if (at <= 0) continue;
const key = rec.slice(0, at);
const value = rec.slice(at + 1);
if (path.dirname(value) === dir && LANE_SIGN_RE.test(path.basename(value))) {
rows.push({ key, value });
}
}
return rows;
}
/** Take every include of `file` out of `~/.gitconfig` (the user's own includes stay). */
async function dropIncludes(account, file) {
const { git } = require("./worktree");
for (const r of await ourIncludes(account)) {
if (r.value !== file) continue;
await git(homeOf(account), [
"config",
"--global",
"--fixed-value",
"--unset-all",
r.key,
r.value,
]).catch(() => {});
}
}
/** The key file a workspace file signs with (its `signingkey` line), or null. */
function laneSignKeyFile(account, file) {
const text = readText(account, file) || "";
const m = /^\s*signingkey\s*=\s*"?(.*?)"?\s*$/m.exec(text);
return m ? m[1].replace(/\\(["\\])/g, "$1") : null;
}
/**
* Which workspaces sign their commits, and with which key:
* `[{laneId, key}]` (`key` null when the key file it names is gone).
*/
async function laneSigningList(account) {
const keys = currentKeys(account);
const seen = new Map();
for (const r of await ourIncludes(account)) {
const laneId = Number(LANE_SIGN_RE.exec(path.basename(r.value))[1]);
if (seen.has(laneId)) continue;
const keyFile = laneSignKeyFile(account, r.value);
seen.set(laneId, keys.find((k) => k.file === keyFile)?.id ?? null);
}
return [...seen].map(([laneId, key]) => ({ laneId, key })).sort((a, b) => a.laneId - b.laneId);
}
const gitQuote = (v) => `"${String(v).replace(/(["\\])/g, "\\$1")}"`;
/**
* Sign the commits of workspace `laneId` (folder `cwd`) with key `id`, or stop
* (`id` null). The git dirs come from git itself, run in the workspace's
* folder as its account — never from the request. ENOREPO when the folder is
* not a git repository.
*/
async function setLaneSigning(account, { laneId, cwd, id = null }) {
const n = Number(laneId);
if (!Number.isSafeInteger(n) || n <= 0) throw err("ENOTFOUND", "no such workspace");
const file = laneSignFile(account, n);
await dropIncludes(account, file);
if (id == null) {
removeFile(account, file);
return;
}
const k = currentKeys(account).find((x) => x.id === id);
if (!k) throw err("ENOKEY", "no such SSH key");
const { git } = require("./worktree");
const rev = (args) => git(cwd, ["rev-parse", ...args]).then((r) => r.stdout.trim());
let gitDir;
let commonDir;
try {
gitDir = await rev(["--absolute-git-dir"]);
commonDir = await rev(["--path-format=absolute", "--git-common-dir"]);
} catch {
throw err("ENOREPO", "this workspace's folder is not a git repository");
}
writeText(
account,
file,
`# Written by AgentHub: commit signing for workspace ${n} (Settings → Git).\n` +
`[gpg]\n\tformat = ssh\n[user]\n\tsigningkey = ${gitQuote(k.file)}\n` +
`[commit]\n\tgpgsign = true\n[tag]\n\tgpgsign = false\n`,
{ secret: false }
);
const add = (pattern) =>
git(homeOf(account), ["config", "--global", "--add", `includeIf.gitdir:${pattern}.path`, file]);
await add(gitDir);
// The worktrees this workspace's sessions make (`claude --worktree=ah-<id>-…`).
await add(path.join(commonDir, "worktrees", `ah-${n}-*`));
}
/**
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
* neither is given; false when there was none. A login naming a deleted key
@@ -619,11 +753,18 @@ async function removeSshKey(account, which) {
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
);
if (!gone.length) return false;
// Commits signed with a key about to go would all fail: stop signing first.
// Commits signed with a key about to go would all fail: stop signing first —
// account-wide, and in every workspace that signs with it.
const signingKey = await globalGet(account, "user.signingkey");
if (signingKey && gone.some((k) => k.file === signingKey)) {
await setSigning(account, { id: null }).catch(() => {});
}
const goneIds = new Set(gone.map((k) => k.id));
for (const l of await laneSigningList(account)) {
if (goneIds.has(l.key)) {
await setLaneSigning(account, { laneId: l.laneId, id: null }).catch(() => {});
}
}
for (const k of gone) {
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
@@ -1425,6 +1566,8 @@ module.exports = {
importSshKey,
parsePrivateKey,
signingState,
laneSigningList,
setLaneSigning,
setSigning,
removeSshKey,
terminalSshCommand,
+7 -1
View File
@@ -340,10 +340,16 @@ 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.
* else, an admin or analyst included. A chat frame (`chat_message`,
* `chat_room`) reaches exactly the users in its `to`.
*/
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 (type === "chat_message" || type === "chat_room") {
return new Set(Array.isArray(d.to) ? d.to : []);
}
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
return null;
}
+2 -1
View File
@@ -76,7 +76,8 @@ module.exports = combine(
overrideCore,
overridePricingAlerts,
overrideOps,
gitRefs
gitRefs,
require("./openapi-extra/chat")
);
// The branch & tag writes join the git action entry in place.
gitRefs.extend(module.exports);
+278
View File
@@ -0,0 +1,278 @@
/**
* @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" },
unread: { type: "integer", description: "Messages of others after `lastReadId`." },
},
},
};
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 enabled account but the caller: id, username, display name, avatar time.",
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 },
},
},
},
},
}),
},
...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}": {
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}/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}/read": {
post: {
tags: ["Chat"],
summary: "Mark a room read up to a message",
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 };
+27 -1
View File
@@ -594,7 +594,13 @@ const paths = {
summary:
"Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)",
operationId: "setCommitSigning",
requestBody: json({ key: { type: "string", nullable: true }, replace: { type: "boolean" } }),
description:
"The account-wide setting (every repository of the account). `clearForeign: true` with `key: null` also takes out a signing setup the user made themselves — the three settings only, never their key file. Per workspace: `PUT /api/me/git/signing/lane`.",
requestBody: json({
key: { type: "string", nullable: true },
replace: { type: "boolean" },
clearForeign: { type: "boolean" },
}),
responses: {
200: { description: "The identity; `signing.state` is on, off or foreign." },
400: { description: "ENOKEY" },
@@ -602,6 +608,26 @@ const paths = {
},
},
},
"/api/me/git/signing/lane": {
put: {
tags: ["Git identity"],
summary:
"Sign ONE workspace's commits with one of the account's keys (`key`: its id), or stop (`key: null`)",
description:
"Writes a small file of AgentHub's (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign) and includes it from the account's `~/.gitconfig` with `includeIf \"gitdir:<the workspace's git dir>\"` and `gitdir:<common dir>/worktrees/ah-<id>-*` (its sessions' worktrees) — nothing is written into the repository. The workspace must be one the caller may see whose folder runs as this same account. `GET /api/me/git` answers `laneSigning: [{laneId, key}]`; deleting a key stops every workspace that signs with it.",
operationId: "setLaneCommitSigning",
requestBody: json({ laneId: { type: "integer" }, key: { type: "string", nullable: true } }, [
"laneId",
]),
responses: {
200: { description: "The identity, with `laneSigning`." },
400: { description: "ENOKEY" },
403: { description: "EFOREIGN — the workspace runs as another account; EHOSTIDENTITY." },
404: { description: "ENOTFOUND — no such workspace (or not the caller's)." },
409: { description: "ENOREPO — the workspace's folder is not a git repository." },
},
},
},
"/api/me/git/ssh-terminal": {
put: {
tags: ["Git identity"],
@@ -154,6 +154,14 @@ const paths = {
"`1` = only sessions blocked on the user (`awaiting_input_since IS NOT NULL AND status = 'active'`), the UI's Waiting filter. Applied before `limit`/`offset`, so `total` stays exact.",
example: "1",
},
{
name: "mine",
in: "query",
schema: { type: "string", enum: ["1"] },
description:
"Server mode: `1` = only the caller's OWN sessions, even for an admin or analyst (the pet's count). No-op in local mode.",
example: "1",
},
{
name: "from",
in: "query",
+164
View File
@@ -0,0 +1,164 @@
/**
* @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` 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`, 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.memoryStorage(),
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 } = chat.updateGroup(me, req.params.id, {
name: b.name,
add: b.add,
remove: b.remove,
});
tell("chat_room", { roomId: room.id }, notify);
res.json({ room });
})
);
router.post(
"/rooms/:id/leave",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.leaveGroup(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,
buffer: f.buffer,
}));
const { message, notify } = chat.post(me, req.params.id, { body: req.body?.body, files });
tell("chat_message", { roomId: message.roomId, message }, notify);
res.status(201).json({ message });
})(req, res)
);
router.post(
"/rooms/:id/read",
sameOriginGuard,
handle((me, req, res) => res.json(chat.markRead(me, req.params.id, req.body?.upTo)))
);
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;
+51 -2
View File
@@ -14,7 +14,10 @@
* public half ever comes back), and whether commits are signed with one of
* the keys (`PUT /signing`: `gpg.format ssh` + `user.signingkey` +
* `commit.gpgsign` in the account's `~/.gitconfig`; a signing key the user
* set themselves is replaced only on `replace: true`). The account is the signed-in user's
* set themselves is replaced only on `replace: true`, or taken out on
* `clearForeign: true` — their key file is never touched), or — per workspace —
* which of the caller's workspaces sign (`PUT /signing/lane`, an
* `includeIf "gitdir:…"` of one small file of ours each). The account is the signed-in user's
* own Linux account under S3, else the host's, which only an admin (or local
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
* request, and every write passes the same-origin guard.
@@ -47,6 +50,9 @@ const STATUS = {
EBADKEY: 400,
EKEYPASSPHRASE: 400,
EFOREIGNSIGN: 409,
ENOREPO: 409,
ENOTFOUND: 404,
EFOREIGN: 403,
};
function fail(res, e) {
@@ -70,6 +76,8 @@ async function view(account, username) {
codecommitRegions: identity.CODECOMMIT_REGIONS,
https: identity.listHttps(account),
signing: await identity.signingState(account),
// Workspaces whose commits are signed: `[{laneId, key}]`.
laneSigning: await identity.laneSigningList(account),
author: await identity.getAuthor(account),
// What the author email form suggests while none is set.
defaultEmail: identity.defaultEmail(username),
@@ -148,7 +156,48 @@ router.put("/signing", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
await identity.setSigning(account, { id, replace: req.body?.replace === true });
await identity.setSigning(account, {
id,
replace: req.body?.replace === true,
clearForeign: req.body?.clearForeign === true,
});
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
/**
* Sign the commits of ONE workspace with `{laneId, key}` (`key: null` stops).
* The workspace must be one the caller may see (scopeGuard, `laneId` in the
* body) whose folder runs as this same account — a workspace another account
* runs could never read this account's `~/.gitconfig` (EFOREIGN).
*/
router.put("/signing/lane", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const laneId = Number(req.body?.laneId);
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
// Stopping touches only this account's own file, so it needs no workspace:
// one removed from the dashboard while it signed can still be turned off.
if (id == null && Number.isSafeInteger(laneId) && laneId > 0) {
await identity.setLaneSigning(account, { laneId, id: null });
return res.json(await view(account, usernameOf(req)));
}
const lane = Number.isSafeInteger(laneId) ? require("../lib/lanes").getLane(laneId) : null;
if (!lane || !require("../lib/scope").canSeeLane(req, laneId)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such workspace" } });
}
const folder = identity.accountForDir(lane.cwd);
if (
folder.kind !== account.kind ||
(folder.kind === "member" && folder.acct.login !== account.acct.login)
) {
throw Object.assign(new Error("this workspace runs as another account"), {
code: "EFOREIGN",
});
}
await identity.setLaneSigning(account, { laneId, cwd: lane.cwd, id });
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
+7
View File
@@ -160,6 +160,13 @@ router.get("/", (req, res) => {
);
params.push(Number(lane), Number(lane));
}
// `mine=1` = the requesting user's OWN sessions whatever their role (the
// pet's count): an admin/analyst otherwise sees every user's. "1" locally.
if (req.query.mine === "1") {
const o = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id");
where.push(o.where);
params.push(...o.params);
}
// `awaiting=1` = only sessions blocked on the user (an active session with
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
if (req.query.awaiting === "1") {
+39 -2
View File
@@ -1,5 +1,5 @@
/**
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients.
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients (server mode: each user only what they may see, and an admin/analyst's session frames marked `mine` when the session is their own).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -9,6 +9,14 @@ const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/au
const { broadcastAudience, exactAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
let wss = null;
// Session-scoped broadcasts an admin/analyst gets with `mine` (see broadcast).
const MINE_TYPES = new Set([
"session_created",
"session_updated",
"agent_created",
"agent_updated",
"new_event",
]);
// How often an open terminal's login is re-checked in server mode.
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
@@ -230,7 +238,8 @@ function initPtyWebSocket(server) {
function broadcast(type, data) {
if (!wss) return;
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
const timestamp = new Date().toISOString();
const message = JSON.stringify({ type, data, timestamp });
// Server mode: worked out once, and only when a member is connected.
let audience;
// A message for exactly these users (a member's own config change): no
@@ -255,6 +264,24 @@ function broadcast(type, data) {
return audienceIncludes(audience, client.user);
};
let stripped;
// Server mode: an admin/analyst gets every user's session news, so a
// session-scoped frame tells them whether it is their OWN (`mine`) — the
// pet counts only those. Worked out once per broadcast, on first need.
const owned = MINE_TYPES.has(type) && isServerMode();
let mineMsg;
let otherMsg;
const forStaff = (client) => {
if (audience === undefined) {
try {
audience = broadcastAudience(type, data);
} catch {
audience = { staffOnly: true };
}
}
const mine = Boolean(audience && audience.owners && audience.owners.has(client.user.id));
if (mine) return (mineMsg ??= JSON.stringify({ type, data, timestamp, mine: true }));
return (otherMsg ??= JSON.stringify({ type, data, timestamp, mine: false }));
};
wss.clients.forEach((client) => {
if (client.readyState === 1 && reaches(client)) {
try {
@@ -264,6 +291,10 @@ function broadcast(type, data) {
client.send(stripped);
return;
}
if (owned && !only && client.user && !member) {
client.send(forStaff(client));
return;
}
client.send(message);
} catch {
// Client closed between readyState check and send — safe to ignore
@@ -331,7 +362,13 @@ function closeWebSocket() {
}
}
/** Tests only: broadcast to these fake clients (`{readyState, user, send}`). */
function _setClientsForTest(clients) {
wss = clients ? { clients } : null;
}
module.exports = {
_setClientsForTest,
initWebSocket,
initPtyWebSocket,
broadcast,