Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0792c88612 | |||
| a3d1dce2ca | |||
| db2e61a74b | |||
| 03ebea1d92 | |||
| c797e9868e | |||
| 0016b6a427 | |||
| 0d3e92cfda | |||
| 1dd2521183 | |||
| 21a042dc2b | |||
| 85edc88a6d | |||
| c229b4b79b | |||
| 9721941962 | |||
| b777e08371 | |||
| d0b4f8aaf6 |
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.63 (2026-11-02) — commit signing is chosen per workspace in Settings → Git._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.67 (2026-11-03) — user chat removed; its tables and uploads are dropped at start._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
* 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; hover
|
||||
* for their login name and — admin/analyst, from `GET /api/users` — role, $ spent
|
||||
* today, workspaces, sessions and last sign-in). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* 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.
|
||||
@@ -330,7 +330,7 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
// 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's card shows the login name alone.
|
||||
// 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());
|
||||
@@ -390,10 +390,11 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
return key;
|
||||
};
|
||||
const userTip = (g: HostUsageGroup) => {
|
||||
const lines = [groupName(g)];
|
||||
if (g.username) lines.push(t("host.userTipLogin", { v: g.username }));
|
||||
const u = g.userId != null ? accounts.get(g.userId) : undefined;
|
||||
if (u) {
|
||||
// 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")}` : "")
|
||||
@@ -721,8 +722,10 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
const name = groupName(g);
|
||||
const isOpen = open === g.key;
|
||||
return (
|
||||
<Tip key={g.key} raw={userTip(g)} block>
|
||||
<div className="space-y-2" data-testid="server-user">
|
||||
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||
{/* 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)}
|
||||
@@ -743,27 +746,27 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||
{isOpen && (
|
||||
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
||||
{g.top.map((p) => (
|
||||
<li
|
||||
key={p.pid}
|
||||
className="flex justify-between gap-2 text-[10px] font-mono"
|
||||
>
|
||||
<span className="truncate text-fg-secondary">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-fg-muted">
|
||||
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Tip>
|
||||
</Tip>
|
||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||
{isOpen && (
|
||||
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
||||
{g.top.map((p) => (
|
||||
<li
|
||||
key={p.pid}
|
||||
className="flex justify-between gap-2 text-[10px] font-mono"
|
||||
>
|
||||
<span className="truncate text-fg-secondary">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-fg-muted">
|
||||
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 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; on
|
||||
* hover their login name, and for staff their role and $ spent today);
|
||||
* 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>
|
||||
*/
|
||||
@@ -186,7 +186,7 @@ describe("HostHealth", () => {
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the login, role and today's spend when a user tile is hovered", async () => {
|
||||
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: [
|
||||
@@ -206,21 +206,27 @@ describe("HostHealth", () => {
|
||||
});
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
await vi.waitFor(() => expect(api.users.list).toHaveBeenCalled());
|
||||
fireEvent.mouseEnter(tile.parentElement!);
|
||||
const tip = await screen.findByText(/Login: mia/);
|
||||
expect(tip.textContent).toContain("Role: Developer");
|
||||
// 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("shows a developer only the login name, without reading the users list", async () => {
|
||||
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(tile.parentElement!);
|
||||
const tip = await screen.findByText(/Login: mia/);
|
||||
expect(tip.textContent).not.toContain("Today");
|
||||
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
|
||||
expect(screen.queryByText(/Today:/)).toBeNull();
|
||||
expect(api.users.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -183,7 +183,6 @@
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Users · CPU / RAM now",
|
||||
"usersCount": "{{n}} users",
|
||||
"userTipLogin": "Login: {{v}}",
|
||||
"userTipRole": "Role: {{v}}",
|
||||
"userTipToday": "Today: {{cost}} · {{tokens}} tokens",
|
||||
"userTipWork": "{{w}} workspaces · {{s}} sessions",
|
||||
|
||||
@@ -183,7 +183,6 @@
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
||||
"usersCount": "{{n}} người dùng",
|
||||
"userTipLogin": "Tên đăng nhập: {{v}}",
|
||||
"userTipRole": "Vai trò: {{v}}",
|
||||
"userTipToday": "Hôm nay: {{cost}} · {{tokens}} token",
|
||||
"userTipWork": "{{w}} workspace · {{s}} phiên",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1858,6 +1858,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 +1880,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);
|
||||
|
||||
@@ -1971,6 +1971,10 @@ export interface WSMessage {
|
||||
/** 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 ─────
|
||||
|
||||
@@ -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.64 (rebuild client to match)
|
||||
UI build v2.8.67 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2176,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` |
|
||||
@@ -3718,6 +3719,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.
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ 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; on hover their login name, and for an
|
||||
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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.64",
|
||||
"version": "2.8.67",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.64",
|
||||
"version": "2.8.67",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.64",
|
||||
"version": "2.8.67",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1854,6 +1854,22 @@ try {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// v2.8.65–2.8.66 shipped a chat between users (chat_* tables, uploads under
|
||||
// <data dir>/chat/); it was removed in v2.8.67, and its data goes with it.
|
||||
try {
|
||||
db.exec(`
|
||||
DROP TABLE IF EXISTS chat_files;
|
||||
DROP TABLE IF EXISTS chat_messages;
|
||||
DROP TABLE IF EXISTS chat_members;
|
||||
DROP TABLE IF EXISTS chat_rooms;
|
||||
`);
|
||||
if (!process.env.DASHBOARD_DB_PATH) {
|
||||
fs.rmSync(path.join(getDataDir(), "chat"), { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
/* best effort: a leftover is unused */
|
||||
}
|
||||
|
||||
// Server mode: whose browser a push subscription is (`users.id`); NULL in
|
||||
// local mode. A user's notifications go only to their own subscriptions.
|
||||
try {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user