feat(maestro): render sidebar sessions as two-line cards per docs/SIDEBAR.md
This commit is contained in:
@@ -104,3 +104,122 @@ describe("blocked-work indicator (#83)", () => {
|
||||
expect(confirmations).toEqual(['Kill session "claude"?']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session cards (#107)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
|
||||
expect(line1).not.toBeNull();
|
||||
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
|
||||
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
|
||||
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
||||
});
|
||||
|
||||
it("stays a single line while freshly active", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
||||
activity: "Active",
|
||||
activity_since: ago(5_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows dot + floored duration once the state has been held past 30s", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000012",
|
||||
activity: "Active",
|
||||
activity_since: ago(4 * 60_000 + 30_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.dataset.activity).toBe("active");
|
||||
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
it("maps each activity state to its contract slug", () => {
|
||||
const mk = (id: string, activity: string) =>
|
||||
session({
|
||||
id,
|
||||
activity: activity as Session["activity"],
|
||||
status: "Running",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
|
||||
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
|
||||
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
|
||||
const el = renderSidebar(storeWith([idle, wait, error])).el;
|
||||
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"idle",
|
||||
);
|
||||
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an unknown activity variant as idle instead of failing to render", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000016",
|
||||
activity: "Teleported" as Session["activity"],
|
||||
activity_since: ago(120_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
|
||||
expect(line2?.getAttribute("data-activity")).toBe("idle");
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
|
||||
});
|
||||
|
||||
it("moves blocked work onto line 2 in the shared phrasing", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000017",
|
||||
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("⚠");
|
||||
expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
|
||||
// Line 1 carries no badges.
|
||||
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows exited on line 2 for dead sessions", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000018",
|
||||
status: "Dead",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("exited");
|
||||
});
|
||||
|
||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||
command: "claude",
|
||||
metadata: { name: "worker" },
|
||||
activity: "AwaitingInput",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
const dot = row.querySelector<HTMLElement>(".activity-dot");
|
||||
expect(dot?.getAttribute("title")).toContain("Awaiting input");
|
||||
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
expect(row.textContent).not.toMatch(/awaiting.?input/i);
|
||||
});
|
||||
|
||||
it("keeps the attach navigation and depth indentation", () => {
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLAnchorElement;
|
||||
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
|
||||
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
|
||||
});
|
||||
});
|
||||
|
||||
+63
-15
@@ -4,6 +4,7 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
||||
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
@@ -17,6 +18,41 @@ export function requestOpenSpawn(): void {
|
||||
|
||||
const collapsed = new Set<string>();
|
||||
|
||||
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
|
||||
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
||||
const STATE_WORDS: Record<string, string> = {
|
||||
active: "Active",
|
||||
idle: "Idle",
|
||||
"awaiting-input": "Awaiting input",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
const name = sessionName(s);
|
||||
const dead = s.status === "Dead";
|
||||
const blockers = sessionBlockers(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
const duration = held !== null ? activityDurationLabel(held) : null;
|
||||
// The directory is the enclosing folder, so only surface the command — and
|
||||
// only when it isn't already what the name shows.
|
||||
const showCmd = s.command !== name;
|
||||
|
||||
const fragments = [
|
||||
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
||||
blockers
|
||||
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
||||
: "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
showCmd
|
||||
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`
|
||||
: "",
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
|
||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
if (node.kind === "folder") {
|
||||
const wrap = document.createElement("div");
|
||||
@@ -52,30 +88,34 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
}
|
||||
|
||||
const s = node.session;
|
||||
const now = Date.now();
|
||||
const row = document.createElement("a");
|
||||
row.className = "tree-leaf";
|
||||
row.className = "tree-leaf session-card";
|
||||
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||
row.dataset.sessionId = s.id;
|
||||
const running = s.status === "Running";
|
||||
row.dataset.status = running ? "running" : "dead";
|
||||
const name = sessionName(s);
|
||||
row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`);
|
||||
// The directory is the enclosing folder, so only surface the command — and
|
||||
// only when it isn't already what the name shows.
|
||||
const showCmd = s.command !== name;
|
||||
// #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so
|
||||
// "this will be refused" is visible before the kill is attempted.
|
||||
const blockers = sessionBlockers(s);
|
||||
const stateWord =
|
||||
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||
);
|
||||
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
|
||||
// the actions reserve their width, so the name truncates before it can ever
|
||||
// reach them. Line 2 renders only when a contract trigger holds.
|
||||
const showMeta = showsMetaLine(s, now);
|
||||
row.innerHTML = `
|
||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
|
||||
${running ? "" : `<span class="leaf-badge">exited</span>`}
|
||||
${blockers ? `<span class="leaf-badge leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>` : ""}
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${showMeta ? metaLine(s, now) : ""}
|
||||
`;
|
||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
@@ -395,6 +435,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
|
||||
const unsubscribe = store.subscribe(refresh);
|
||||
|
||||
// Contract granularity: re-render at least every 30s while visible so the
|
||||
// duration cutover points land within one tick.
|
||||
const clock = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
refresh();
|
||||
}, 30_000);
|
||||
|
||||
return {
|
||||
el,
|
||||
setActive(id: string | null) {
|
||||
@@ -410,6 +457,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
applyHighlights();
|
||||
},
|
||||
dispose() {
|
||||
clearInterval(clock);
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
|
||||
+66
-18
@@ -59,20 +59,28 @@ function leafRow(opts: {
|
||||
status: "running" | "dead";
|
||||
active?: boolean;
|
||||
indent?: number;
|
||||
activity?: "active" | "idle" | "awaiting-input" | "error";
|
||||
duration?: string;
|
||||
}): HTMLElement {
|
||||
const row = h("a", "tree-leaf");
|
||||
const row = h("a", "tree-leaf session-card");
|
||||
(row as HTMLAnchorElement).href = "#/kit";
|
||||
row.dataset.status = opts.status;
|
||||
if (opts.active) row.classList.add("is-active");
|
||||
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
|
||||
const flags = [
|
||||
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
|
||||
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
|
||||
].join("");
|
||||
row.innerHTML = `
|
||||
<span class="leaf-name">${opts.name}</span>
|
||||
${opts.cmd !== opts.name ? `<span class="leaf-meta"><span class="leaf-cmd">${opts.cmd}</span></span>` : ""}
|
||||
${opts.status === "dead" ? `<span class="leaf-badge">exited</span>` : ""}
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${opts.name}">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${opts.name}">✕</button>
|
||||
</span>`;
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name">${opts.name}</span>
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -190,14 +198,17 @@ export function renderKit(): HTMLElement {
|
||||
// --- chips & badges ---
|
||||
{
|
||||
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
|
||||
const activeLine = h(
|
||||
"span",
|
||||
"card-line2",
|
||||
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
|
||||
);
|
||||
activeLine.setAttribute("data-activity", "active");
|
||||
body.append(
|
||||
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
||||
specimen("folder-count", h("span", "folder-count", "4")),
|
||||
specimen("leaf-badge", h("span", "leaf-badge", "exited")),
|
||||
specimen(
|
||||
"attach-state · open",
|
||||
h("span", "attach-state", "live"),
|
||||
),
|
||||
specimen("card-line2 · active", activeLine),
|
||||
specimen("attach-state · open", h("span", "attach-state", "live")),
|
||||
);
|
||||
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
|
||||
"data-state",
|
||||
@@ -215,19 +226,56 @@ export function renderKit(): HTMLElement {
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Session rows — nested by host, then directory",
|
||||
"The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
|
||||
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
|
||||
);
|
||||
const list = h("div", "kit-tree");
|
||||
list.append(
|
||||
folderHeader("~/git/maestro", 3, 0),
|
||||
folderHeader("web", 2, 1),
|
||||
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
|
||||
leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }),
|
||||
leafRow({
|
||||
name: "dev-server",
|
||||
cmd: "vite dev",
|
||||
status: "running",
|
||||
indent: 2,
|
||||
active: true,
|
||||
activity: "active",
|
||||
duration: "4m",
|
||||
}),
|
||||
folderHeader("crates/daemon", 1, 1),
|
||||
leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }),
|
||||
leafRow({
|
||||
name: "daemon-work",
|
||||
cmd: "claude",
|
||||
status: "running",
|
||||
indent: 2,
|
||||
activity: "awaiting-input",
|
||||
duration: "12s",
|
||||
}),
|
||||
leafRow({
|
||||
name: "flaky-agent",
|
||||
cmd: "claude",
|
||||
status: "running",
|
||||
indent: 2,
|
||||
activity: "error",
|
||||
duration: "2h",
|
||||
}),
|
||||
folderHeader("~/work/acme", 2, 0),
|
||||
leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }),
|
||||
leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }),
|
||||
leafRow({
|
||||
name: "billing-api",
|
||||
cmd: "claude",
|
||||
status: "running",
|
||||
indent: 1,
|
||||
activity: "idle",
|
||||
duration: "26m",
|
||||
}),
|
||||
leafRow({
|
||||
name: "scratch",
|
||||
cmd: "bash",
|
||||
status: "dead",
|
||||
indent: 1,
|
||||
activity: "idle",
|
||||
duration: "5h",
|
||||
}),
|
||||
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
|
||||
);
|
||||
body.classList.add("kit-tree-host");
|
||||
|
||||
+63
-38
@@ -787,60 +787,85 @@ textarea {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
|
||||
always, line 2 (meta) only when it has something to say. */
|
||||
.tree-leaf.session-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.card-line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leaf-name {
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-13);
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaf-meta {
|
||||
font-size: var(--fs-11);
|
||||
color: var(--muted);
|
||||
margin-left: auto;
|
||||
padding-left: var(--sp-2);
|
||||
/* Grows to push the actions to the right edge but shrinks first, so the name
|
||||
truncates before it can ever reach them — overlap is a defect (#107). */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
||||
state word itself is never spelled out here; the colour carries it. */
|
||||
.card-line2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.leaf-cmd {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaf-badge {
|
||||
font-size: var(--fs-11);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
.card-line2[data-activity="active"] {
|
||||
color: var(--live);
|
||||
}
|
||||
.card-line2[data-activity="idle"] {
|
||||
color: var(--muted);
|
||||
}
|
||||
.card-line2[data-activity="awaiting-input"] {
|
||||
color: var(--warn);
|
||||
}
|
||||
.card-line2[data-activity="error"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.activity-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* #83: the session row's blocked-work warning — an unforced kill will be
|
||||
refused, and the operator should see that before pressing the button. */
|
||||
.leaf-badge.leaf-blocked {
|
||||
color: var(--warn, #b45309);
|
||||
border-color: var(--warn, #b45309);
|
||||
.activity-duration {
|
||||
color: inherit;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
|
||||
signals severity and "exited" dims with the row. */
|
||||
.card-flag {
|
||||
color: var(--faint);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Actions live in line 1's flow at its right edge — never overlaid on the
|
||||
name (#107). They reserve their width even while hover-faded. */
|
||||
.leaf-actions {
|
||||
position: absolute;
|
||||
right: var(--sp-1);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user