fix: prevent blank browser reloads and clear stale errors
Require a committed page URL before enabling reload. Clear sidebar action errors when leaving or closing a panel, and ignore late failures from its previous scope. Add regression coverage and document browser error lifecycle and validation results. Release note: 修复空白浏览器页刷新时报错、关闭浏览器后错误提示仍残留的问题;切换或关闭面板后,旧操作的延迟失败不再重新显示。
This commit is contained in:
@@ -89,7 +89,10 @@ Tab 的跨会话可见性与 MCP 授权分开处理。B 的请求不能选择属
|
|||||||
capability 在用户切到 B 后仍固定路由至 A 的原 Tab,使用租约也继续阻止关闭该 Tab。
|
capability 在用户切到 B 后仍固定路由至 A 的原 Tab,使用租约也继续阻止关闭该 Tab。
|
||||||
内部点击实例只同步应用类型,不再次按当前 Conversation 改选实例。Main 状态携带对应的
|
内部点击实例只同步应用类型,不再次按当前 Conversation 改选实例。Main 状态携带对应的
|
||||||
`workbarInstanceId`,Renderer 据此绑定请求创建的同一 Tab,而不是另建空白页。收到 `stopped`
|
`workbarInstanceId`,Renderer 据此绑定请求创建的同一 Tab,而不是另建空白页。收到 `stopped`
|
||||||
后清除该实例的旧 Tab 绑定,下一次使用通过原逻辑实例重新创建,保留地址草稿与错误反馈。
|
后清除该实例的旧 Tab 绑定,下一次使用通过原逻辑实例重新创建,保留地址草稿。
|
||||||
|
刷新可用性读取 Main 的已提交 URL,不使用地址草稿或仅凭 `sessionActive` 判断。
|
||||||
|
侧栏操作错误以当前工作栏实例和面板展开状态为作用域;切换或关闭时清空,旧作用域的
|
||||||
|
异步回调不再写入错误,即使用户随后返回同一实例。
|
||||||
请求创建的实例也遵守现有 32 个工作栏页签上限;满额时保留 Main 页面及其状态,并就地提示
|
请求创建的实例也遵守现有 32 个工作栏页签上限;满额时保留 Main 页面及其状态,并就地提示
|
||||||
关闭一个可关闭页签。释放位置后再绑定该页面,不写入超限布局或丢弃 Agent 页面。
|
关闭一个可关闭页签。释放位置后再绑定该页面,不写入超限布局或丢弃 Agent 页面。
|
||||||
|
|
||||||
@@ -173,7 +176,7 @@ primary, including one opened by an Agent, is not adopted by a new instance. New
|
|||||||
|
|
||||||
持久化工作栏实例 ID、顺序、自定义标题和公开 Conversation 绑定,不持久化页面进程、CDP
|
持久化工作栏实例 ID、顺序、自定义标题和公开 Conversation 绑定,不持久化页面进程、CDP
|
||||||
引用、snapshot、viewport lease、Cookie、Token 或 Main `BrowserTabId`。应用重启后实例显示
|
引用、snapshot、viewport lease、Cookie、Token 或 Main `BrowserTabId`。应用重启后实例显示
|
||||||
未启动状态,首次导航或 Agent 使用时按原绑定创建新 Tab。
|
未启动状态,展开浏览器面板或 Agent 首次使用时按原绑定创建新 Tab。
|
||||||
|
|
||||||
绑定的 Conversation 已删除或不可见时保留实例并显示目标失效,不自动绑定当前 Conversation。
|
绑定的 Conversation 已删除或不可见时保留实例并显示目标失效,不自动绑定当前 Conversation。
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,10 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
|||||||
页面加载期间原位切换为“停止加载”;“关闭浏览器”是清楚独立于导航的会话生命周期操作,
|
页面加载期间原位切换为“停止加载”;“关闭浏览器”是清楚独立于导航的会话生命周期操作,
|
||||||
必须与“停止加载”在文案和视觉上明确区分。现有“停止浏览器”语义统一改名为“关闭浏览器”。
|
必须与“停止加载”在文案和视觉上明确区分。现有“停止浏览器”语义统一改名为“关闭浏览器”。
|
||||||
|
|
||||||
|
新建或重新创建的空白标签页尚无已提交 URL 时禁用刷新;只输入地址草稿不会启用刷新。
|
||||||
|
首次导航期间仍可停止加载。操作错误仅在当前面板本次打开期间显示,切换面板、关闭当前
|
||||||
|
页签或收起工作栏后清除,已离开面板的异步失败不得重新显示旧提示。
|
||||||
|
|
||||||
同一 Conversation 下的 Browser Tab 共享隔离存储和代理上下文,但每个 Tab 拥有独立页面、
|
同一 Conversation 下的 Browser Tab 共享隔离存储和代理上下文,但每个 Tab 拥有独立页面、
|
||||||
导航状态、操作队列和元素引用空间。模型请求开始时绑定一个确定的 Browser Tab;用户切换
|
导航状态、操作队列和元素引用空间。模型请求开始时绑定一个确定的 Browser Tab;用户切换
|
||||||
工作栏实例不能在请求途中改变 Agent 的工具目标。用户在该实例执行前往、返回或刷新会有意
|
工作栏实例不能在请求途中改变 Agent 的工具目标。用户在该实例执行前往、返回或刷新会有意
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# 工作栏实现与验证进度
|
# 工作栏实现与验证进度
|
||||||
|
|
||||||
|
## 2026-09-12 浏览器刷新与关闭后的错误清理
|
||||||
|
|
||||||
|
- 按 [浏览器工具栏规则](./prd.md#77-浏览器) 修复空白标签页允许刷新的问题;
|
||||||
|
刷新需要 Main 的已提交 URL,地址草稿不启用刷新,首次导航仍可停止加载。
|
||||||
|
- 侧栏操作错误在切换面板、关闭当前页签或收起工作栏后清除;旧面板的异步失败不再回写。
|
||||||
|
作用域实现见 [浏览器多实例技术设计](./browser-tabs-technical-design.md#4-renderer-与-viewport)。
|
||||||
|
- Sidebar 回归 38 项、App 浏览器筛选回归 5 项、BrowserService 回归 43 项通过。
|
||||||
|
`node build/run-browser-tabs-electron-e2e.cjs` 通过,验证真实空白页、导航、页面隔离和关闭;
|
||||||
|
该脚本不覆盖 Renderer 提示显示,提示清理由组件测试验证,本轮未手动操作完整桌面 UI。
|
||||||
|
- `npm run typecheck` 和两份修改源码的 ESLint 检查通过。全量 `npm test` 在 Agent
|
||||||
|
离线依赖清单用例超时后,整轮达到 200 秒执行上限;包含完整 App 测试的补跑也达到该上限。
|
||||||
|
`npm run lint` 被工作区原有未跟踪的 `application-tool-navigation/navigation-demo.js`
|
||||||
|
中 27 项浏览器全局变量错误阻断,未修改该文件,不将整库校验记为全绿。
|
||||||
|
- 本轮仅修改桌面 Renderer 控件与提示状态,未改 BrowserService、Runtime、Agent 或远程协议;
|
||||||
|
不需要单独的远程实现。无外部模型调用。
|
||||||
|
|
||||||
## 2026-09-10 提交后保留问答
|
## 2026-09-10 提交后保留问答
|
||||||
|
|
||||||
- 桌面端在提交成功后保留结构化问答回顾,支持多轮回答、跳过及本地会话重新加载;
|
- 桌面端在提交成功后保留结构化问答回顾,支持多轮回答、跳过及本地会话重新加载;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ const currentProject: AssistantProject = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sidebarElement({
|
function sidebarElement({
|
||||||
|
open = true,
|
||||||
tab = 'tasks',
|
tab = 'tasks',
|
||||||
approvals = [],
|
approvals = [],
|
||||||
artifacts = [],
|
artifacts = [],
|
||||||
@@ -144,6 +145,7 @@ function sidebarElement({
|
|||||||
onStopLoadingBrowser,
|
onStopLoadingBrowser,
|
||||||
onTabChange = vi.fn()
|
onTabChange = vi.fn()
|
||||||
}: {
|
}: {
|
||||||
|
open?: boolean
|
||||||
tab?: AssistantSidebarTab
|
tab?: AssistantSidebarTab
|
||||||
approvals?: PendingSidebarApproval[]
|
approvals?: PendingSidebarApproval[]
|
||||||
artifacts?: SidebarArtifact[]
|
artifacts?: SidebarArtifact[]
|
||||||
@@ -206,7 +208,7 @@ function sidebarElement({
|
|||||||
onStopLoadingBrowser={onStopLoadingBrowser}
|
onStopLoadingBrowser={onStopLoadingBrowser}
|
||||||
onOpenTask={vi.fn()}
|
onOpenTask={vi.fn()}
|
||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
open
|
open={open}
|
||||||
restoreFocusRef={restoreFocusRef}
|
restoreFocusRef={restoreFocusRef}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
workspaceProjectId={workspaceProjectId}
|
workspaceProjectId={workspaceProjectId}
|
||||||
@@ -822,6 +824,112 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requires a committed URL to reload a fresh active browser tab, but still allows Stop', async () => {
|
||||||
|
const conversationId = 'blank-browser'
|
||||||
|
const state: BrowserLiveState = {
|
||||||
|
...browserSummary(conversationId, firstBrowserTabId, true),
|
||||||
|
sessionActive: true
|
||||||
|
}
|
||||||
|
const onReloadBrowser = vi.fn(async () => undefined)
|
||||||
|
const onStopLoadingBrowser = vi.fn(async () => undefined)
|
||||||
|
const props = {
|
||||||
|
tab: 'browser' as const,
|
||||||
|
activeConversationId: conversationId,
|
||||||
|
onReloadBrowser,
|
||||||
|
onStopLoadingBrowser
|
||||||
|
}
|
||||||
|
const withState = (browserState: BrowserLiveState) => sidebarElement({
|
||||||
|
...props, browserStates: { [conversationId]: { [firstBrowserTabId]: browserState } }
|
||||||
|
})
|
||||||
|
const view = render(withState(state))
|
||||||
|
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||||
|
expect(screen.getByRole('button', { name: '刷新' })).toBeDisabled()
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
|
||||||
|
target: { value: 'https://draft.example/' }
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||||
|
expect(screen.getByRole('button', { name: '刷新' })).toBeDisabled()
|
||||||
|
expect(onReloadBrowser).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
view.rerender(withState({ ...state, status: 'loading', isLoading: true }))
|
||||||
|
expect(screen.getByRole('button', { name: '停止加载' })).toBeEnabled()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '停止加载' }))
|
||||||
|
await waitFor(() => expect(onStopLoadingBrowser).toHaveBeenCalledWith(conversationId, firstBrowserTabId))
|
||||||
|
|
||||||
|
view.rerender(withState({ ...state, url: 'https://committed.example/' }))
|
||||||
|
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||||
|
await waitFor(() => expect(onReloadBrowser).toHaveBeenCalledWith(conversationId, firstBrowserTabId))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['close', 'switch', 'collapse'] as const)(
|
||||||
|
'clears a browser action error after %s without restoring it on return',
|
||||||
|
async (transition) => {
|
||||||
|
const props = {
|
||||||
|
tab: 'browser' as const,
|
||||||
|
activeConversationId: 'error-browser',
|
||||||
|
onNavigateBrowser: vi.fn().mockRejectedValue(new Error('Navigation failed'))
|
||||||
|
}
|
||||||
|
const view = render(sidebarElement(props))
|
||||||
|
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
|
||||||
|
target: { value: 'https://example.com/' }
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '前往' }))
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Navigation failed')
|
||||||
|
|
||||||
|
if (transition === 'close') {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · error-browser' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · error-browser' })).not.toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
|
||||||
|
fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!)
|
||||||
|
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
|
||||||
|
} else if (transition === 'switch') {
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
|
||||||
|
expect(screen.queryByText('Navigation failed')).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '浏览器 · error-browser' }))
|
||||||
|
} else {
|
||||||
|
view.rerender(sidebarElement({ ...props, open: false }))
|
||||||
|
view.rerender(sidebarElement(props))
|
||||||
|
}
|
||||||
|
expect(screen.queryByText('Navigation failed')).not.toBeInTheDocument()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each(['close', 'switch', 'collapse'] as const)(
|
||||||
|
'ignores a late browser action failure after %s',
|
||||||
|
async (transition) => {
|
||||||
|
let rejectNavigation!: (reason: Error) => void
|
||||||
|
const props = {
|
||||||
|
tab: 'browser' as const,
|
||||||
|
activeConversationId: 'late-browser',
|
||||||
|
onNavigateBrowser: vi.fn(() => new Promise<void>((_resolve, reject) => {
|
||||||
|
rejectNavigation = reject
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const view = render(sidebarElement(props))
|
||||||
|
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
|
||||||
|
target: { value: 'https://example.com/' }
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '前往' }))
|
||||||
|
await waitFor(() => expect(props.onNavigateBrowser).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
if (transition === 'close') {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · late-browser' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · late-browser' })).not.toBeInTheDocument())
|
||||||
|
} else if (transition === 'switch') {
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '浏览器 · late-browser' }))
|
||||||
|
} else {
|
||||||
|
view.rerender(sidebarElement({ ...props, open: false }))
|
||||||
|
}
|
||||||
|
await act(async () => rejectNavigation(new Error('Late navigation failed')))
|
||||||
|
if (transition === 'collapse') view.rerender(sidebarElement(props))
|
||||||
|
expect(screen.queryByText('Late navigation failed')).not.toBeInTheDocument()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
it('keeps browser A selected and usable after switching to B, and binds a new browser to B', async () => {
|
it('keeps browser A selected and usable after switching to B, and binds a new browser to B', async () => {
|
||||||
const onNavigateBrowser = vi.fn(async () => undefined)
|
const onNavigateBrowser = vi.fn(async () => undefined)
|
||||||
const onBackBrowser = vi.fn(async () => undefined)
|
const onBackBrowser = vi.fn(async () => undefined)
|
||||||
@@ -1204,8 +1312,36 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
|
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · conversation-a' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · conversation-a' })).not.toBeInTheDocument())
|
||||||
|
expect(screen.queryByText('Close denied')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['create', 'close'] as const)(
|
||||||
|
'ignores a late browser %s failure after switching panels',
|
||||||
|
async (operation) => {
|
||||||
|
let rejectOperation!: (reason: Error) => void
|
||||||
|
const pending = new Promise<never>((_resolve, reject) => {
|
||||||
|
rejectOperation = reject
|
||||||
|
})
|
||||||
|
if (operation === 'create') browserApi.createTab.mockReturnValueOnce(pending)
|
||||||
|
else browserApi.closeTab.mockReturnValueOnce(pending)
|
||||||
|
renderSidebar({ tab: 'browser', activeConversationId: 'late-browser' })
|
||||||
|
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
|
||||||
|
if (operation === 'close') {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · late-browser' }))
|
||||||
|
await waitFor(() => expect(browserApi.closeTab).toHaveBeenCalledOnce())
|
||||||
|
}
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
|
||||||
|
await act(async () => rejectOperation(new Error('Late browser failure')))
|
||||||
|
expect(screen.queryByText('Late browser failure')).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '浏览器 · late-browser' }))
|
||||||
|
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(operation === 'create' ? 2 : 1))
|
||||||
|
expect(screen.queryByText('Late browser failure')).not.toBeInTheDocument()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
it('rebinds a released tab before retrying navigation and can close a released instance', async () => {
|
it('rebinds a released tab before retrying navigation and can close a released instance', async () => {
|
||||||
const conversationId = 'retry-browser'
|
const conversationId = 'retry-browser'
|
||||||
const onNavigateBrowser = vi.fn(async () => undefined)
|
const onNavigateBrowser = vi.fn(async () => undefined)
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ function BrowserToolbar({
|
|||||||
const refreshDisabled =
|
const refreshDisabled =
|
||||||
operationBlocked ||
|
operationBlocked ||
|
||||||
!sessionActive ||
|
!sessionActive ||
|
||||||
|
!authoritativeUrl ||
|
||||||
isLoading
|
isLoading
|
||||||
const goDisabled =
|
const goDisabled =
|
||||||
actionPending ||
|
actionPending ||
|
||||||
@@ -803,7 +804,23 @@ export function RightAssistantSidebar({
|
|||||||
const [taskScope, setTaskScope] = useState<WorkbarTaskScope>(
|
const [taskScope, setTaskScope] = useState<WorkbarTaskScope>(
|
||||||
initialLayout?.taskScope ?? 'current-project'
|
initialLayout?.taskScope ?? 'current-project'
|
||||||
)
|
)
|
||||||
const [actionError, setActionError] = useState('')
|
const [actionErrorState, setActionErrorState] = useState({
|
||||||
|
scope: { instanceId: activeWorkbarInstanceId, open },
|
||||||
|
message: ''
|
||||||
|
})
|
||||||
|
const actionErrorScope = actionErrorState.scope
|
||||||
|
const actionError = actionErrorState.message
|
||||||
|
if (actionErrorScope.instanceId !== activeWorkbarInstanceId || actionErrorScope.open !== open) {
|
||||||
|
setActionErrorState({ scope: { instanceId: activeWorkbarInstanceId, open }, message: '' })
|
||||||
|
}
|
||||||
|
const setActionError = useCallback((message: string): void => {
|
||||||
|
// Old callbacks cannot restore an error after leaving and returning to a panel.
|
||||||
|
setActionErrorState((current) =>
|
||||||
|
current.scope === actionErrorScope && actionErrorScope.open
|
||||||
|
? { ...current, message }
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
}, [actionErrorScope])
|
||||||
const [terminalSessionIds, setTerminalSessionIds] = useState<
|
const [terminalSessionIds, setTerminalSessionIds] = useState<
|
||||||
Record<string, string>
|
Record<string, string>
|
||||||
>({})
|
>({})
|
||||||
@@ -1271,7 +1288,7 @@ export function RightAssistantSidebar({
|
|||||||
tabId = binding?.tabId
|
tabId = binding?.tabId
|
||||||
conversationId = binding?.conversationId
|
conversationId = binding?.conversationId
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setActionError(
|
if (instance.id === activeWorkbarInstanceId) setActionError(
|
||||||
reason instanceof Error
|
reason instanceof Error
|
||||||
? reason.message
|
? reason.message
|
||||||
: t('sidebar.errors.closeBrowser')
|
: t('sidebar.errors.closeBrowser')
|
||||||
@@ -1283,7 +1300,7 @@ export function RightAssistantSidebar({
|
|||||||
try {
|
try {
|
||||||
await window.goodbuddy.browser.closeTab?.({ conversationId, tabId })
|
await window.goodbuddy.browser.closeTab?.({ conversationId, tabId })
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setActionError(
|
if (instance.id === activeWorkbarInstanceId) setActionError(
|
||||||
reason instanceof Error
|
reason instanceof Error
|
||||||
? reason.message
|
? reason.message
|
||||||
: t('sidebar.errors.closeBrowser')
|
: t('sidebar.errors.closeBrowser')
|
||||||
@@ -1326,6 +1343,8 @@ export function RightAssistantSidebar({
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
activeWorkbarInstanceId,
|
||||||
|
setActionError,
|
||||||
removeWorkbarInstance,
|
removeWorkbarInstance,
|
||||||
localizedAppDefinitions,
|
localizedAppDefinitions,
|
||||||
terminalAdapter,
|
terminalAdapter,
|
||||||
@@ -1654,7 +1673,7 @@ export function RightAssistantSidebar({
|
|||||||
: t('sidebar.errors.browserControlUnavailable')
|
: t('sidebar.errors.browserControlUnavailable')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [activeWorkbarInstance, ensureBrowserTab, hasUnboundRequestBrowser, open, t])
|
}, [activeWorkbarInstance, ensureBrowserTab, hasUnboundRequestBrowser, open, setActionError, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
Reference in New Issue
Block a user