feat(FN-1150): add session notification banner for awaiting input
- Add SessionNotificationBanner to list awaiting_input AI sessions with type-specific labels/icons and resume or dismiss actions - Integrate the banner into App project layout and wire resume handlers for planning, subtask, and mission interview sessions - Add dismiss-all handling that clears all currently awaiting-input background sessions from the banner - Style the banner with sticky placement, entry animation, responsive layout, and action button states - Add component tests covering filtering, count rendering, resume/dismiss callbacks, type metadata, and local dismissal behavior
This commit is contained in:
@@ -8,6 +8,7 @@ import { AgentsView } from "./components/AgentsView";
|
||||
import { NodesView } from "./components/NodesView";
|
||||
import { AppModals } from "./components/AppModals";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||
import { MobileNavBar } from "./components/MobileNavBar";
|
||||
import { QuickChatFAB } from "./components/QuickChatFAB";
|
||||
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
|
||||
@@ -25,6 +26,7 @@ import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
|
||||
import { useViewState } from "./hooks/useViewState";
|
||||
import { useProjectActions } from "./hooks/useProjectActions";
|
||||
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
|
||||
function AppInner() {
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
@@ -45,6 +47,7 @@ function AppInner() {
|
||||
|
||||
// Background AI sessions
|
||||
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
|
||||
const sessionsNeedingInput = bgSessions.filter((session) => session.status === "awaiting_input");
|
||||
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
@@ -164,6 +167,22 @@ function AppInner() {
|
||||
setNodesOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleOpenBackgroundSession = useCallback((session: AiSessionSummary) => {
|
||||
if (session.type === "planning") {
|
||||
modalManager.openPlanningWithSession(session.id);
|
||||
} else if (session.type === "subtask") {
|
||||
modalManager.openSubtaskWithSession(session.id);
|
||||
} else if (session.type === "mission_interview") {
|
||||
modalManager.openMissionWithSession(session.id);
|
||||
}
|
||||
}, [modalManager]);
|
||||
|
||||
const handleDismissAllNeedingInputSessions = useCallback(() => {
|
||||
for (const session of sessionsNeedingInput) {
|
||||
bgDismiss(session.id);
|
||||
}
|
||||
}, [bgDismiss, sessionsNeedingInput]);
|
||||
|
||||
// Render main content based on view mode
|
||||
const renderMainContent = () => {
|
||||
if (nodesOpen) {
|
||||
@@ -291,6 +310,14 @@ function AppInner() {
|
||||
projectId={currentProject?.id}
|
||||
mobileNavEnabled={isMobile}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && !nodesOpen && (
|
||||
<SessionNotificationBanner
|
||||
sessions={sessionsNeedingInput}
|
||||
onResumeSession={handleOpenBackgroundSession}
|
||||
onDismissSession={bgDismiss}
|
||||
onDismissAll={handleDismissAllNeedingInputSessions}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`project-content${viewMode === "project" && currentProject ? " project-content--with-footer" : ""}${isMobile ? " project-content--with-mobile-nav" : ""}`}
|
||||
>
|
||||
@@ -304,15 +331,7 @@ function AppInner() {
|
||||
backgroundSessions={bgSessions}
|
||||
backgroundGenerating={bgGenerating}
|
||||
backgroundNeedsInput={bgNeedsInput}
|
||||
onOpenBackgroundSession={(session) => {
|
||||
if (session.type === "planning") {
|
||||
modalManager.openPlanningWithSession(session.id);
|
||||
} else if (session.type === "subtask") {
|
||||
modalManager.openSubtaskWithSession(session.id);
|
||||
} else if (session.type === "mission_interview") {
|
||||
modalManager.openMissionWithSession(session.id);
|
||||
}
|
||||
}}
|
||||
onOpenBackgroundSession={handleOpenBackgroundSession}
|
||||
onDismissBackgroundSession={bgDismiss}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { AiSessionSummary } from "../api";
|
||||
import { SessionNotificationBanner } from "./SessionNotificationBanner";
|
||||
|
||||
function buildSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
|
||||
return {
|
||||
id: overrides.id ?? "session-1",
|
||||
type: overrides.type ?? "planning",
|
||||
status: overrides.status ?? "awaiting_input",
|
||||
title: overrides.title ?? "Draft implementation plan",
|
||||
projectId: overrides.projectId ?? "proj-1",
|
||||
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionNotificationBanner", () => {
|
||||
it("renders nothing when no sessions need input", () => {
|
||||
const { container } = render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[
|
||||
buildSession({ id: "a", status: "generating" }),
|
||||
buildSession({ id: "b", status: "complete" }),
|
||||
]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders banner with correct awaiting_input count", () => {
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[
|
||||
buildSession({ id: "a", title: "First", status: "awaiting_input" }),
|
||||
buildSession({ id: "b", title: "Second", status: "awaiting_input" }),
|
||||
buildSession({ id: "c", title: "Done", status: "complete" }),
|
||||
]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("2 AI sessions need your input")).toBeInTheDocument();
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Done")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render sessions that are generating or complete", () => {
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[
|
||||
buildSession({ id: "planning", type: "planning", title: "Planning Session", status: "awaiting_input" }),
|
||||
buildSession({ id: "gen", title: "Generating", status: "generating" }),
|
||||
buildSession({ id: "complete", title: "Complete", status: "complete" }),
|
||||
]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Planning Session")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Generating")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Complete")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onResumeSession with the selected session", () => {
|
||||
const onResumeSession = vi.fn();
|
||||
const planningSession = buildSession({
|
||||
id: "planning-1",
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Plan checkout flow",
|
||||
});
|
||||
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[planningSession]}
|
||||
onResumeSession={onResumeSession}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||
expect(onResumeSession).toHaveBeenCalledWith(planningSession);
|
||||
});
|
||||
|
||||
it("calls onDismissSession with the session id", () => {
|
||||
const onDismissSession = vi.fn();
|
||||
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[buildSession({ id: "dismiss-1", title: "Dismiss me" })]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={onDismissSession}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss Dismiss me" }));
|
||||
expect(onDismissSession).toHaveBeenCalledWith("dismiss-1");
|
||||
});
|
||||
|
||||
it("calls onDismissAll when clicking dismiss all", () => {
|
||||
const onDismissAll = vi.fn();
|
||||
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[
|
||||
buildSession({ id: "a", title: "A" }),
|
||||
buildSession({ id: "b", title: "B" }),
|
||||
]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={onDismissAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /dismiss all/i }));
|
||||
expect(onDismissAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows type labels and icons for planning, subtask, and mission interview", () => {
|
||||
const { container } = render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[
|
||||
buildSession({ id: "planning", type: "planning", title: "Plan" }),
|
||||
buildSession({ id: "subtask", type: "subtask", title: "Breakdown" }),
|
||||
buildSession({ id: "mission", type: "mission_interview", title: "Mission" }),
|
||||
]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Planning")).toBeInTheDocument();
|
||||
expect(screen.getByText("Subtask Breakdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mission Interview")).toBeInTheDocument();
|
||||
|
||||
expect(container.querySelector(".lucide-lightbulb")).toBeTruthy();
|
||||
expect(container.querySelector(".lucide-layers")).toBeTruthy();
|
||||
expect(container.querySelector(".lucide-target")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removes dismissed sessions from the banner", () => {
|
||||
render(
|
||||
<SessionNotificationBanner
|
||||
sessions={[
|
||||
buildSession({ id: "first", title: "First Session" }),
|
||||
buildSession({ id: "second", title: "Second Session" }),
|
||||
]}
|
||||
onResumeSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
onDismissAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss First Session" }));
|
||||
|
||||
expect(screen.queryByText("First Session")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Second Session")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
146
packages/dashboard/app/components/SessionNotificationBanner.tsx
Normal file
146
packages/dashboard/app/components/SessionNotificationBanner.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AlertCircle, Lightbulb, Layers, Target, X } from "lucide-react";
|
||||
import type { AiSessionSummary } from "../api";
|
||||
|
||||
interface SessionNotificationBannerProps {
|
||||
sessions: AiSessionSummary[];
|
||||
onResumeSession: (session: AiSessionSummary) => void;
|
||||
onDismissSession: (id: string) => void;
|
||||
onDismissAll: () => void;
|
||||
}
|
||||
|
||||
const TYPE_ICONS = {
|
||||
planning: Lightbulb,
|
||||
subtask: Layers,
|
||||
mission_interview: Target,
|
||||
} as const;
|
||||
|
||||
const TYPE_LABELS = {
|
||||
planning: "Planning",
|
||||
subtask: "Subtask Breakdown",
|
||||
mission_interview: "Mission Interview",
|
||||
} as const;
|
||||
|
||||
export function SessionNotificationBanner({
|
||||
sessions,
|
||||
onResumeSession,
|
||||
onDismissSession,
|
||||
onDismissAll,
|
||||
}: SessionNotificationBannerProps) {
|
||||
const [dismissedSessionIds, setDismissedSessionIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setDismissedSessionIds((previous) => {
|
||||
if (previous.size === 0) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
const next = new Set<string>();
|
||||
let changed = false;
|
||||
|
||||
for (const id of previous) {
|
||||
const session = sessionById.get(id);
|
||||
if (session && session.status === "awaiting_input") {
|
||||
next.add(id);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : previous;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
const sessionsNeedingInput = useMemo(
|
||||
() => sessions.filter((session) => session.status === "awaiting_input" && !dismissedSessionIds.has(session.id)),
|
||||
[dismissedSessionIds, sessions],
|
||||
);
|
||||
|
||||
if (sessionsNeedingInput.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const count = sessionsNeedingInput.length;
|
||||
const headerText = `${count} AI session${count === 1 ? "" : "s"} need${count === 1 ? "s" : ""} your input`;
|
||||
|
||||
const dismissLocally = (id: string) => {
|
||||
setDismissedSessionIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleResume = (session: AiSessionSummary) => {
|
||||
setDismissedSessionIds((previous) => {
|
||||
if (!previous.has(session.id)) {
|
||||
return previous;
|
||||
}
|
||||
const next = new Set(previous);
|
||||
next.delete(session.id);
|
||||
return next;
|
||||
});
|
||||
onResumeSession(session);
|
||||
};
|
||||
|
||||
const handleDismissAll = () => {
|
||||
setDismissedSessionIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const session of sessionsNeedingInput) {
|
||||
next.add(session.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
onDismissAll();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="session-notification-banner" role="region" aria-live="polite" aria-label="AI sessions needing input">
|
||||
<div className="session-notification-banner__header">
|
||||
<div className="session-notification-banner__headline">
|
||||
<AlertCircle size={16} aria-hidden="true" />
|
||||
<span>{headerText}</span>
|
||||
</div>
|
||||
<button className="session-notification-banner__dismiss-all" onClick={handleDismissAll}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
<span>Dismiss all</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="session-notification-banner__list">
|
||||
{sessionsNeedingInput.map((session) => {
|
||||
const Icon = TYPE_ICONS[session.type];
|
||||
|
||||
return (
|
||||
<article className="session-notification-banner__item" key={session.id} data-session-type={session.type}>
|
||||
<div className="session-notification-banner__item-main">
|
||||
<Icon size={16} className="session-notification-banner__type-icon" aria-hidden="true" />
|
||||
<div className="session-notification-banner__text">
|
||||
<p className="session-notification-banner__title" title={session.title}>{session.title}</p>
|
||||
<p className="session-notification-banner__meta">{TYPE_LABELS[session.type]}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="session-notification-banner__actions">
|
||||
<button className="session-notification-banner__resume" onClick={() => handleResume(session)}>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
className="session-notification-banner__dismiss"
|
||||
onClick={() => {
|
||||
dismissLocally(session.id);
|
||||
onDismissSession(session.id);
|
||||
}}
|
||||
aria-label={`Dismiss ${session.title}`}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -25460,3 +25460,200 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Session Notification Banner === */
|
||||
|
||||
.session-notification-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 4px solid var(--triage);
|
||||
background: var(--surface);
|
||||
box-sizing: border-box;
|
||||
max-height: 420px;
|
||||
opacity: 1;
|
||||
transition: opacity var(--transition-fast), max-height var(--transition-fast);
|
||||
animation: session-notification-banner-enter 180ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes session-notification-banner-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
.session-notification-banner__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.session-notification-banner__headline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-notification-banner__headline svg {
|
||||
color: var(--triage);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-notification-banner__dismiss-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.session-notification-banner__dismiss-all:hover {
|
||||
color: var(--text);
|
||||
background: var(--card-hover);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.session-notification-banner__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.session-notification-banner__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--triage) 8%, var(--surface));
|
||||
}
|
||||
|
||||
.session-notification-banner__item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.session-notification-banner__type-icon {
|
||||
color: var(--triage);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-notification-banner__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-notification-banner__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-notification-banner__meta {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-notification-banner__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-notification-banner__resume {
|
||||
border: 1px solid color-mix(in srgb, var(--triage) 60%, var(--border));
|
||||
background: transparent;
|
||||
color: var(--triage);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.session-notification-banner__resume:hover {
|
||||
background: color-mix(in srgb, var(--triage) 14%, transparent);
|
||||
border-color: var(--triage);
|
||||
}
|
||||
|
||||
.session-notification-banner__dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.session-notification-banner__dismiss:hover {
|
||||
color: var(--text);
|
||||
background: var(--card-hover);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.session-notification-banner {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-notification-banner__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.session-notification-banner__item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.session-notification-banner__item-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-notification-banner__title {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.session-notification-banner__actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user