docs(web): the composer bar is one session-maybe slot, not a swapped pair
fix(web): keep one composer bar DOM across the no-workspace transition The composer.bar slot moves from session to session-maybe scope: with no current session the entry still mounts, the machine faces (keyboard, stop, command) arrive undefined, and the bar renders its normal DOM inert via the disabled owner prop. DisabledInputBar and its parallel tree are gone, so the textarea node survives the cold-start workspace pick instead of flashing through a remount. A blank session whose workspace was deleted takes the same inert path through owner props. test(web): add the composer DOM-continuity acceptance probe Drives a real dsh web server with headless chromium through the cold-start -> pick-workspace -> type flow and asserts the composer textarea is the same DOM node throughout (a marker property must survive). Rerun prerequisites are in the header comment. docs(web): session-maybe identity is adoption, not hold-forever fix(web): session-maybe entries adopt the first session, then remount like strict entries A session-maybe entry used to keep one React instance across every transition, so component-local state leaked between sessions once the composer bar moved to that scope (PermissionSelect's optimistic pick, the IME composition guard). Identity is now adoption: an incarnation born session-less holds through the arrival of the first session (the blank shell's DOM survives the workspace pick), and afterwards behaves exactly like a strict session entry — a switch or a drop to no-session remounts, clearing local state by construction. The child key is an incarnation counter kept in the stable outlet wrapper via render-phase setState. chore: knip knows the root acceptance probe's playwright dependency scripts/hero-composer-dom-continuity.mjs resolves playwright through apps/web's devDependency tree (createRequire), which knip cannot follow; ignore it at the root workspace.
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
||||
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import type { InputNotice } from './input/contract.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
@@ -31,6 +32,19 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
|
||||
|
||||
// Static no-session sources for the composer-bar hooks compartment: module
|
||||
// constants so the render side's per-source hook cache (observableHook) keeps
|
||||
// one identity across every no-session render.
|
||||
const ABSENT_NOTICES = {
|
||||
getSnapshot: (): InputNotice | null => null,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
|
||||
const ABSENT_LEXICON = {
|
||||
getSnapshot: () => EMPTY_LEXICON,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
const scoped = sessions.scope(id)
|
||||
@@ -121,7 +135,7 @@ export function apply(ctx: Context): void {
|
||||
children: {
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
'conversation.composer.bar': { kind: 'single', scope: 'session' },
|
||||
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
|
||||
'conversation.input.overlay': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.dock': { kind: 'list', scope: 'session' },
|
||||
'conversation.composer.dock': { kind: 'list', scope: 'session' },
|
||||
@@ -166,6 +180,9 @@ export function apply(ctx: Context): void {
|
||||
// chain's fallback (decision 20). Public machine surface arrives via the
|
||||
// provide channel above; the keyboard command face and the stop/retry
|
||||
// verbs ride this inject (package-internal — hub and bar are one plugin).
|
||||
// Session-maybe: with no current session the machine faces are absent and
|
||||
// the hooks compartment binds static empty sources (module constants, so
|
||||
// observableHook caching and hook order stay stable across transitions).
|
||||
slots.register({
|
||||
name: 'conversation.composer.bar',
|
||||
// The two named control seats in the bar's tool row (plan beside the
|
||||
@@ -175,7 +192,16 @@ export function apply(ctx: Context): void {
|
||||
'conversation.input.plan': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
inject: (sessionId: SessionId): ComposerBarInjected => {
|
||||
inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
|
||||
if (sessionId === undefined) {
|
||||
return {
|
||||
keyboard: undefined,
|
||||
stop: undefined,
|
||||
command: undefined,
|
||||
translateHint,
|
||||
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
|
||||
}
|
||||
}
|
||||
const shell = inputHub.shell(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
|
||||
@@ -77,11 +77,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* The default composer body: a single slot rendered as the composer
|
||||
* chain's fallback (decision 20 — a real entry, not a chain rider, so a
|
||||
* takeover election hides rather than unmounts it and the textarea DOM
|
||||
* survives). InputBar registers here from this package's apply; its
|
||||
* machine state arrives through the standard provide channel (useInput +
|
||||
* inputActions), the keyboard command face through its own inject.
|
||||
* survives). Session-maybe: the bar stays mounted across the
|
||||
* no-session/session transition — the no-workspace hero renders the SAME
|
||||
* textarea DOM disabled instead of a parallel inert tree — with the
|
||||
* machine hooks absent until a session is current. InputBar registers
|
||||
* here from this package's apply; its machine state arrives through the
|
||||
* standard provide channel (useInput + inputActions), the keyboard
|
||||
* command face through its own inject.
|
||||
*/
|
||||
'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
|
||||
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
|
||||
/**
|
||||
* The Plan-mode status seat in the composer tool row (left group,
|
||||
* right of the access-mode control). Declared by the composer-bar
|
||||
@@ -244,6 +248,12 @@ export interface ConversationSessionInjected {
|
||||
export interface ComposerBarOwnerProps {
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
variant: 'hero' | 'composer'
|
||||
/**
|
||||
* Inert no-workspace state: the bar renders its normal DOM fully disabled
|
||||
* (textarea, add, send) so the workspace pick transitions in place instead
|
||||
* of swapping component trees.
|
||||
*/
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
/** Optional content rendered above the textarea. */
|
||||
accessory?: ReactNode
|
||||
@@ -261,19 +271,24 @@ export interface ComposerBarOwnerProps {
|
||||
|
||||
/** Injected share of the composer-bar entry (package-internal faces). */
|
||||
export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
|
||||
keyboard: ComposerKeyboard | undefined
|
||||
/** Cancel the in-flight turn; absent with the session. */
|
||||
stop: (() => void) | undefined
|
||||
/**
|
||||
* Submit one slash-command line against this session's agent (the chrome
|
||||
* controls' write path — the permission chip submits `/permission <preset>`).
|
||||
* controls' write path — the permission chip submits `/permission <preset>`);
|
||||
* absent with the session.
|
||||
* Resolves admission: false = rejected/unmatched/transport failure.
|
||||
*/
|
||||
command: (line: string) => Promise<boolean>
|
||||
/** Locale-aware hint translator for claimed command placeholders. */
|
||||
command: ((line: string) => Promise<boolean>) | undefined
|
||||
/** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */
|
||||
translateHint: (key: string) => string
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
/**
|
||||
* Registrant hooks compartment: the renderer binds these to
|
||||
* useNotices/useLexicon (static absent sources without a session — hook
|
||||
* order stays constant).
|
||||
*/
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
notices: ObservableSnapshot<InputNotice | null>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// Resident conversation skeleton. Hero chrome, composer positioning, and the
|
||||
// chain stay mounted across no-session/session transitions. Only the inert
|
||||
// input body swaps for the strict session InputBar.
|
||||
// Resident conversation skeleton. Hero chrome, composer positioning, the
|
||||
// chain, AND the composer bar (session-maybe slot) stay mounted across
|
||||
// no-session/session transitions — the bar renders inert via owner props.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import { DisabledInputBar } from './DisabledInputBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
/** Full props composed from the slot contract. */
|
||||
@@ -113,20 +112,23 @@ export function ConversationRoot({
|
||||
)
|
||||
|
||||
// The placeholder chip ("Choose workspace") and the inert input travel
|
||||
// together: a blank session whose workspace vanished (deleted from the
|
||||
// sidebar) reverts to the same disabled bar as the initial no-session state.
|
||||
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
|
||||
? <DisabledInputBar />
|
||||
: renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
// Stats band under the card, inside the bar's width column so both
|
||||
// share one constraint (composer.dock = stats-line family).
|
||||
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
|
||||
})
|
||||
// together: no workspace picked yet (cold start, no session at all), or a
|
||||
// blank session whose workspace vanished (deleted from the sidebar). The
|
||||
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
|
||||
// not a different tree, so the textarea DOM survives the transition.
|
||||
const inert = sessionId === undefined || (hero && chipTitle === undefined)
|
||||
const inputBar = renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(inert
|
||||
? { disabled: true, placeholder: 'Choose a workspace to start' }
|
||||
: hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
// Stats band under the card, inside the bar's width column so both
|
||||
// share one constraint (composer.dock = stats-line family).
|
||||
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
|
||||
})
|
||||
|
||||
const composerBar = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/** Inert no-session input body; the resident Hero shell renders around it. */
|
||||
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Disabled visual twin of the session-bound InputBar. */
|
||||
export function DisabledInputBar() {
|
||||
return (
|
||||
<div className={clsx(css.root, css.hero)}>
|
||||
<div className={css.card}>
|
||||
<div className={css.grow}>
|
||||
<textarea
|
||||
className={css.input}
|
||||
value=""
|
||||
disabled
|
||||
placeholder="Choose a workspace to start"
|
||||
rows={2}
|
||||
readOnly
|
||||
/>
|
||||
<div aria-hidden className={css.mirror}>{'\n'}</div>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<div className={css.tools}>
|
||||
<button type="button" className={css.add} aria-label="Add attachment" disabled>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
<button type="button" className={css.primary} aria-label="Send message" disabled>
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,9 +17,13 @@ import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
import type {} from '@deepseek-ai/dsh-goal/client'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import type { DraftDecorations } from '../input/decorations.ts'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Decoration product of the no-session state (no machine, empty draft). */
|
||||
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
|
||||
|
||||
/** Prompt failure surface (derived from promptError). */
|
||||
export interface InputBarError {
|
||||
op: 'send' | 'stop'
|
||||
@@ -29,15 +33,16 @@ export interface InputBarError {
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment',
|
||||
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
|
||||
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
|
||||
onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useNotices(s => s)
|
||||
const lexicon = useLexicon(s => s)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
const promptError = useSession(s => s.promptError) ?? null
|
||||
const running = useSession(s => s.running) ?? false
|
||||
const removed = useSession(s => s.removed) ?? false
|
||||
// Plan mode swaps the textarea placeholder (the projection is the folded
|
||||
// host value; owner-prop placeholders — hero, session-unavailable — win).
|
||||
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
|
||||
@@ -49,7 +54,10 @@ export function InputBar({
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
|
||||
const draft = input.draft
|
||||
// Session-maybe: the machine faces are absent together while no session is
|
||||
// current; the bar renders the same DOM inert instead of a parallel tree.
|
||||
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
|
||||
const draft = input?.draft ?? ''
|
||||
const empty = draft.trim() === ''
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
|
||||
@@ -68,16 +76,18 @@ export function InputBar({
|
||||
// (undefined = capability absent → the chip renders nothing).
|
||||
const permissions = useProjection('permissions')
|
||||
|
||||
// Queue cut 1: running input stays free; locked = session disabled only.
|
||||
// The transient machine locks (adjudicating pending / submitting) render
|
||||
// Queue cut 1: running input stays free; locked = session removed, the
|
||||
// inert no-workspace state, or the machine faces absent (no session). The
|
||||
// transient machine locks (adjudicating pending / submitting) render
|
||||
// read-only — the draft stays visible and focused, keystrokes drop.
|
||||
const disabled = removed || inert || !live
|
||||
const locked = disabled
|
||||
const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting'
|
||||
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
|
||||
|
||||
// Unlock (mount / session switch) returns focus to the box.
|
||||
useEffect(() => {
|
||||
if (!locked) inputRef.current?.focus()
|
||||
}, [locked])
|
||||
}, [locked, sessionId])
|
||||
|
||||
// Active conversation scrollport: chain the wheel. While the textarea (capped
|
||||
// at 14 lines with overflow-y:auto) can still move in this direction, keep
|
||||
@@ -101,6 +111,9 @@ export function InputBar({
|
||||
}, [])
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
// Absent machine (no session): the textarea is disabled so events cannot
|
||||
// fire; the guard narrows the faces for the paths below.
|
||||
if (keyboard === undefined || inputActions === undefined) return
|
||||
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
|
||||
// IME guard so a composition-closing Shift+Enter still breaks the line.
|
||||
if (e.key === 'Enter' && e.shiftKey) return
|
||||
@@ -162,6 +175,7 @@ export function InputBar({
|
||||
}
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
if (keyboard === undefined) return // absent machine: disabled textarea, no events
|
||||
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
||||
const next = e.target.value
|
||||
keyboard.setDraft(next)
|
||||
@@ -187,6 +201,7 @@ export function InputBar({
|
||||
/* oxlint-enable typescript/no-unnecessary-condition */
|
||||
|
||||
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
|
||||
if (input === undefined || keyboard === undefined) return // absent machine: disabled textarea, no events
|
||||
const el = e.currentTarget
|
||||
const { start, end } = selectionOf(el)
|
||||
if (start === end) return
|
||||
@@ -211,6 +226,7 @@ export function InputBar({
|
||||
}
|
||||
|
||||
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (keyboard === undefined) return // absent machine: disabled textarea, no events
|
||||
if (machineBusy || locked) return
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') return
|
||||
@@ -230,7 +246,7 @@ export function InputBar({
|
||||
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
|
||||
// Any caret/selection gesture ends a live paste attempt (the machine
|
||||
// cannot observe DOM selection). Cheap no-op when none is live.
|
||||
if (keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
|
||||
if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
|
||||
void e
|
||||
}
|
||||
|
||||
@@ -242,6 +258,7 @@ export function InputBar({
|
||||
|
||||
const primaryLabel = running ? 'Stop generating' : 'Send message'
|
||||
const onPrimary = (): void => {
|
||||
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
|
||||
if (running) {
|
||||
stop()
|
||||
return
|
||||
@@ -251,16 +268,17 @@ export function InputBar({
|
||||
}
|
||||
|
||||
// The Access seat: the projection-fed permission chip (renders nothing
|
||||
// while the permissions key is absent — permission-less host or Draft).
|
||||
const accessSelect: ReactNode = (
|
||||
<PermissionSelect value={permissions} locked={locked} command={command} />
|
||||
)
|
||||
// while the permissions key is absent — permission-less host or Draft —
|
||||
// or while the command face is absent with the session).
|
||||
const accessSelect: ReactNode = command === undefined
|
||||
? null
|
||||
: <PermissionSelect value={permissions} locked={locked} command={command} />
|
||||
|
||||
// Mirror-layer decorations: a visible backdrop with transparent text. The
|
||||
// claim token highlights through behind the textarea glyphs; each U+FFFC
|
||||
// placeholder renders as a chip (the textarea's own glyph is invisible, the
|
||||
// backdrop chip supplies the visual); the claim hint is ghost text.
|
||||
const deco = deriveDecorations(input, lexicon)
|
||||
const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon)
|
||||
const backdrop: ReactNode[] = []
|
||||
{
|
||||
// Segment boundaries: the token range end, every chip offset, and every
|
||||
@@ -322,7 +340,7 @@ export function InputBar({
|
||||
pushPlain(draft.length)
|
||||
if (deco.hint !== null) {
|
||||
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
|
||||
const commandName = input.claim?.token.slice(1).trim() ?? ''
|
||||
const commandName = input?.claim?.token.slice(1).trim() ?? ''
|
||||
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
|
||||
const translated = translateHint(hintKey)
|
||||
const displayHint = translated !== hintKey ? translated : deco.hint
|
||||
@@ -356,7 +374,7 @@ export function InputBar({
|
||||
value={draft}
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input.phase}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? 'Session unavailable'
|
||||
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
|
||||
|
||||
Reference in New Issue
Block a user