feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file AlertsNotifications.roles.test.tsx
|
||||
* @description Server mode, not an admin: alert rules and channels fire for
|
||||
* every user, so neither tab is shown nor loaded, and an analyst's feed offers
|
||||
* Acknowledge only on alerts the server marks theirs (`can_ack`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const rulesList = vi.fn(async () => ({ rules: [] }));
|
||||
const alert = (id: number, can_ack: boolean) => ({
|
||||
id,
|
||||
rule_id: "r1",
|
||||
rule_name: `rule ${id}`,
|
||||
rule_type: "error_rate",
|
||||
session_id: null,
|
||||
agent_id: null,
|
||||
message: `boom ${id}`,
|
||||
details: null,
|
||||
triggered_at: "2026-10-08T00:00:00.000Z",
|
||||
acknowledged_at: null,
|
||||
can_ack,
|
||||
});
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
alerts: {
|
||||
rules: { list: () => rulesList() },
|
||||
list: vi.fn(async () => ({
|
||||
alerts: [alert(1, true), alert(2, false)],
|
||||
total: 2,
|
||||
unacked: 2,
|
||||
})),
|
||||
ack: vi.fn(),
|
||||
ackAll: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../AuthGate", () => ({
|
||||
usePermissions: () => ({ isAdmin: false, seesAll: true, canEditLane: () => false }),
|
||||
}));
|
||||
|
||||
import { AlertsNotifications } from "../AlertsNotifications";
|
||||
|
||||
describe("AlertsNotifications for a non-admin", () => {
|
||||
it("shows only the feed, and Acknowledge only where the server allows it", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertsNotifications />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("boom 2")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^acknowledge$/i })).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: /rules/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /channels/i })).toBeNull();
|
||||
expect(rulesList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,25 +2,52 @@
|
||||
* @file AuthGate.test.tsx
|
||||
* @description The login gate: local mode renders the app untouched, server
|
||||
* mode shows the login page until a login succeeds, a wrong password says so,
|
||||
* and a later 401 `EAUTH` brings the login page back.
|
||||
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
|
||||
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||
* analyst reads everything but works only in their own workspaces, a
|
||||
* developer only ever sees (and so changes) their own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
const LANES = [
|
||||
{ id: 1, owner_id: 5 },
|
||||
{ id: 2, owner_id: 6 },
|
||||
];
|
||||
vi.mock("../../lib/api", () => ({
|
||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } },
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { AuthGate } from "../AuthGate";
|
||||
import { AuthGate, usePermissions } from "../AuthGate";
|
||||
import { useWorkLanes } from "../../hooks/useLanes";
|
||||
|
||||
const user = { id: 1, username: "mia", role: "member" };
|
||||
function Probe() {
|
||||
const p = usePermissions();
|
||||
const { lanes } = useWorkLanes();
|
||||
return (
|
||||
<p>
|
||||
{[
|
||||
`admin:${p.isAdmin}`,
|
||||
`all:${p.seesAll}`,
|
||||
`mine:${p.canEditLane({ owner_id: 5 })}`,
|
||||
`theirs:${p.canEditLane({ owner_id: 6 })}`,
|
||||
`work:${lanes.map((l) => l.id).join(",")}`,
|
||||
].join(" ")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const user = { id: 1, username: "mia", role: "developer" };
|
||||
|
||||
beforeEach(() => {
|
||||
me.mockReset();
|
||||
@@ -98,3 +125,37 @@ describe("AuthGate", () => {
|
||||
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePermissions / useWorkLanes", () => {
|
||||
const as = async (state: unknown) => {
|
||||
cleanup();
|
||||
me.mockResolvedValue(state);
|
||||
render(
|
||||
<AuthGate>
|
||||
<Probe />
|
||||
</AuthGate>
|
||||
);
|
||||
return (await screen.findByText(/work:\d/)).textContent;
|
||||
};
|
||||
|
||||
it("lets an admin and local mode do everything", async () => {
|
||||
for (const state of [
|
||||
{ mode: "local" },
|
||||
{ mode: "server", user: { id: 9, username: "boss", role: "admin" } },
|
||||
]) {
|
||||
expect(await as(state)).toBe("admin:true all:true mine:true theirs:true work:1,2");
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an analyst read everything but work only in their own workspaces", async () => {
|
||||
expect(await as({ mode: "server", user: { id: 5, username: "ana", role: "analyst" } })).toBe(
|
||||
"admin:false all:true mine:true theirs:false work:1"
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a developer act on everything they can see, and nothing host-wide", async () => {
|
||||
expect(await as({ mode: "server", user: { id: 5, username: "mia", role: "developer" } })).toBe(
|
||||
"admin:false all:false mine:true theirs:true work:1,2"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,10 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
|
||||
await screen.findByText("Session 8");
|
||||
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
||||
await user.click(screen.getByTestId("workspace-sessions-less-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("renames a session on double-click + Enter, and archives one from its row", async () => {
|
||||
|
||||
Reference in New Issue
Block a user