pi session context persistence, fix new session button, add clear all sessions
- handle pi process death by nulling piProcess ref so next message respawns - replay conversation history on respawn via --session flag - fix user message JSONL format to array for pi compatibility - fix container sessions mount to match storage path - fix ChatPanelWrapper: key on inner component so usePiChat resets on new session - add bulk delete sessions endpoint and clear all button in chat header Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,10 @@ type SandboxOptions = {
|
|||||||
homeDir: string;
|
homeDir: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SpawnPiOptions = {
|
||||||
|
sessionFile?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function spawnPi(
|
export async function spawnPi(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
model: string,
|
model: string,
|
||||||
@@ -202,6 +206,7 @@ export async function spawnPi(
|
|||||||
email: string,
|
email: string,
|
||||||
onEvent: PiEventHandler,
|
onEvent: PiEventHandler,
|
||||||
sandbox?: SandboxOptions,
|
sandbox?: SandboxOptions,
|
||||||
|
options?: SpawnPiOptions,
|
||||||
): Promise<Subprocess> {
|
): Promise<Subprocess> {
|
||||||
let proc: Subprocess;
|
let proc: Subprocess;
|
||||||
|
|
||||||
@@ -234,6 +239,7 @@ export async function spawnPi(
|
|||||||
...resourceSkillFlags,
|
...resourceSkillFlags,
|
||||||
];
|
];
|
||||||
if (model) piArgs.push('--model', model);
|
if (model) piArgs.push('--model', model);
|
||||||
|
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
|
||||||
|
|
||||||
const resourcesEnv = buildResourcesEnv();
|
const resourcesEnv = buildResourcesEnv();
|
||||||
|
|
||||||
@@ -284,6 +290,7 @@ export async function spawnPi(
|
|||||||
|
|
||||||
const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags];
|
const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags];
|
||||||
if (model) args.push('--model', model);
|
if (model) args.push('--model', model);
|
||||||
|
if (options?.sessionFile) args.push('--session', options.sessionFile);
|
||||||
|
|
||||||
if (!existsSync(cwd)) {
|
if (!existsSync(cwd)) {
|
||||||
mkdirSync(cwd, { recursive: true });
|
mkdirSync(cwd, { recursive: true });
|
||||||
|
|||||||
@@ -235,6 +235,39 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/pi/sessions
|
||||||
|
* Delete all sessions, optionally filtered by context
|
||||||
|
*/
|
||||||
|
piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
if (!user) {
|
||||||
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await ctx.req.json().catch(() => ({}));
|
||||||
|
const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined;
|
||||||
|
const userHome = getHomeDir(user.email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await storage.listUserSessions(userHome, contextFilter);
|
||||||
|
let deleted = 0;
|
||||||
|
for (const session of sessions) {
|
||||||
|
try {
|
||||||
|
await storage.deleteSession(userHome, session.id, session.groupSlug);
|
||||||
|
deleted++;
|
||||||
|
} catch {
|
||||||
|
// Skip sessions that fail to delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info('Bulk deleted sessions', { email: user.email, deleted, total: sessions.length, context: contextFilter?.context });
|
||||||
|
return ctx.json({ success: true, deleted });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to bulk delete sessions', { email: user.email, error: String(err) });
|
||||||
|
return ctx.json({ error: 'Failed to delete sessions' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/pi/sessions/search
|
* GET /api/pi/sessions/search
|
||||||
* Search sessions by query
|
* Search sessions by query
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export function messagesToJnlEntries(messages: Message[], meta: SessionMeta): Jn
|
|||||||
id,
|
id,
|
||||||
parentId: prevId,
|
parentId: prevId,
|
||||||
timestamp: new Date(msg.timestamp).toISOString(),
|
timestamp: new Date(msg.timestamp).toISOString(),
|
||||||
message: { role: 'user', content: msg.text ?? '' },
|
message: { role: 'user', content: [{ type: 'text' as const, text: msg.text ?? '' }] },
|
||||||
};
|
};
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
prevId = id;
|
prevId = id;
|
||||||
@@ -281,11 +281,16 @@ export function jnlEntriesToMessages(entries: JnlEntry[]): ParsedSession {
|
|||||||
const ts = new Date(entry.timestamp).getTime();
|
const ts = new Date(entry.timestamp).getTime();
|
||||||
|
|
||||||
if (msgEntry.message.role === 'user') {
|
if (msgEntry.message.role === 'user') {
|
||||||
|
const rawContent = msgEntry.message.content;
|
||||||
|
const text =
|
||||||
|
typeof rawContent === 'string'
|
||||||
|
? rawContent
|
||||||
|
: (rawContent as Array<JnlTextContent>).map((c) => c.text).join('\n');
|
||||||
messages.push({
|
messages.push({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
text: msgEntry.message.content as string,
|
text,
|
||||||
});
|
});
|
||||||
} else if (msgEntry.message.role === 'assistant') {
|
} else if (msgEntry.message.role === 'assistant') {
|
||||||
const contentBlocks = msgEntry.message.content as Array<JnlTextContent | JnlToolCall>;
|
const contentBlocks = msgEntry.message.content as Array<JnlTextContent | JnlToolCall>;
|
||||||
@@ -380,6 +385,15 @@ function metaToIndexEntry(meta: SessionMeta, relFile: string): SessionIndexEntry
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Path resolution ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getSessionFilePath(baseCwd: string, sessionId: string): Promise<string | null> {
|
||||||
|
const index = await loadIndex(baseCwd);
|
||||||
|
const entry = index[sessionId];
|
||||||
|
if (!entry) return null;
|
||||||
|
return path.join(getSessionsDir(baseCwd), entry.file);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Session CRUD ───────────────────────────────────────────────────────
|
// ── Session CRUD ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function saveSession(
|
export async function saveSession(
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export type JnlUserMessage = JnlEntryBase & {
|
|||||||
type: 'message';
|
type: 'message';
|
||||||
message: {
|
message: {
|
||||||
role: 'user';
|
role: 'user';
|
||||||
content: string;
|
content: string | Array<JnlTextContent>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -286,8 +286,37 @@ async function handleChat(
|
|||||||
if (!session.piProcess) {
|
if (!session.piProcess) {
|
||||||
try {
|
try {
|
||||||
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
||||||
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
|
const sandbox = sandboxed ? { userId, username, email, homeDir } : undefined;
|
||||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
|
|
||||||
|
// If session has history, save to disk and pass --session for context replay
|
||||||
|
let spawnOptions: { sessionFile?: string } | undefined;
|
||||||
|
if (session.messages.length > 0) {
|
||||||
|
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
|
||||||
|
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
|
||||||
|
if (hostPath) {
|
||||||
|
if (sandboxed) {
|
||||||
|
const containerHome = `/home/${username}`;
|
||||||
|
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
|
||||||
|
const relativePart = hostPath.slice(sessionsPrefix.length);
|
||||||
|
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
|
||||||
|
} else {
|
||||||
|
spawnOptions = { sessionFile: hostPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandbox, spawnOptions);
|
||||||
|
|
||||||
|
// Null out piProcess when the process dies so next message triggers respawn
|
||||||
|
const proc = session.piProcess;
|
||||||
|
proc.exited.then(() => {
|
||||||
|
if (session.piProcess === proc) {
|
||||||
|
session.piProcess = null;
|
||||||
|
logger.info('Pi process exited, nulled reference', { sessionId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||||
@@ -361,8 +390,36 @@ async function handleResume(
|
|||||||
const homeDir = getHomeDir(email);
|
const homeDir = getHomeDir(email);
|
||||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
||||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
|
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
|
||||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox);
|
|
||||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
|
// If session has history, save to disk and pass --session for context replay
|
||||||
|
let spawnOptions: { sessionFile?: string } | undefined;
|
||||||
|
if (session.messages.length > 0) {
|
||||||
|
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
|
||||||
|
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
|
||||||
|
if (hostPath) {
|
||||||
|
if (sandbox) {
|
||||||
|
const containerHome = `/home/${ws.data.username}`;
|
||||||
|
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
|
||||||
|
const relativePart = hostPath.slice(sessionsPrefix.length);
|
||||||
|
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
|
||||||
|
} else {
|
||||||
|
spawnOptions = { sessionFile: hostPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox, spawnOptions);
|
||||||
|
|
||||||
|
// Null out piProcess when the process dies so next message triggers respawn
|
||||||
|
const proc = session.piProcess;
|
||||||
|
proc.exited.then(() => {
|
||||||
|
if (session.piProcess === proc) {
|
||||||
|
session.piProcess = null;
|
||||||
|
logger.info('Pi process exited, nulled reference', { sessionId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
||||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
|
|||||||
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
|
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
|
||||||
'-v', `${PI_CONFIG_DIR}:/officer/pi-config:ro`,
|
'-v', `${PI_CONFIG_DIR}:/officer/pi-config:ro`,
|
||||||
'-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`,
|
'-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`,
|
||||||
'-v', `${ensureDir(join(DATA_PATH, email, 'pi-sessions'))}:${containerHome}/.pi/agent/sessions`,
|
'-v', `${ensureDir(join(getHomeDir(email), '.pi', 'agent', 'sessions'))}:${containerHome}/.pi/agent/sessions`,
|
||||||
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
|
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
|
||||||
...googleMounts,
|
...googleMounts,
|
||||||
'-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`,
|
'-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { MessageSquare, History, Plus } from 'lucide-react';
|
import { MessageSquare, History, Plus, Trash2 } from 'lucide-react';
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
|
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
|
||||||
import { useWorkspace } from '../../components/Workspace/WorkspaceContext';
|
import { useWorkspace } from '../../components/Workspace/WorkspaceContext';
|
||||||
import { useChatSessions } from 'state/useChatSessions';
|
import { useChatSessions } from 'state/useChatSessions';
|
||||||
@@ -29,7 +29,7 @@ export const ChatHeader = () => {
|
|||||||
: workspaceId && !workspaceId.startsWith('screens/')
|
: workspaceId && !workspaceId.startsWith('screens/')
|
||||||
? { context: 'workspace' as const, contextId: workspaceId }
|
? { context: 'workspace' as const, contextId: workspaceId }
|
||||||
: undefined;
|
: undefined;
|
||||||
const { sessions } = useChatSessions(contextFilter);
|
const { sessions, clearSessions } = useChatSessions(contextFilter);
|
||||||
const [selection, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
|
const [selection, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
|
||||||
const [activeSessionId] = usePanelChannel<string | null>('chat:active-session', null);
|
const [activeSessionId] = usePanelChannel<string | null>('chat:active-session', null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -69,6 +69,20 @@ export const ChatHeader = () => {
|
|||||||
<PopoverContent align="end" className="w-72 p-0 max-h-80 flex flex-col">
|
<PopoverContent align="end" className="w-72 p-0 max-h-80 flex flex-col">
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||||
<span className="text-xs font-medium">Sessions</span>
|
<span className="text-xs font-medium">Sessions</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await clearSessions();
|
||||||
|
selectSession(null);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-destructive cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectSession(null)}
|
onClick={() => selectSession(null)}
|
||||||
@@ -78,6 +92,7 @@ export const ChatHeader = () => {
|
|||||||
New
|
New
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{/* Show active session at top if not yet in fetched list */}
|
{/* Show active session at top if not yet in fetched list */}
|
||||||
{activeSessionId && !activeInList && (
|
{activeSessionId && !activeInList && (
|
||||||
|
|||||||
@@ -9,6 +9,39 @@ type ChatSessionSelection = {
|
|||||||
model?: string | null;
|
model?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ChatPanelInnerProps = {
|
||||||
|
sessionId?: string;
|
||||||
|
model?: string;
|
||||||
|
scoped: boolean;
|
||||||
|
sandboxed: boolean;
|
||||||
|
cwdParam?: { root?: string; path: string };
|
||||||
|
promptPrefix?: string;
|
||||||
|
chatContext: Record<string, string | undefined>;
|
||||||
|
setActiveSession: (id: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession }: ChatPanelInnerProps) => {
|
||||||
|
const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, ...chatContext });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveSession(chat.sessionId);
|
||||||
|
}, [chat.sessionId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddableChat
|
||||||
|
className="h-full"
|
||||||
|
chat={chat}
|
||||||
|
sessionId={sessionId}
|
||||||
|
initialModel={model}
|
||||||
|
cwd={cwdParam}
|
||||||
|
sandboxed={sandboxed}
|
||||||
|
replaceUrl={false}
|
||||||
|
promptPrefix={promptPrefix}
|
||||||
|
{...chatContext}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ChatPanelWrapper = () => {
|
export const ChatPanelWrapper = () => {
|
||||||
const { workspaceId, cwd, root, promptPrefix } = useWorkspace();
|
const { workspaceId, cwd, root, promptPrefix } = useWorkspace();
|
||||||
const scoped = cwd !== '~';
|
const scoped = cwd !== '~';
|
||||||
@@ -31,24 +64,17 @@ export const ChatPanelWrapper = () => {
|
|||||||
const sessionId = selection?.sessionId ?? undefined;
|
const sessionId = selection?.sessionId ?? undefined;
|
||||||
const model = selection?.model ?? undefined;
|
const model = selection?.model ?? undefined;
|
||||||
|
|
||||||
const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, ...chatContext });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveSession(chat.sessionId);
|
|
||||||
}, [chat.sessionId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmbeddableChat
|
<ChatPanelInner
|
||||||
key={sessionId ?? 'new'}
|
key={sessionId ?? 'new'}
|
||||||
className="h-full"
|
|
||||||
chat={chat}
|
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
initialModel={model}
|
model={model}
|
||||||
cwd={cwdParam}
|
scoped={scoped}
|
||||||
sandboxed={sandboxed}
|
sandboxed={sandboxed}
|
||||||
replaceUrl={false}
|
cwdParam={cwdParam}
|
||||||
promptPrefix={promptPrefix}
|
promptPrefix={promptPrefix}
|
||||||
{...chatContext}
|
chatContext={chatContext}
|
||||||
|
setActiveSession={setActiveSession}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export function useChatSessions(filter?: ChatSessionsFilter) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearSessions() {
|
||||||
|
await client.delete('/pi/sessions', {
|
||||||
|
...(filter?.context ? { context: filter.context } : {}),
|
||||||
|
...(filter?.contextId ? { contextId: filter.contextId } : {}),
|
||||||
|
});
|
||||||
|
queryClient.setQueryData<SessionEntry[]>(['PI_SESSIONS', filter?.context, filter?.contextId], []);
|
||||||
|
}
|
||||||
|
|
||||||
function searchSessions(query: string) {
|
function searchSessions(query: string) {
|
||||||
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
|
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
|
||||||
}
|
}
|
||||||
@@ -60,6 +68,7 @@ export function useChatSessions(filter?: ChatSessionsFilter) {
|
|||||||
saveMessages,
|
saveMessages,
|
||||||
renameSession,
|
renameSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
clearSessions,
|
||||||
searchSessions,
|
searchSessions,
|
||||||
invalidate,
|
invalidate,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user